[php-src] master: Merge branch 'PHP-8.6'
| From: | Alexandre Daubois | Date: | Tue, 06 Oct 2026 07:12:18 +0000 |
| Subject: | [php-src] master: Merge branch 'PHP-8.6' | ||
| Groups: | php.cvs | ||
| Request: | Send a blank email to php-cvs+get-141994@lists.php.net to get a copy of this message | ||
Author: Alexandre Daubois (alexandre-daubois)
Date: 2026-10-06T09:11:01+02:00
Commit: https://github.com/php/php-src/commit/17277c619fbfeb4754722c9464d51d51825ffef0
Raw diff: https://github.com/php/php-src/commit/17277c619fbfeb4754722c9464d51d51825ffef0.diff
Merge branch 'PHP-8.6'
* PHP-8.6:
lexbor: Merge upstream WHATWG URL and IDNA fixes
Changed paths:
A ext/lexbor/patches/0016-URL-fixed-tail-pointer-in-URLSearchParams-for-delimi.patch
A ext/lexbor/patches/0017-URL-Fix-parsing-of-usernames-containing.patch
A ext/lexbor/patches/0018-Unicode-fixed-uninitialized-memory-in-IDNA-buffer-gr.patch
A ext/lexbor/patches/0019-URL-encode-opaque-path-spaces-before-query-and-fragm.patch
A ext/lexbor/patches/0020-URL-Fix-lxb_url_serialize_fragment-without-a-query-4.patch
A ext/lexbor/patches/0021-URL-expose-component-reset-functions.-415.patch
A ext/lexbor/patches/0022-URL-Fix-parsing-of-query-and-fragment-after-a-dot-co.patch
A ext/lexbor/patches/0023-URL-Keep-replacement-file-drive-paths-hierarchical-4.patch
A ext/lexbor/patches/0024-URL-normalize-output-encoding-for-percent-encoding.patch
A ext/uri/tests/whatwg/modification/withPath_success_file_invalid_drive_letter.phpt
A ext/uri/tests/whatwg/parsing/host_success_multibyte_long.phpt
A ext/uri/tests/whatwg/parsing/path_success_dot_segment_delimiters.phpt
A ext/uri/tests/whatwg/parsing/path_success_opaque_space_before_query.phpt
A ext/uri/tests/whatwg/parsing/username_success_at_sign.phpt
M NEWS
M ext/lexbor/lexbor/unicode/idna.c
M ext/lexbor/lexbor/url/url.c
M ext/lexbor/lexbor/url/url.h
M ext/lexbor/patches/0001-Expose-line-and-column-information-for-use-in-PHP.patch
M ext/lexbor/patches/0002-Track-implied-added-nodes-for-options-use-in-PHP.patch
M ext/lexbor/patches/0003-Patch-utilities-and-data-structure-to-be-able-to-gen.patch
M ext/lexbor/patches/0004-Remove-unused-upper-case-tag-static-data.patch
M ext/lexbor/patches/0005-Shrink-size-of-static-binary-search-tree.patch
M ext/lexbor/patches/0006-Patch-out-unused-CSS-style-code.patch
M ext/lexbor/patches/0007-Add-lxb_url_is_special-to-the-public-API-362.patch
M ext/lexbor/patches/0008-URL-fixed-setters-for-empty-hosts.patch
M ext/lexbor/patches/0009-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch
M ext/lexbor/patches/0010-Fix-parsing-for-URL-containing-empty-host-and-userin.patch
M ext/lexbor/patches/0011-Percent-encode-the-caret-in-the-path.patch
M ext/lexbor/patches/0012-URL-added-public-IPv6-parser.patch
M ext/lexbor/patches/0013-URL-added-public-percent-encoder-API.patch
M ext/lexbor/patches/0014-CSS-fixed-heap-buffer-overflow-in-lexbor-contains-pa.patch
M ext/lexbor/patches/0015-Encoding-fixed-buffer-overflows-in-malformed-decode-.patch
M ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt
M ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt
Diff:
diff --git a/NEWS b/NEWS
index 8d0f7c815b6e..07d329ef5a60 100644
--- a/NEWS
+++ b/NEWS
@@ -23,6 +23,15 @@ PHP NEWS
. Merge patches lexbor/lexbor@8a14bc0 and lexbor/lexbor@f67ce4b, fixing a
heap buffer overflow in :lexbor-contains() parsing and buffer overflows
in malformed decode replay. (alexandre-daubois)
+ . Merge patches lexbor/lexbor@859f100, lexbor/lexbor@a36e09a,
+ lexbor/lexbor@b0f7412, lexbor/lexbor@1b215a8, lexbor/lexbor@385afff,
+ lexbor/lexbor@e6c068f, lexbor/lexbor@327a8b6, lexbor/lexbor@917742f and
+ lexbor/lexbor@e89c258, fixing dropped usernames containing an at sign,
+ uninitialized memory in IDNA buffer growth, the encoding of a space
+ before a query or fragment in an opaque path, a query or fragment lost
+ after a dot segment in a path, replacement file drive paths, the output
+ encoding used for percent-encoding, the URLSearchParams tail pointer and
+ fragment serialization without a query. (alexandre-daubois)
- MBString:
. Fixed bug GH-23106 (mb_strpos() reads past the end of a haystack ending in
diff --git a/ext/lexbor/lexbor/unicode/idna.c b/ext/lexbor/lexbor/unicode/idna.c
index 754f6b2026de..b31ae3338b92 100644
--- a/ext/lexbor/lexbor/unicode/idna.c
+++ b/ext/lexbor/lexbor/unicode/idna.c
@@ -117,12 +117,14 @@ lxb_unicode_idna_realloc(lxb_codepoint_t *buf, const lxb_codepoint_t *buffer,
lxb_codepoint_t *tmp;
nlen = ((*buf_end - buf) * 4) + len;
-
+
if (buf == buffer) {
tmp = lexbor_malloc(nlen * sizeof(lxb_codepoint_t));
if (tmp == NULL) {
return NULL;
}
+
+ memcpy(tmp, buf, (*buf_p - buf) * sizeof(lxb_codepoint_t));
}
else {
tmp = lexbor_realloc(buf, nlen * sizeof(lxb_codepoint_t));
@@ -458,13 +460,17 @@ lxb_unicode_idna_ascii_puny_cb(const lxb_char_t *data, size_t length, void
*ctx,
if (asc->buf == asc->buffer) {
tmp = lexbor_malloc(nlen);
+ if (tmp == NULL) {
+ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
+ }
+
+ memcpy(tmp, asc->buf, asc->p - asc->buf);
}
else {
tmp = lexbor_realloc(asc->buf, nlen);
- }
-
- if (tmp == NULL) {
- return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
+ if (tmp == NULL) {
+ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
+ }
}
asc->p = tmp + (asc->p - asc->buf);
@@ -711,13 +717,17 @@ lxb_unicode_idna_to_unicode_cb(const lxb_codepoint_t *part, size_t len,
if (asc->buf == asc->buffer) {
tmp = lexbor_malloc(nlen);
+ if (tmp == NULL) {
+ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
+ }
+
+ memcpy(tmp, asc->buf, asc->p - asc->buf);
}
else {
tmp = lexbor_realloc(asc->buf, nlen);
- }
-
- if (tmp == NULL) {
- return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
+ if (tmp == NULL) {
+ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
+ }
}
asc->p = tmp + (asc->p - asc->buf);
diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c
index 69d91969a6a1..c88f8e7a7ad6 100644
--- a/ext/lexbor/lexbor/url/url.c
+++ b/ext/lexbor/lexbor/url/url.c
@@ -532,15 +532,15 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
const lxb_char_t *data, const lxb_char_t *end, bool bqs);
static lxb_status_t
-lxb_url_path_try_dot(lxb_url_t *url, const lxb_char_t **begin,
- const lxb_char_t **last, const lxb_char_t **start,
- const lxb_char_t *end, bool bqs);
+lxb_url_path_try_dot(lxb_url_parser_t *parser, lxb_url_t *url,
+ const lxb_char_t **begin, const lxb_char_t **last,
+ const lxb_char_t **start, const lxb_char_t *end, bool bqs);
static const lxb_char_t *
-lxb_url_path_dot_count(lxb_url_t *url, const lxb_char_t *p,
- const lxb_char_t *end, const lxb_char_t *sbuf_begin,
- lxb_char_t **sbuf, lxb_char_t **last, size_t *path_count,
- bool bqs);
+lxb_url_path_dot_count(lxb_url_parser_t *parser, lxb_url_t *url,
+ const lxb_char_t *p, const lxb_char_t *end,
+ const lxb_char_t *sbuf_begin, lxb_char_t **sbuf,
+ lxb_char_t **last, size_t *path_count, bool bqs);
static void
lxb_url_path_fix_windows_drive(lxb_url_t *url, lxb_char_t *sbuf,
@@ -909,7 +909,7 @@ lxb_url_scheme_copy_special(const lxb_url_scheme_data_t *src,
return lxb_url_str_copy(&src->name, &dst->name, dst_mraw);
}
-static void
+void
lxb_url_path_set_null(lxb_url_t *url)
{
if (url->path.str.data == NULL) {
@@ -976,23 +976,25 @@ lxb_url_path_shorten(lxb_url_t *url)
}
}
- if (url->path.str.data != NULL) {
- url->path.length -= 1;
+ if (url->path.length == 0 || str->data == NULL) {
+ return;
+ }
- begin = str->data;
- p = begin + str->length;
+ url->path.length -= 1;
- while (p > begin) {
- p -= 1;
+ begin = str->data;
+ p = begin + str->length;
- if (*p == '/') {
- *p = '\0';
- break;
- }
- }
+ while (p > begin) {
+ p -= 1;
- str->length = p - begin;
+ if (*p == '/') {
+ *p = '\0';
+ break;
+ }
}
+
+ str->length = p - begin;
}
static lxb_status_t
@@ -1133,7 +1135,7 @@ lxb_url_host_destroy(lxb_url_host_t *host, lexbor_mraw_t *mraw)
}
}
-static void
+void
lxb_url_host_set_empty(lxb_url_host_t *host, lexbor_mraw_t *mraw)
{
lxb_url_host_destroy(host, mraw);
@@ -1183,7 +1185,15 @@ lxb_url_port_set(lxb_url_t *url, uint16_t port)
url->has_port = true;
}
-static void
+void
+lxb_url_query_set_null(lxb_url_t *url)
+{
+ if (url->query.data != NULL) {
+ (void) lexbor_str_destroy(&url->query, url->mraw, false);
+ }
+}
+
+void
lxb_url_fragment_set_null(lxb_url_t *url)
{
if (url->fragment.data != NULL) {
@@ -1204,6 +1214,26 @@ lxb_url_encoding_init(const lxb_encoding_data_t *encoding,
(void) lxb_encoding_encode_init_single(encode, encoding);
}
+/*
+ * https://encoding.spec.whatwg.org/#get-an-output-encoding
+ */
+lxb_inline lxb_encoding_t
+lxb_url_output_encoding(lxb_encoding_t encoding)
+{
+ switch (encoding) {
+ case LXB_ENCODING_DEFAULT:
+ case LXB_ENCODING_AUTO:
+ case LXB_ENCODING_UNDEFINED:
+ case LXB_ENCODING_REPLACEMENT:
+ case LXB_ENCODING_UTF_16BE:
+ case LXB_ENCODING_UTF_16LE:
+ return LXB_ENCODING_UTF_8;
+
+ default:
+ return encoding;
+ }
+}
+
static bool
lxb_url_start_windows_drive_letter(const lxb_char_t *data,
const lxb_char_t *end)
@@ -1348,12 +1378,7 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
state = override_state;
}
- if (encoding <= LXB_ENCODING_UNDEFINED
- || encoding == LXB_ENCODING_UTF_16BE
- || encoding == LXB_ENCODING_UTF_16LE)
- {
- encoding = LXB_ENCODING_UTF_8;
- }
+ encoding = lxb_url_output_encoding(encoding);
enc = lxb_encoding_data(encoding);
if (enc == NULL) {
@@ -1739,16 +1764,14 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
break;
}
- if (pswd == NULL || !at_sign) {
- tmp = (pswd != NULL) ? pswd - 1 : p;
-
- if (tmp > begin) {
- status = lxb_url_percent_encode_after_utf_8(begin,
- tmp, &url->username, url->mraw, lxb_url_map,
- LXB_URL_MAP_USERINFO, false);
- if (status != LXB_STATUS_OK) {
- lxb_url_parse_return(orig_data, buf, status);
- }
+ tmp = (pswd != NULL) ? pswd - 1 : p;
+ if (tmp > begin) {
+ status = lxb_url_percent_encode_after_utf_8(begin, tmp,
+ &url->username, url->mraw,
+ lxb_url_map, LXB_URL_MAP_USERINFO,
+ false);
+ if (status != LXB_STATUS_OK) {
+ lxb_url_parse_return(orig_data, buf, status);
}
}
@@ -2078,7 +2101,6 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
}
lxb_url_path_set_null(url);
- url->path.opaque = true;
}
}
@@ -2127,6 +2149,8 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
if (status != LXB_STATUS_OK) {
lxb_url_parse_return(orig_data, buf, status);
}
+
+ url->path.length += 1;
}
}
}
@@ -2268,7 +2292,13 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
&& url->host.type == LXB_URL_HOST_TYPE__UNDEF)
{
status = lxb_url_path_append(url, mp_str.data, mp_str.length);
- lxb_url_parse_return(orig_data, buf, status);
+ if (status != LXB_STATUS_OK) {
+ lxb_url_parse_return(orig_data, buf, status);
+ }
+
+ url->path.length += 1;
+
+ lxb_url_parse_return(orig_data, buf, LXB_STATUS_OK);
}
lxb_url_parse_return(orig_data, buf, LXB_STATUS_OK);
@@ -2329,6 +2359,17 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
lxb_url_parse_return(orig_data, buf, status);
}
+ /* Encode only the space immediately before a query or fragment. */
+ if (p > begin && p[-1] == ' ') {
+ tmp_str.length--;
+ if (lexbor_str_append(&tmp_str, url->mraw,
+ (const lxb_char_t *) "%20", 3) == NULL)
+ {
+ lxb_url_parse_return(orig_data, buf,
+ LXB_STATUS_ERROR_MEMORY_ALLOCATION);
+ }
+ }
+
status = lxb_url_path_list_push(url, &tmp_str);
if (status != LXB_STATUS_OK) {
lxb_url_parse_return(orig_data, buf, status);
@@ -2506,13 +2547,8 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
|| lexbor_str_res_map_hex[p[1]] == 0xff
|| lexbor_str_res_map_hex[p[2]] == 0xff)
{
- status = lxb_url_log_append(parser, p,
- LXB_URL_ERROR_TYPE_INVALID_URL_UNIT);
- if (status != LXB_STATUS_OK) {
- return NULL;
- }
-
- p = (end - p < 3) ? end - 1 : p + 2;
+ /* Reprocess the segment without skipping delimiters. */
+ goto slow;
}
else if (p[1] == '2' && (p[2] == 'e' || p[2] ==
'E')
&& (p == begin
@@ -2521,8 +2557,8 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
{
url->path.length = count;
- status = lxb_url_path_try_dot(url, &begin, &last,
- &p, end, bqs);
+ status = lxb_url_path_try_dot(parser, url, &begin,
+ &last, &p, end, bqs);
if (status != LXB_STATUS_OK) {
return NULL;
}
@@ -2560,8 +2596,8 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
{
url->path.length = count;
- status = lxb_url_path_try_dot(url, &begin, &last,
- &p, end, bqs);
+ status = lxb_url_path_try_dot(parser, url, &begin,
+ &last, &p, end, bqs);
if (status != LXB_STATUS_OK) {
return NULL;
}
@@ -2570,17 +2606,7 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
}
}
else {
- url->path.length = count;
-
- if (last - 1 > begin) {
- status = lxb_url_path_append(url, begin,
- (last - 1) - begin);
- if (status != LXB_STATUS_OK) {
- return NULL;
- }
- }
-
- return lxb_url_path_slow_path(parser, url, last, end, bqs);
+ goto slow;
}
}
}
@@ -2590,13 +2616,22 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
return NULL;
}
- if (count == 0 || p != begin) {
- count += 1;
- }
+ url->path.length = count + 1;
+
+ return p;
+
+slow:
url->path.length = count;
- return p;
+ if (last > begin) {
+ status = lxb_url_path_append(url, begin, (last - 1) - begin);
+ if (status != LXB_STATUS_OK) {
+ return NULL;
+ }
+ }
+
+ return lxb_url_path_slow_path(parser, url, last, end, bqs);
}
/*
@@ -2692,10 +2727,6 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
count += 1;
last = sbuf;
-
- if (p + 1 >= end) {
- count += 1;
- }
}
else if (c == '\\' && lxb_url_is_special(url)) {
status = lxb_url_log_append(parser, p,
@@ -2713,16 +2744,8 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
count += 1;
last = sbuf;
-
- if (p + 1 >= end) {
- count += 1;
- }
}
else if ((c == '?' || c == '#') && bqs) {
- lxb_url_path_fix_windows_drive(url, last, sbuf, count);
-
- count += 1;
- last = sbuf;
break;
}
else if (lxb_url_map[c] & LXB_URL_MAP_PATH) {
@@ -2742,11 +2765,19 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
}
else if (c == '.') {
if (last == sbuf) {
- tmp = lxb_url_path_dot_count(url, p, end, sbuf_begin,
+ tmp = lxb_url_path_dot_count(parser, url, p, end, sbuf_begin,
&sbuf, &last, &count, bqs);
+ if (tmp == NULL) {
+ goto failed;
+ }
if (tmp != p) {
- p = tmp + 1;
+ /* Skip '/' or '\', but leave '?' and
'#' to the loop. */
+ if (tmp < end && *tmp != '?' && *tmp !=
'#') {
+ tmp += 1;
+ }
+
+ p = tmp;
continue;
}
}
@@ -2771,11 +2802,19 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
else if (p[1] == '2' && (p[2] == 'e' || p[2] ==
'E')
&& last == sbuf)
{
- tmp = lxb_url_path_dot_count(url, p, end, sbuf_begin,
+ tmp = lxb_url_path_dot_count(parser, url, p, end, sbuf_begin,
&sbuf, &last, &count, bqs);
+ if (tmp == NULL) {
+ goto failed;
+ }
if (tmp != p) {
- p = tmp + 1;
+ /* Skip '/' or '\', but leave '?' and
'#' to the loop. */
+ if (tmp < end && *tmp != '?' && *tmp !=
'#') {
+ tmp += 1;
+ }
+
+ p = tmp;
continue;
}
}
@@ -2804,12 +2843,9 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
p += 1;
}
- if (count == 0 || last < sbuf) {
- lxb_url_path_fix_windows_drive(url, last, sbuf, count);
- count += 1;
- }
+ lxb_url_path_fix_windows_drive(url, last, sbuf, count);
- url->path.length = count;
+ url->path.length = count + 1;
status = lxb_url_path_append_wo_slash(url, sbuf_begin, sbuf - sbuf_begin);
if (status != LXB_STATUS_OK) {
@@ -2832,13 +2868,12 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
}
static lxb_status_t
-lxb_url_path_try_dot(lxb_url_t *url, const lxb_char_t **begin,
- const lxb_char_t **last, const lxb_char_t **start,
- const lxb_char_t *end, bool bqs)
+lxb_url_path_try_dot(lxb_url_parser_t *parser, lxb_url_t *url,
+ const lxb_char_t **begin, const lxb_char_t **last,
+ const lxb_char_t **start, const lxb_char_t *end, bool bqs)
{
unsigned count;
lxb_char_t c;
- lexbor_str_t *str;
lxb_status_t status;
const lxb_char_t *p;
@@ -2883,40 +2918,54 @@ lxb_url_path_try_dot(lxb_url_t *url, const lxb_char_t **begin,
}
}
- if (p < end) {
- *start = p;
- *begin = p + 1;
- *last = *begin;
+ if (count == 2) {
+ lxb_url_path_shorten(url);
}
- else {
+
+ if (p >= end) {
+ /* The caller appends the trailing empty segment. */
*start = end - 1;
*begin = end;
*last = end;
+
+ return LXB_STATUS_OK;
}
- if (count == 2) {
- lxb_url_path_shorten(url);
+ if (*p == '?' || *p == '#') {
+ /* The caller's loop handles the delimiter and the empty segment. */
+ *start = p - 1;
+ *begin = p;
+ *last = p;
+
+ return LXB_STATUS_OK;
}
- else if (count == 1) {
- str = &url->path.str;
- if (str->length > 0 && str->data[str->length - 1] == '/') {
- str->length -= 1;
- str->data[str->length] = '\0';
+ if (*p == '\\') {
+ status = lxb_url_log_append(parser, p,
+ LXB_URL_ERROR_TYPE_INVALID_REVERSE_SOLIDUS);
+ if (status != LXB_STATUS_OK) {
+ return status;
}
}
+ /* Skip '/' or '\'. */
+
+ *start = p;
+ *begin = p + 1;
+ *last = *begin;
+
return LXB_STATUS_OK;
}
static const lxb_char_t *
-lxb_url_path_dot_count(lxb_url_t *url, const lxb_char_t *p,
- const lxb_char_t *end, const lxb_char_t *sbuf_begin,
- lxb_char_t **sbuf, lxb_char_t **last, size_t *path_count,
- bool bqs)
+lxb_url_path_dot_count(lxb_url_parser_t *parser, lxb_url_t *url,
+ const lxb_char_t *p, const lxb_char_t *end,
+ const lxb_char_t *sbuf_begin, lxb_char_t **sbuf,
+ lxb_char_t **last, size_t *path_count, bool bqs)
{
unsigned count;
lxb_char_t c, *last_p;
+ lxb_status_t status;
const lxb_char_t *begin;
count = 0;
@@ -2953,6 +3002,14 @@ lxb_url_path_dot_count(lxb_url_t *url, const lxb_char_t *p,
return begin;
}
+ if (p < end && *p == '\\') {
+ status = lxb_url_log_append(parser, p,
+ LXB_URL_ERROR_TYPE_INVALID_REVERSE_SOLIDUS);
+ if (status != LXB_STATUS_OK) {
+ return NULL;
+ }
+ }
+
if (url->scheme.type == LXB_URL_SCHEMEL_TYPE_FILE
&& *path_count == 1
&& lxb_url_normalized_windows_drive_letter(sbuf_begin + 1, *last - 1))
@@ -3180,7 +3237,7 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
const lxb_char_t *buf_end = buf + sizeof(buffer);
static const lexbor_str_t esc_str = lexbor_str("%26%23");
- if (encoding->encoding == LXB_ENCODING_UTF_8) {
+ if (lxb_url_output_encoding(encoding->encoding) == LXB_ENCODING_UTF_8) {
return lxb_url_percent_encode_after_utf_8(data, end, str, mraw,
url_map, enmap,
space_as_plus);
@@ -3216,13 +3273,14 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
len = encoding->encode_single(&encode, &buf, buf_end, cp);
if (len < LXB_ENCODING_ENCODE_OK) {
- size = lexbor_conv_int64_to_data((int64_t) cp, buf, buf_end - buf);
+ size = lexbor_conv_int64_to_data((int64_t) cp, buffer,
+ sizeof(buffer));
if (lexbor_str_append(str, mraw, esc_str.data, esc_str.length) == NULL) {
return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
}
- if (lexbor_str_append(str, mraw, buf, size) == NULL) {
+ if (lexbor_str_append(str, mraw, buffer, size) == NULL) {
return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
}
@@ -4968,7 +5026,7 @@ lxb_status_t
lxb_url_serialize_fragment(const lxb_url_t *url,
lexbor_serialize_cb_f cb, void *ctx)
{
- if (url->query.data != NULL) {
+ if (url->fragment.data != NULL) {
return cb(url->fragment.data, url->fragment.length, ctx);
}
@@ -5167,6 +5225,8 @@ lxb_url_search_params_parse(lxb_url_search_params_t *search_params,
return status;
}
+ last = entry;
+
lexbor_str_init(&entry->value, mraw, 0);
if (entry->value.data == NULL) {
return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
diff --git a/ext/lexbor/lexbor/url/url.h b/ext/lexbor/lexbor/url/url.h
index d2c93080c922..5ecc1da5dfbc 100644
--- a/ext/lexbor/lexbor/url/url.h
+++ b/ext/lexbor/lexbor/url/url.h
@@ -400,6 +400,10 @@ lxb_url_percent_encode_utf_8(const lxb_char_t *data, size_t length,
* hexadecimal digits. If a code point cannot be represented in the target
* encoding, its percent-encoded numeric character reference is appended.
*
+ * The output encoding of the target encoding is used: UTF-16BE, UTF-16LE and
+ * replacement are replaced with UTF-8, see
+ * https://encoding.spec.whatwg.org/#get-an-output-encoding
+ *
* If encoding is UTF-8, no conversion is performed. If space_as_plus is true,
* an encoded U+0020 SPACE is replaced with '+' before the map is checked.
* The input is expected to be valid UTF-8; the function does not validate it.
@@ -894,6 +898,51 @@ lxb_url_search_params_serialize(lxb_url_search_params_t *search_params,
LXB_API bool
lxb_url_is_special(const lxb_url_t *url);
+/*
+ * Reset the URL path to an empty list.
+ *
+ * Frees the path buffer using url->mraw, resets the segment count and clears
+ * the opaque flag. Does nothing if the path buffer is already NULL.
+ *
+ * @param[in, out] URL object. Not NULL.
+ */
+LXB_API void
+lxb_url_path_set_null(lxb_url_t *url);
+
+/*
+ * Set the host to the empty host.
+ *
+ * Frees any domain or opaque host buffer using mraw and sets the host type
+ * to LXB_URL_HOST_TYPE_EMPTY.
+ *
+ * @param[in, out] Host object. Not NULL.
+ * @param[in] Memory object associated with the host. Not NULL.
+ */
+LXB_API void
+lxb_url_host_set_empty(lxb_url_host_t *host, lexbor_mraw_t *mraw);
+
+/*
+ * Set the URL query to null.
+ *
+ * Frees the query buffer using url->mraw. Does nothing if the query
+ * is already null.
+ *
+ * @param[in, out] URL object. Not NULL.
+ */
+LXB_API void
+lxb_url_query_set_null(lxb_url_t *url);
+
+/*
+ * Set the URL fragment to null.
+ *
+ * Frees the fragment buffer using url->mraw. Does nothing if the
+ * fragment is already null.
+ *
+ * @param[in, out] URL object. Not NULL.
+ */
+LXB_API void
+lxb_url_fragment_set_null(lxb_url_t *url);
+
/*
* Inline functions.
*/
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 6bc4929e9b07..2cd28f9eaf1d 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 01/15] Expose line and column information for use in PHP
+Subject: [PATCH 01/24] 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 29bc4b12adce..4cff1b906f8d 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 02/15] Track implied added nodes for options use in PHP
+Subject: [PATCH 02/24] 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 286fd2e16fd7..19b411b2ffcf 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 03/15] Patch utilities and data structure to be able to
+Subject: [PATCH 03/24] 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 34b58217aa6f..67f241a96cf9 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 04/15] Remove unused upper case tag static data
+Subject: [PATCH 04/24] 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 0c88f6031720..a36832333e12 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 05/15] Shrink size of static binary search tree
+Subject: [PATCH 05/24] 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 3e31f7925889..fa25a94ef3fa 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 06/15] Patch out unused CSS style code
+Subject: [PATCH 06/24] 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 505cb66844c1..2f939cb69f2c 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?= <kocsismate@woohoolabs.com>
Date: Sun, 17 May 2026 22:17:14 +0200
-Subject: [PATCH 07/15] Add lxb_url_is_special() to the public API (#362)
+Subject: [PATCH 07/24] 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 1a6b8278a117..d72c82fbdaea 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 <lex.borisov@gmail.com>
Date: Fri, 26 Jun 2026 18:55:56 +0300
-Subject: [PATCH 08/15] URL: fixed setters for empty hosts.
+Subject: [PATCH 08/24] 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 0b942644bf8e..b2ac318e2ec6 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 <lex.borisov@gmail.com>
Date: Fri, 5 Jun 2026 22:13:32 +0300
-Subject: [PATCH 09/15] URL: fixed uninitialized memory in the path buffer
+Subject: [PATCH 09/24] 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
index 4ab6177f9a29..937722a246b5 100644
--- 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
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= <kocsismate@woohoolabs.com>
Date: Thu, 9 Jul 2026 21:51:05 +0200
-Subject: [PATCH 10/15] Fix parsing for URL containing empty host and userinfo
+Subject: [PATCH 10/24] 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/0011-Percent-encode-the-caret-in-the-path.patch
b/ext/lexbor/patches/0011-Percent-encode-the-caret-in-the-path.patch
index 0a07b7095fd9..9c1a0708a756 100644
--- 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
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= <kocsismate@woohoolabs.com>
Date: Fri, 10 Jul 2026 22:31:16 +0200
-Subject: [PATCH 11/15] Percent-encode the caret in the path
+Subject: [PATCH 11/24] Percent-encode the caret in the path
The caret (^) is part of the path percent-encode set:
diff --git a/ext/lexbor/patches/0012-URL-added-public-IPv6-parser.patch
b/ext/lexbor/patches/0012-URL-added-public-IPv6-parser.patch
index 42cd11a6729b..a4007d2a68f9 100644
--- a/ext/lexbor/patches/0012-URL-added-public-IPv6-parser.patch
+++ b/ext/lexbor/patches/0012-URL-added-public-IPv6-parser.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Alexander Borisov <lex.borisov@gmail.com>
Date: Wed, 12 Aug 2026 23:29:20 +0300
-Subject: [PATCH 12/15] URL: added public IPv6 parser.
+Subject: [PATCH 12/24] URL: added public IPv6 parser.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
diff --git a/ext/lexbor/patches/0013-URL-added-public-percent-encoder-API.patch
b/ext/lexbor/patches/0013-URL-added-public-percent-encoder-API.patch
index edefe14ebc0b..729969019b40 100644
--- a/ext/lexbor/patches/0013-URL-added-public-percent-encoder-API.patch
+++ b/ext/lexbor/patches/0013-URL-added-public-percent-encoder-API.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Alexander Borisov <lex.borisov@gmail.com>
Date: Thu, 13 Aug 2026 23:29:16 +0300
-Subject: [PATCH 13/15] URL: added public percent-encoder API.
+Subject: [PATCH 13/24] URL: added public percent-encoder API.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
diff --git a/ext/lexbor/patches/0014-CSS-fixed-heap-buffer-overflow-in-lexbor-contains-pa.patch
b/ext/lexbor/patches/0014-CSS-fixed-heap-buffer-overflow-in-lexbor-contains-pa.patch
index 05e049118c9a..373a579e0842 100644
--- a/ext/lexbor/patches/0014-CSS-fixed-heap-buffer-overflow-in-lexbor-contains-pa.patch
+++ b/ext/lexbor/patches/0014-CSS-fixed-heap-buffer-overflow-in-lexbor-contains-pa.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Alexander Borisov <lex.borisov@gmail.com>
Date: Fri, 5 Jun 2026 22:34:23 +0300
-Subject: [PATCH 14/15] CSS: fixed heap buffer overflow in :lexbor-contains()
+Subject: [PATCH 14/24] CSS: fixed heap buffer overflow in :lexbor-contains()
parsing.
The contains string buffer was allocated by the size of the string
diff --git a/ext/lexbor/patches/0015-Encoding-fixed-buffer-overflows-in-malformed-decode-.patch
b/ext/lexbor/patches/0015-Encoding-fixed-buffer-overflows-in-malformed-decode-.patch
index 31c3f5f95f91..68756c46547e 100644
--- a/ext/lexbor/patches/0015-Encoding-fixed-buffer-overflows-in-malformed-decode-.patch
+++ b/ext/lexbor/patches/0015-Encoding-fixed-buffer-overflows-in-malformed-decode-.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Alexander Borisov <lex.borisov@gmail.com>
Date: Wed, 10 Jun 2026 19:50:10 +0300
-Subject: [PATCH 15/15] Encoding: fixed buffer overflows in malformed decode
+Subject: [PATCH 15/24] Encoding: fixed buffer overflows in malformed decode
replay.
Fixed out-of-bounds writes in buffering decoders when replacement output
diff --git a/ext/lexbor/patches/0016-URL-fixed-tail-pointer-in-URLSearchParams-for-delimi.patch
b/ext/lexbor/patches/0016-URL-fixed-tail-pointer-in-URLSearchParams-for-delimi.patch
new file mode 100644
index 000000000000..bc44d2ec9b43
--- /dev/null
+++ b/ext/lexbor/patches/0016-URL-fixed-tail-pointer-in-URLSearchParams-for-delimi.patch
@@ -0,0 +1,29 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Alexander Borisov <lex.borisov@gmail.com>
+Date: Fri, 5 Jun 2026 21:46:26 +0300
+Subject: [PATCH 16/24] URL: fixed tail pointer in URLSearchParams for
+ delimiter-free query.
+
+When a query had a single token without '=' or '&' (e.g. "?abc"),
the
+internal tail pointer wasn't updated, so a later append() could lose the
+added parameter (and write through a stale pointer). Fixed by keeping the
+tail pointer in sync.
+
+Per report from Xiansheng Cao (@HMF2021)
+---
+ source/lexbor/url/url.c | 2 ++
+ 1 file changed, 2 insertions(+)
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index 69d9196..337c172 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -5167,6 +5167,8 @@ lxb_url_search_params_parse(lxb_url_search_params_t *search_params,
+ return status;
+ }
+
++ last = entry;
++
+ lexbor_str_init(&entry->value, mraw, 0);
+ if (entry->value.data == NULL) {
+ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
diff --git a/ext/lexbor/patches/0017-URL-Fix-parsing-of-usernames-containing.patch
b/ext/lexbor/patches/0017-URL-Fix-parsing-of-usernames-containing.patch
new file mode 100644
index 000000000000..f6009b9709fa
--- /dev/null
+++ b/ext/lexbor/patches/0017-URL-Fix-parsing-of-usernames-containing.patch
@@ -0,0 +1,39 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Tim=20D=C3=BCsterhus?= <tim@bastelstu.be>
+Date: Fri, 31 Jul 2026 20:56:18 +0200
+Subject: [PATCH 17/24] URL: Fix parsing of usernames containing
@
+
+Fixes lexbor/lexbor#399.
+---
+ source/lexbor/url/url.c | 18 ++++++++----------
+ 1 file changed, 8 insertions(+), 10 deletions(-)
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index 337c172..a5bc3f0 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -1739,16 +1739,14 @@ again:
+ break;
+ }
+
+- if (pswd == NULL || !at_sign) {
+- tmp = (pswd != NULL) ? pswd - 1 : p;
+-
+- if (tmp > begin) {
+- status = lxb_url_percent_encode_after_utf_8(begin,
+- tmp, &url->username, url->mraw, lxb_url_map,
+- LXB_URL_MAP_USERINFO, false);
+- if (status != LXB_STATUS_OK) {
+- lxb_url_parse_return(orig_data, buf, status);
+- }
++ tmp = (pswd != NULL) ? pswd - 1 : p;
++ if (tmp > begin) {
++ status = lxb_url_percent_encode_after_utf_8(begin, tmp,
++ &url->username, url->mraw,
++ lxb_url_map, LXB_URL_MAP_USERINFO,
++ false);
++ if (status != LXB_STATUS_OK) {
++ lxb_url_parse_return(orig_data, buf, status);
+ }
+ }
+
diff --git a/ext/lexbor/patches/0018-Unicode-fixed-uninitialized-memory-in-IDNA-buffer-gr.patch
b/ext/lexbor/patches/0018-Unicode-fixed-uninitialized-memory-in-IDNA-buffer-gr.patch
new file mode 100644
index 000000000000..23c4cf1acc03
--- /dev/null
+++ b/ext/lexbor/patches/0018-Unicode-fixed-uninitialized-memory-in-IDNA-buffer-gr.patch
@@ -0,0 +1,81 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Alexander Borisov <lex.borisov@gmail.com>
+Date: Mon, 7 Sep 2026 22:11:39 +0300
+Subject: [PATCH 18/24] Unicode: fixed uninitialized memory in IDNA buffer
+ growth.
+
+When an IDNA buffer outgrew the stack allocation, the move to the heap
+did not copy the existing contents. The converted domain could therefore
+contain uninitialized heap data.
+
+Fixed copying of the codepoint, ASCII and UTF-8 buffers.
+
+Per report from Muhammad Daffa (@daffainfo).
+---
+ source/lexbor/unicode/idna.c | 28 +++++++++++++++++++---------
+ 1 file changed, 19 insertions(+), 9 deletions(-)
+
+diff --git a/source/lexbor/unicode/idna.c b/source/lexbor/unicode/idna.c
+index 754f6b2..b31ae33 100644
+--- a/source/lexbor/unicode/idna.c
++++ b/source/lexbor/unicode/idna.c
+@@ -117,12 +117,14 @@ lxb_unicode_idna_realloc(lxb_codepoint_t *buf, const lxb_codepoint_t *buffer,
+ lxb_codepoint_t *tmp;
+
+ nlen = ((*buf_end - buf) * 4) + len;
+-
++
+ if (buf == buffer) {
+ tmp = lexbor_malloc(nlen * sizeof(lxb_codepoint_t));
+ if (tmp == NULL) {
+ return NULL;
+ }
++
++ memcpy(tmp, buf, (*buf_p - buf) * sizeof(lxb_codepoint_t));
+ }
+ else {
+ tmp = lexbor_realloc(buf, nlen * sizeof(lxb_codepoint_t));
+@@ -458,13 +460,17 @@ lxb_unicode_idna_ascii_puny_cb(const lxb_char_t *data, size_t length, void
*ctx,
+
+ if (asc->buf == asc->buffer) {
+ tmp = lexbor_malloc(nlen);
++ if (tmp == NULL) {
++ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
++ }
++
++ memcpy(tmp, asc->buf, asc->p - asc->buf);
+ }
+ else {
+ tmp = lexbor_realloc(asc->buf, nlen);
+- }
+-
+- if (tmp == NULL) {
+- return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
++ if (tmp == NULL) {
++ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
++ }
+ }
+
+ asc->p = tmp + (asc->p - asc->buf);
+@@ -711,13 +717,17 @@ lxb_unicode_idna_to_unicode_cb(const lxb_codepoint_t *part, size_t len,
+
+ if (asc->buf == asc->buffer) {
+ tmp = lexbor_malloc(nlen);
++ if (tmp == NULL) {
++ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
++ }
++
++ memcpy(tmp, asc->buf, asc->p - asc->buf);
+ }
+ else {
+ tmp = lexbor_realloc(asc->buf, nlen);
+- }
+-
+- if (tmp == NULL) {
+- return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
++ if (tmp == NULL) {
++ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
++ }
+ }
+
+ asc->p = tmp + (asc->p - asc->buf);
diff --git a/ext/lexbor/patches/0019-URL-encode-opaque-path-spaces-before-query-and-fragm.patch
b/ext/lexbor/patches/0019-URL-encode-opaque-path-spaces-before-query-and-fragm.patch
new file mode 100644
index 000000000000..4d8338521997
--- /dev/null
+++ b/ext/lexbor/patches/0019-URL-encode-opaque-path-spaces-before-query-and-fragm.patch
@@ -0,0 +1,38 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= <kocsismate@woohoolabs.com>
+Date: Fri, 11 Sep 2026 21:58:45 +0200
+Subject: [PATCH 19/24] URL: encode opaque path spaces before query and
+ fragment. (#405)
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+Percent-encode only the space immediately preceding a query or fragment delimiter, while preserving
validation errors for every parsed space.
+
+This way, Lexbor will correctly follow "If remaining starts with U+003F (?) or U+0023 (#),
then append "%20" to url’s path." in the "opaque path state".
+---
+ source/lexbor/url/url.c | 11 +++++++++++
+ 1 file changed, 11 insertions(+)
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index a5bc3f0..1354f45 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -2327,6 +2327,17 @@ again:
+ lxb_url_parse_return(orig_data, buf, status);
+ }
+
++ /* Encode only the space immediately before a query or fragment. */
++ if (p > begin && p[-1] == ' ') {
++ tmp_str.length--;
++ if (lexbor_str_append(&tmp_str, url->mraw,
++ (const lxb_char_t *) "%20", 3) == NULL)
++ {
++ lxb_url_parse_return(orig_data, buf,
++ LXB_STATUS_ERROR_MEMORY_ALLOCATION);
++ }
++ }
++
+ status = lxb_url_path_list_push(url, &tmp_str);
+ if (status != LXB_STATUS_OK) {
+ lxb_url_parse_return(orig_data, buf, status);
diff --git a/ext/lexbor/patches/0020-URL-Fix-lxb_url_serialize_fragment-without-a-query-4.patch
b/ext/lexbor/patches/0020-URL-Fix-lxb_url_serialize_fragment-without-a-query-4.patch
new file mode 100644
index 000000000000..a0eecf7234be
--- /dev/null
+++ b/ext/lexbor/patches/0020-URL-Fix-lxb_url_serialize_fragment-without-a-query-4.patch
@@ -0,0 +1,25 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Tim=20D=C3=BCsterhus?= <tim@tideways-gmbh.com>
+Date: Fri, 11 Sep 2026 22:45:33 +0200
+Subject: [PATCH 20/24] URL: Fix lxb_url_serialize_fragment() without a query
+ (#410)
+
+The lxb_url_serialize_fragment() function checked if the query contained data
+before correctly serializing the fragment. Check for the fragment instead.
+---
+ 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 1354f45..06bdd2e 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -4977,7 +4977,7 @@ lxb_status_t
+ lxb_url_serialize_fragment(const lxb_url_t *url,
+ lexbor_serialize_cb_f cb, void *ctx)
+ {
+- if (url->query.data != NULL) {
++ if (url->fragment.data != NULL) {
+ return cb(url->fragment.data, url->fragment.length, ctx);
+ }
+
diff --git a/ext/lexbor/patches/0021-URL-expose-component-reset-functions.-415.patch
b/ext/lexbor/patches/0021-URL-expose-component-reset-functions.-415.patch
new file mode 100644
index 000000000000..deaac125e82e
--- /dev/null
+++ b/ext/lexbor/patches/0021-URL-expose-component-reset-functions.-415.patch
@@ -0,0 +1,110 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= <kocsismate@woohoolabs.com>
+Date: Tue, 22 Sep 2026 22:20:28 +0200
+Subject: [PATCH 21/24] URL: expose component reset functions. (#415)
+
+* URL: expose component reset functions.
+
+Make the path, host and fragment reset functions public and add lxb_url_query_set_null().
+
+* Add documentation for the newly exposed functions
+---
+ source/lexbor/url/url.c | 14 ++++++++++---
+ source/lexbor/url/url.h | 45 +++++++++++++++++++++++++++++++++++++++++
+ 2 files changed, 56 insertions(+), 3 deletions(-)
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index 06bdd2e..06325cd 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -909,7 +909,7 @@ lxb_url_scheme_copy_special(const lxb_url_scheme_data_t *src,
+ return lxb_url_str_copy(&src->name, &dst->name, dst_mraw);
+ }
+
+-static void
++void
+ lxb_url_path_set_null(lxb_url_t *url)
+ {
+ if (url->path.str.data == NULL) {
+@@ -1133,7 +1133,7 @@ lxb_url_host_destroy(lxb_url_host_t *host, lexbor_mraw_t *mraw)
+ }
+ }
+
+-static void
++void
+ lxb_url_host_set_empty(lxb_url_host_t *host, lexbor_mraw_t *mraw)
+ {
+ lxb_url_host_destroy(host, mraw);
+@@ -1183,7 +1183,15 @@ lxb_url_port_set(lxb_url_t *url, uint16_t port)
+ url->has_port = true;
+ }
+
+-static void
++void
++lxb_url_query_set_null(lxb_url_t *url)
++{
++ if (url->query.data != NULL) {
++ (void) lexbor_str_destroy(&url->query, url->mraw, false);
++ }
++}
++
++void
+ lxb_url_fragment_set_null(lxb_url_t *url)
+ {
+ if (url->fragment.data != NULL) {
+diff --git a/source/lexbor/url/url.h b/source/lexbor/url/url.h
+index d2c9308..a68cf65 100644
+--- a/source/lexbor/url/url.h
++++ b/source/lexbor/url/url.h
+@@ -894,6 +894,51 @@ lxb_url_search_params_serialize(lxb_url_search_params_t *search_params,
+ LXB_API bool
+ lxb_url_is_special(const lxb_url_t *url);
+
++/*
++ * Reset the URL path to an empty list.
++ *
++ * Frees the path buffer using url->mraw, resets the segment count and clears
++ * the opaque flag. Does nothing if the path buffer is already NULL.
++ *
++ * @param[in, out] URL object. Not NULL.
++ */
++LXB_API void
++lxb_url_path_set_null(lxb_url_t *url);
++
++/*
++ * Set the host to the empty host.
++ *
++ * Frees any domain or opaque host buffer using mraw and sets the host type
++ * to LXB_URL_HOST_TYPE_EMPTY.
++ *
++ * @param[in, out] Host object. Not NULL.
++ * @param[in] Memory object associated with the host. Not NULL.
++ */
++LXB_API void
++lxb_url_host_set_empty(lxb_url_host_t *host, lexbor_mraw_t *mraw);
++
++/*
++ * Set the URL query to null.
++ *
++ * Frees the query buffer using url->mraw. Does nothing if the query
++ * is already null.
++ *
++ * @param[in, out] URL object. Not NULL.
++ */
++LXB_API void
++lxb_url_query_set_null(lxb_url_t *url);
++
++/*
++ * Set the URL fragment to null.
++ *
++ * Frees the fragment buffer using url->mraw. Does nothing if the
++ * fragment is already null.
++ *
++ * @param[in, out] URL object. Not NULL.
++ */
++LXB_API void
++lxb_url_fragment_set_null(lxb_url_t *url);
++
+ /*
+ * Inline functions.
+ */
diff --git a/ext/lexbor/patches/0022-URL-Fix-parsing-of-query-and-fragment-after-a-dot-co.patch
b/ext/lexbor/patches/0022-URL-Fix-parsing-of-query-and-fragment-after-a-dot-co.patch
new file mode 100644
index 000000000000..4f9607068b9d
--- /dev/null
+++ b/ext/lexbor/patches/0022-URL-Fix-parsing-of-query-and-fragment-after-a-dot-co.patch
@@ -0,0 +1,416 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Tim=20D=C3=BCsterhus?= <tim@tideways-gmbh.com>
+Date: Sat, 26 Sep 2026 18:41:05 +0200
+Subject: [PATCH 22/24] URL: Fix parsing of query and fragment after a
+ dot-component in path (#411)
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+* URL: fixed dot segment terminators in path parsing.
+
+After a "." or ".." path segment the parser skipped the next code point
+unconditionally. For "?" and "#" this lost the delimiter, so the query
+or the fragment became part of the path: "https://example.com/..#frag"
+was parsed as "https://example.com/frag".
The fast path fix from #411
+did not cover the slow path, which is taken after a code point that must
+be percent-encoded, for example "https://example.com/café/..#frag".
+
+Also fixed in the same code:
+- "\" after a dot segment in a special URL did not report
+ invalid-reverse-solidus.
+- The empty segment was lost in "//./c" and at the fast/slow path
+ handoff ("//é").
+- path.length drifted after dot segments and underflowed after ".." at
+ the root, so a later ".." in the slow path did not shorten the path
+ ("/a/b/../../../c/é/../../x" gave "/c/x"). The file host and path
+ start states did not update it either.
+- An invalid percent sequence in the fast path skipped the next two
+ code points, so a following "/", "\", "?" or "#" was lost
("/%?q" put
+ "?q" into the path). Such a segment is now handed to the slow path,
+ which checks "%" one code point at a time.
+
+Per reports from Tim Düsterhus (@TimWolla) and @NickSdot.
+
+This relates to #409 issue on GitHub.
+This relates to #412 issue on GitHub.
+This relates to #411 PR on GitHub.
+
+* URL: added regression tests for dot segments in path.
+
+Added tests for "." and ".." path segments followed by "?",
"#", "\"
+and the end of input, in both the fast and the slow path, for the
+invalid-reverse-solidus validation error, and for path.length against
+the serialized path. Also added tests for invalid percent sequences
+before dot segments and delimiters, both in parsing and in the pathname
+setter.
+
+This relates to #409 issue on GitHub.
+This relates to #412 issue on GitHub.
+---
+ source/lexbor/url/url.c | 200 +++++++++++++++++++++++-----------------
+ 1 file changed, 113 insertions(+), 87 deletions(-)
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index 06325cd..8187c0f 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -532,15 +532,15 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
+ const lxb_char_t *data, const lxb_char_t *end, bool bqs);
+
+ static lxb_status_t
+-lxb_url_path_try_dot(lxb_url_t *url, const lxb_char_t **begin,
+- const lxb_char_t **last, const lxb_char_t **start,
+- const lxb_char_t *end, bool bqs);
++lxb_url_path_try_dot(lxb_url_parser_t *parser, lxb_url_t *url,
++ const lxb_char_t **begin, const lxb_char_t **last,
++ const lxb_char_t **start, const lxb_char_t *end, bool bqs);
+
+ static const lxb_char_t *
+-lxb_url_path_dot_count(lxb_url_t *url, const lxb_char_t *p,
+- const lxb_char_t *end, const lxb_char_t *sbuf_begin,
+- lxb_char_t **sbuf, lxb_char_t **last, size_t *path_count,
+- bool bqs);
++lxb_url_path_dot_count(lxb_url_parser_t *parser, lxb_url_t *url,
++ const lxb_char_t *p, const lxb_char_t *end,
++ const lxb_char_t *sbuf_begin, lxb_char_t **sbuf,
++ lxb_char_t **last, size_t *path_count, bool bqs);
+
+ static void
+ lxb_url_path_fix_windows_drive(lxb_url_t *url, lxb_char_t *sbuf,
+@@ -976,23 +976,25 @@ lxb_url_path_shorten(lxb_url_t *url)
+ }
+ }
+
+- if (url->path.str.data != NULL) {
+- url->path.length -= 1;
++ if (url->path.length == 0 || str->data == NULL) {
++ return;
++ }
+
+- begin = str->data;
+- p = begin + str->length;
++ url->path.length -= 1;
+
+- while (p > begin) {
+- p -= 1;
++ begin = str->data;
++ p = begin + str->length;
+
+- if (*p == '/') {
+- *p = '\0';
+- break;
+- }
+- }
++ while (p > begin) {
++ p -= 1;
+
+- str->length = p - begin;
++ if (*p == '/') {
++ *p = '\0';
++ break;
++ }
+ }
++
++ str->length = p - begin;
+ }
+
+ static lxb_status_t
+@@ -2133,6 +2135,8 @@ again:
+ if (status != LXB_STATUS_OK) {
+ lxb_url_parse_return(orig_data, buf, status);
+ }
++
++ url->path.length += 1;
+ }
+ }
+ }
+@@ -2274,7 +2278,13 @@ again:
+ && url->host.type == LXB_URL_HOST_TYPE__UNDEF)
+ {
+ status = lxb_url_path_append(url, mp_str.data, mp_str.length);
+- lxb_url_parse_return(orig_data, buf, status);
++ if (status != LXB_STATUS_OK) {
++ lxb_url_parse_return(orig_data, buf, status);
++ }
++
++ url->path.length += 1;
++
++ lxb_url_parse_return(orig_data, buf, LXB_STATUS_OK);
+ }
+
+ lxb_url_parse_return(orig_data, buf, LXB_STATUS_OK);
+@@ -2523,13 +2533,8 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
+ || lexbor_str_res_map_hex[p[1]] == 0xff
+ || lexbor_str_res_map_hex[p[2]] == 0xff)
+ {
+- status = lxb_url_log_append(parser, p,
+- LXB_URL_ERROR_TYPE_INVALID_URL_UNIT);
+- if (status != LXB_STATUS_OK) {
+- return NULL;
+- }
+-
+- p = (end - p < 3) ? end - 1 : p + 2;
++ /* Reprocess the segment without skipping delimiters. */
++ goto slow;
+ }
+ else if (p[1] == '2' && (p[2] == 'e' || p[2] ==
'E')
+ && (p == begin
+@@ -2538,8 +2543,8 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
+ {
+ url->path.length = count;
+
+- status = lxb_url_path_try_dot(url, &begin, &last,
+- &p, end, bqs);
++ status = lxb_url_path_try_dot(parser, url, &begin,
++ &last, &p, end, bqs);
+ if (status != LXB_STATUS_OK) {
+ return NULL;
+ }
+@@ -2577,8 +2582,8 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
+ {
+ url->path.length = count;
+
+- status = lxb_url_path_try_dot(url, &begin, &last,
+- &p, end, bqs);
++ status = lxb_url_path_try_dot(parser, url, &begin,
++ &last, &p, end, bqs);
+ if (status != LXB_STATUS_OK) {
+ return NULL;
+ }
+@@ -2587,17 +2592,7 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
+ }
+ }
+ else {
+- url->path.length = count;
+-
+- if (last - 1 > begin) {
+- status = lxb_url_path_append(url, begin,
+- (last - 1) - begin);
+- if (status != LXB_STATUS_OK) {
+- return NULL;
+- }
+- }
+-
+- return lxb_url_path_slow_path(parser, url, last, end, bqs);
++ goto slow;
+ }
+ }
+ }
+@@ -2607,13 +2602,22 @@ lxb_url_path_fast_path(lxb_url_parser_t *parser, lxb_url_t *url,
+ return NULL;
+ }
+
+- if (count == 0 || p != begin) {
+- count += 1;
+- }
++ url->path.length = count + 1;
++
++ return p;
++
++slow:
+
+ url->path.length = count;
+
+- return p;
++ if (last > begin) {
++ status = lxb_url_path_append(url, begin, (last - 1) - begin);
++ if (status != LXB_STATUS_OK) {
++ return NULL;
++ }
++ }
++
++ return lxb_url_path_slow_path(parser, url, last, end, bqs);
+ }
+
+ /*
+@@ -2709,10 +2713,6 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
+
+ count += 1;
+ last = sbuf;
+-
+- if (p + 1 >= end) {
+- count += 1;
+- }
+ }
+ else if (c == '\\' && lxb_url_is_special(url)) {
+ status = lxb_url_log_append(parser, p,
+@@ -2730,16 +2730,8 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
+
+ count += 1;
+ last = sbuf;
+-
+- if (p + 1 >= end) {
+- count += 1;
+- }
+ }
+ else if ((c == '?' || c == '#') && bqs) {
+- lxb_url_path_fix_windows_drive(url, last, sbuf, count);
+-
+- count += 1;
+- last = sbuf;
+ break;
+ }
+ else if (lxb_url_map[c] & LXB_URL_MAP_PATH) {
+@@ -2759,11 +2751,19 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
+ }
+ else if (c == '.') {
+ if (last == sbuf) {
+- tmp = lxb_url_path_dot_count(url, p, end, sbuf_begin,
++ tmp = lxb_url_path_dot_count(parser, url, p, end, sbuf_begin,
+ &sbuf, &last, &count, bqs);
++ if (tmp == NULL) {
++ goto failed;
++ }
+
+ if (tmp != p) {
+- p = tmp + 1;
++ /* Skip '/' or '\', but leave '?' and
'#' to the loop. */
++ if (tmp < end && *tmp != '?' && *tmp !=
'#') {
++ tmp += 1;
++ }
++
++ p = tmp;
+ continue;
+ }
+ }
+@@ -2788,11 +2788,19 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
+ else if (p[1] == '2' && (p[2] == 'e' || p[2] ==
'E')
+ && last == sbuf)
+ {
+- tmp = lxb_url_path_dot_count(url, p, end, sbuf_begin,
++ tmp = lxb_url_path_dot_count(parser, url, p, end, sbuf_begin,
+ &sbuf, &last, &count, bqs);
++ if (tmp == NULL) {
++ goto failed;
++ }
+
+ if (tmp != p) {
+- p = tmp + 1;
++ /* Skip '/' or '\', but leave '?' and
'#' to the loop. */
++ if (tmp < end && *tmp != '?' && *tmp !=
'#') {
++ tmp += 1;
++ }
++
++ p = tmp;
+ continue;
+ }
+ }
+@@ -2821,12 +2829,9 @@ lxb_url_path_slow_path(lxb_url_parser_t *parser, lxb_url_t *url,
+ p += 1;
+ }
+
+- if (count == 0 || last < sbuf) {
+- lxb_url_path_fix_windows_drive(url, last, sbuf, count);
+- count += 1;
+- }
++ lxb_url_path_fix_windows_drive(url, last, sbuf, count);
+
+- url->path.length = count;
++ url->path.length = count + 1;
+
+ status = lxb_url_path_append_wo_slash(url, sbuf_begin, sbuf - sbuf_begin);
+ if (status != LXB_STATUS_OK) {
+@@ -2849,13 +2854,12 @@ failed:
+ }
+
+ static lxb_status_t
+-lxb_url_path_try_dot(lxb_url_t *url, const lxb_char_t **begin,
+- const lxb_char_t **last, const lxb_char_t **start,
+- const lxb_char_t *end, bool bqs)
++lxb_url_path_try_dot(lxb_url_parser_t *parser, lxb_url_t *url,
++ const lxb_char_t **begin, const lxb_char_t **last,
++ const lxb_char_t **start, const lxb_char_t *end, bool bqs)
+ {
+ unsigned count;
+ lxb_char_t c;
+- lexbor_str_t *str;
+ lxb_status_t status;
+ const lxb_char_t *p;
+
+@@ -2900,40 +2904,54 @@ lxb_url_path_try_dot(lxb_url_t *url, const lxb_char_t **begin,
+ }
+ }
+
+- if (p < end) {
+- *start = p;
+- *begin = p + 1;
+- *last = *begin;
++ if (count == 2) {
++ lxb_url_path_shorten(url);
+ }
+- else {
++
++ if (p >= end) {
++ /* The caller appends the trailing empty segment. */
+ *start = end - 1;
+ *begin = end;
+ *last = end;
++
++ return LXB_STATUS_OK;
+ }
+
+- if (count == 2) {
+- lxb_url_path_shorten(url);
++ if (*p == '?' || *p == '#') {
++ /* The caller's loop handles the delimiter and the empty segment. */
++ *start = p - 1;
++ *begin = p;
++ *last = p;
++
++ return LXB_STATUS_OK;
+ }
+- else if (count == 1) {
+- str = &url->path.str;
+
+- if (str->length > 0 && str->data[str->length - 1] == '/') {
+- str->length -= 1;
+- str->data[str->length] = '\0';
++ if (*p == '\\') {
++ status = lxb_url_log_append(parser, p,
++ LXB_URL_ERROR_TYPE_INVALID_REVERSE_SOLIDUS);
++ if (status != LXB_STATUS_OK) {
++ return status;
+ }
+ }
+
++ /* Skip '/' or '\'. */
++
++ *start = p;
++ *begin = p + 1;
++ *last = *begin;
++
+ return LXB_STATUS_OK;
+ }
+
+ static const lxb_char_t *
+-lxb_url_path_dot_count(lxb_url_t *url, const lxb_char_t *p,
+- const lxb_char_t *end, const lxb_char_t *sbuf_begin,
+- lxb_char_t **sbuf, lxb_char_t **last, size_t *path_count,
+- bool bqs)
++lxb_url_path_dot_count(lxb_url_parser_t *parser, lxb_url_t *url,
++ const lxb_char_t *p, const lxb_char_t *end,
++ const lxb_char_t *sbuf_begin, lxb_char_t **sbuf,
++ lxb_char_t **last, size_t *path_count, bool bqs)
+ {
+ unsigned count;
+ lxb_char_t c, *last_p;
++ lxb_status_t status;
+ const lxb_char_t *begin;
+
+ count = 0;
+@@ -2970,6 +2988,14 @@ lxb_url_path_dot_count(lxb_url_t *url, const lxb_char_t *p,
+ return begin;
+ }
+
++ if (p < end && *p == '\\') {
++ status = lxb_url_log_append(parser, p,
++ LXB_URL_ERROR_TYPE_INVALID_REVERSE_SOLIDUS);
++ if (status != LXB_STATUS_OK) {
++ return NULL;
++ }
++ }
++
+ if (url->scheme.type == LXB_URL_SCHEMEL_TYPE_FILE
+ && *path_count == 1
+ && lxb_url_normalized_windows_drive_letter(sbuf_begin + 1, *last - 1))
diff --git a/ext/lexbor/patches/0023-URL-Keep-replacement-file-drive-paths-hierarchical-4.patch
b/ext/lexbor/patches/0023-URL-Keep-replacement-file-drive-paths-hierarchical-4.patch
new file mode 100644
index 000000000000..2caee494342d
--- /dev/null
+++ b/ext/lexbor/patches/0023-URL-Keep-replacement-file-drive-paths-hierarchical-4.patch
@@ -0,0 +1,23 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= <kocsismate@woohoolabs.com>
+Date: Mon, 21 Sep 2026 20:01:43 +0200
+Subject: [PATCH 23/24] URL: Keep replacement file drive paths hierarchical
+ (#423)
+
+WHATWG file state (https://url.spec.whatwg.org/#file-state) step 4.4.3.2 resets the path to an
empty list. Do not mark it opaque, as that makes subsequent pathname updates silently do nothing.
+---
+ source/lexbor/url/url.c | 1 -
+ 1 file changed, 1 deletion(-)
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index 8187c0f..dcbc410 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -2086,7 +2086,6 @@ again:
+ }
+
+ lxb_url_path_set_null(url);
+- url->path.opaque = true;
+ }
+ }
+
diff --git a/ext/lexbor/patches/0024-URL-normalize-output-encoding-for-percent-encoding.patch
b/ext/lexbor/patches/0024-URL-normalize-output-encoding-for-percent-encoding.patch
new file mode 100644
index 000000000000..c0d192d24eaf
--- /dev/null
+++ b/ext/lexbor/patches/0024-URL-normalize-output-encoding-for-percent-encoding.patch
@@ -0,0 +1,96 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Alexander Borisov <lex.borisov@gmail.com>
+Date: Thu, 24 Sep 2026 15:05:51 +0300
+Subject: [PATCH 24/24] URL: normalize output encoding for percent-encoding.
+
+---
+ source/lexbor/url/url.c | 34 +++++++++++++++++++++++++---------
+ source/lexbor/url/url.h | 4 ++++
+ 2 files changed, 29 insertions(+), 9 deletions(-)
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index dcbc410..c88f8e7 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -1214,6 +1214,26 @@ lxb_url_encoding_init(const lxb_encoding_data_t *encoding,
+ (void) lxb_encoding_encode_init_single(encode, encoding);
+ }
+
++/*
++ * https://encoding.spec.whatwg.org/#get-an-output-encoding
++ */
++lxb_inline lxb_encoding_t
++lxb_url_output_encoding(lxb_encoding_t encoding)
++{
++ switch (encoding) {
++ case LXB_ENCODING_DEFAULT:
++ case LXB_ENCODING_AUTO:
++ case LXB_ENCODING_UNDEFINED:
++ case LXB_ENCODING_REPLACEMENT:
++ case LXB_ENCODING_UTF_16BE:
++ case LXB_ENCODING_UTF_16LE:
++ return LXB_ENCODING_UTF_8;
++
++ default:
++ return encoding;
++ }
++}
++
+ static bool
+ lxb_url_start_windows_drive_letter(const lxb_char_t *data,
+ const lxb_char_t *end)
+@@ -1358,12 +1378,7 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
+ state = override_state;
+ }
+
+- if (encoding <= LXB_ENCODING_UNDEFINED
+- || encoding == LXB_ENCODING_UTF_16BE
+- || encoding == LXB_ENCODING_UTF_16LE)
+- {
+- encoding = LXB_ENCODING_UTF_8;
+- }
++ encoding = lxb_url_output_encoding(encoding);
+
+ enc = lxb_encoding_data(encoding);
+ if (enc == NULL) {
+@@ -3222,7 +3237,7 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
+ const lxb_char_t *buf_end = buf + sizeof(buffer);
+ static const lexbor_str_t esc_str = lexbor_str("%26%23");
+
+- if (encoding->encoding == LXB_ENCODING_UTF_8) {
++ if (lxb_url_output_encoding(encoding->encoding) == LXB_ENCODING_UTF_8) {
+ return lxb_url_percent_encode_after_utf_8(data, end, str, mraw,
+ url_map, enmap,
+ space_as_plus);
+@@ -3258,13 +3273,14 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
+ len = encoding->encode_single(&encode, &buf, buf_end, cp);
+
+ if (len < LXB_ENCODING_ENCODE_OK) {
+- size = lexbor_conv_int64_to_data((int64_t) cp, buf, buf_end - buf);
++ size = lexbor_conv_int64_to_data((int64_t) cp, buffer,
++ sizeof(buffer));
+
+ if (lexbor_str_append(str, mraw, esc_str.data, esc_str.length) == NULL) {
+ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
+ }
+
+- if (lexbor_str_append(str, mraw, buf, size) == NULL) {
++ if (lexbor_str_append(str, mraw, buffer, size) == NULL) {
+ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
+ }
+
+diff --git a/source/lexbor/url/url.h b/source/lexbor/url/url.h
+index a68cf65..5ecc1da 100644
+--- a/source/lexbor/url/url.h
++++ b/source/lexbor/url/url.h
+@@ -400,6 +400,10 @@ lxb_url_percent_encode_utf_8(const lxb_char_t *data, size_t length,
+ * hexadecimal digits. If a code point cannot be represented in the target
+ * encoding, its percent-encoded numeric character reference is appended.
+ *
++ * The output encoding of the target encoding is used: UTF-16BE, UTF-16LE and
++ * replacement are replaced with UTF-8, see
++ * https://encoding.spec.whatwg.org/#get-an-output-encoding
++ *
+ * If encoding is UTF-8, no conversion is performed. If space_as_plus is true,
+ * an encoded U+0020 SPACE is replaced with '+' before the map is checked.
+ * The input is expected to be valid UTF-8; the function does not validate it.
diff --git a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt
b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt
index a5cc306c07f4..b6d72abded4c 100644
--- a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt
+++ b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt
@@ -17,7 +17,7 @@ var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()),
Uri\UriComparis
?>
--EXPECTF--
-string(11) "foo:abc #f"
+string(13) "foo:abc %20#f"
object(Uri\WhatWg\Url)#%d (%d) {
["scheme"]=>
string(3) "foo"
@@ -30,7 +30,7 @@ object(Uri\WhatWg\Url)#%d (%d) {
["port"]=>
NULL
["path"]=>
- string(5) "abc "
+ string(7) "abc %20"
["query"]=>
NULL
["fragment"]=>
diff --git a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt
b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt
index 4ffa9701e100..d719feafc3d8 100644
--- a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt
+++ b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt
@@ -17,7 +17,7 @@ var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
?>
--EXPECTF--
-string(11) "foo:abc ?q"
+string(13) "foo:abc %20?q"
object(Uri\WhatWg\Url)#%d (%d) {
["scheme"]=>
string(3) "foo"
@@ -30,7 +30,7 @@ object(Uri\WhatWg\Url)#%d (%d) {
["port"]=>
NULL
["path"]=>
- string(5) "abc "
+ string(7) "abc %20"
["query"]=>
string(1) "q"
["fragment"]=>
diff --git a/ext/uri/tests/whatwg/modification/withPath_success_file_invalid_drive_letter.phpt
b/ext/uri/tests/whatwg/modification/withPath_success_file_invalid_drive_letter.phpt
new file mode 100644
index 000000000000..39ed5d30a4fc
--- /dev/null
+++ b/ext/uri/tests/whatwg/modification/withPath_success_file_invalid_drive_letter.phpt
@@ -0,0 +1,21 @@
+--TEST--
+Test Uri\WhatWg\Url::withPath() - file URL whose path was reset by an invalid drive letter
+--FILE--
+<?php
+
+$url = Uri\WhatWg\Url::parse("c|/x", new Uri\WhatWg\Url("file:///d:/a/b"),
$errors);
+
+var_dump($url->getPath());
+var_dump(array_map(static fn (Uri\WhatWg\UrlValidationError $error): string =>
$error->type->name, $errors));
+var_dump($url->withPath("/zz")->getPath());
+
+?>
+--EXPECT--
+string(5) "/c:/x"
+array(2) {
+ [0]=>
+ string(14) "InvalidUrlUnit"
+ [1]=>
+ string(29) "FileInvalidWindowsDriveLetter"
+}
+string(3) "/zz"
diff --git a/ext/uri/tests/whatwg/parsing/host_success_multibyte_long.phpt
b/ext/uri/tests/whatwg/parsing/host_success_multibyte_long.phpt
new file mode 100644
index 000000000000..109cb39de1f0
--- /dev/null
+++ b/ext/uri/tests/whatwg/parsing/host_success_multibyte_long.phpt
@@ -0,0 +1,21 @@
+--TEST--
+Test Uri\WhatWg\Url parsing - host - IDN host longer than the IDNA on-stack buffer
+--FILE--
+<?php
+
+$url = Uri\WhatWg\Url::parse("https://" .
str_repeat("é", 5000) . ".com/");
+$host = $url->getAsciiHost();
+
+var_dump(strlen($host));
+var_dump(substr($host, 0, 12));
+var_dump(substr($host, -6));
+var_dump(substr_count($host, "a"));
+var_dump($url->getUnicodeHost() === str_repeat("é", 5000) . ".com");
+
+?>
+--EXPECT--
+int(5010)
+string(12) "xn--9caaaaaa"
+string(6) "aa.com"
+int(5000)
+bool(true)
diff --git a/ext/uri/tests/whatwg/parsing/path_success_dot_segment_delimiters.phpt
b/ext/uri/tests/whatwg/parsing/path_success_dot_segment_delimiters.phpt
new file mode 100644
index 000000000000..648edbf2653b
--- /dev/null
+++ b/ext/uri/tests/whatwg/parsing/path_success_dot_segment_delimiters.phpt
@@ -0,0 +1,29 @@
+--TEST--
+Test Uri\WhatWg\Url parsing - path - query and fragment after a dot segment
+--FILE--
+<?php
+
+foreach ([
+ "https://example.com/..#frag",
+ "https://example.com/caf\u{e9}/..#frag",
+ "https://example.com/..?q=1",
+ "https://example.com/%?q",
+] as $input) {
+ $url = new Uri\WhatWg\Url($input);
+ var_dump($url->getPath(), $url->getQuery(), $url->getFragment());
+}
+
+?>
+--EXPECT--
+string(1) "/"
+NULL
+string(4) "frag"
+string(1) "/"
+NULL
+string(4) "frag"
+string(1) "/"
+string(3) "q=1"
+NULL
+string(2) "/%"
+string(1) "q"
+NULL
diff --git a/ext/uri/tests/whatwg/parsing/path_success_opaque_space_before_query.phpt
b/ext/uri/tests/whatwg/parsing/path_success_opaque_space_before_query.phpt
new file mode 100644
index 000000000000..3ffdd94f54c2
--- /dev/null
+++ b/ext/uri/tests/whatwg/parsing/path_success_opaque_space_before_query.phpt
@@ -0,0 +1,16 @@
+--TEST--
+Test Uri\WhatWg\Url parsing - path - space before the query or fragment of an opaque path
+--FILE--
+<?php
+
+foreach (["data:x ?q", "data:x #f", "foo:a b ?c", "data:x
?q"] as $input) {
+ $url = new Uri\WhatWg\Url($input);
+ var_dump($url->getPath());
+}
+
+?>
+--EXPECT--
+string(4) "x%20"
+string(4) "x%20"
+string(6) "a b%20"
+string(5) "x %20"
diff --git a/ext/uri/tests/whatwg/parsing/username_success_at_sign.phpt
b/ext/uri/tests/whatwg/parsing/username_success_at_sign.phpt
new file mode 100644
index 000000000000..910d984858f3
--- /dev/null
+++ b/ext/uri/tests/whatwg/parsing/username_success_at_sign.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\Url parsing - username - at sign in the username and the password
+--FILE--
+<?php
+
+$url = new Uri\WhatWg\Url("http://user@name:pass@word@localhost/");
+
+var_dump($url->getUsername());
+var_dump($url->getPassword());
+var_dump($url->getAsciiHost());
+var_dump($url->toAsciiString());
+
+?>
+--EXPECT--
+string(11) "user%40name"
+string(11) "pass%40word"
+string(9) "localhost"
+string(41) "http://user%40name:pass%40word@localhost/"