[php-src] master: lexbor: Add new URL patches (#23274)

Máté Kocsis via GitHub <[email protected]>
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: Máté Kocsis (kocsismate)
Committer: GitHub (web-flow)
Pusher: kocsismate
Date: 2026-08-15T13:02:11+02:00

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

lexbor: Add new URL patches (#23274)

- URL: added public IPv6 parser.
  (https://github.com/lexbor/lexbor/commit/a7dbebbe)
- URL: added public percent-encoder API. (https://github.com/lexbor/lexbor/commit/2b24b565)

Both of which commits are needed for https://wiki.php.net/rfc/uri_followup

Changed paths:
  A  ext/lexbor/patches/0012-URL-added-public-IPv6-parser.patch
  A  ext/lexbor/patches/0013-URL-added-public-percent-encoder-API.patch
  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


Diff:

diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c
index 8099c12089bb..69d91969a6a1 100644
--- a/ext/lexbor/lexbor/url/url.c
+++ b/ext/lexbor/lexbor/url/url.c
@@ -27,20 +27,6 @@
 #define LXB_URL_BUFFER_NUM_SIZE 128
 
 
-typedef enum {
-    LXB_URL_MAP_UNDEF         = 0x00,
-    LXB_URL_MAP_C0            = 0x01,
-    LXB_URL_MAP_FRAGMENT      = 0x02,
-    LXB_URL_MAP_QUERY         = 0x04,
-    LXB_URL_MAP_SPECIAL_QUERY = 0x08,
-    LXB_URL_MAP_PATH          = 0x10,
-    LXB_URL_MAP_USERINFO      = 0x20,
-    LXB_URL_MAP_COMPONENT     = 0x40,
-    LXB_URL_MAP_X_WWW_FORM    = 0x80,
-    LXB_URL_MAP_ALL           = 0xff
-}
-lxb_url_map_type_t;
-
 typedef enum {
     LXB_URL_HOST_OPT_UNDEF       = 0 << 0,
     LXB_URL_HOST_OPT_NOT_SPECIAL = 1 << 0,
@@ -563,7 +549,7 @@ lxb_url_path_fix_windows_drive(lxb_url_t *url, lxb_char_t *sbuf,
 static lxb_status_t
 lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
                                       const lxb_char_t *end, lexbor_str_t *str,
-                                      lexbor_mraw_t *mraw,
+                                      lexbor_mraw_t *mraw, const uint8_t *url_map,
                                       const lxb_encoding_data_t *encoding,
                                       lxb_url_map_type_t enmap,
                                       bool space_as_plus);
@@ -571,7 +557,7 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
 static lxb_status_t
 lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
                                    const lxb_char_t *end, lexbor_str_t *str,
-                                   lexbor_mraw_t *mraw,
+                                   lexbor_mraw_t *mraw, const uint8_t *url_map,
                                    lxb_url_map_type_t enmap,
                                    bool space_as_plus);
 
@@ -1757,9 +1743,9 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
                         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_USERINFO, false);
+                            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);
                             }
@@ -1768,8 +1754,8 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
 
                     if (pswd != NULL && p > pswd) {
                         status = lxb_url_percent_encode_after_utf_8(pswd, p,
-                                                    &url->password, url->mraw,
-                                                    LXB_URL_MAP_USERINFO, false);
+                                        &url->password, url->mraw, lxb_url_map,
+                                        LXB_URL_MAP_USERINFO, false);
                         if (status != LXB_STATUS_OK) {
                             lxb_url_parse_return(orig_data, buf, status);
                         }
@@ -2319,8 +2305,8 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
             if (p >= end) {
                 tmp_str.data = NULL;
 
-                status = lxb_url_percent_encode_after_utf_8(begin, p,
-                                                            &tmp_str, url->mraw,
+                status = lxb_url_percent_encode_after_utf_8(begin, p, &tmp_str,
+                                                            url->mraw, lxb_url_map,
                                                             LXB_URL_MAP_C0, false);
                 if (status != LXB_STATUS_OK) {
                     lxb_url_parse_return(orig_data, buf, status);
@@ -2336,8 +2322,8 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
             if (c == '#' || c == '?') {
                 tmp_str.data = NULL;
 
-                status = lxb_url_percent_encode_after_utf_8(begin, p,
-                                                            &tmp_str, url->mraw,
+                status = lxb_url_percent_encode_after_utf_8(begin, p, &tmp_str,
+                                                            url->mraw, lxb_url_map,
                                                             LXB_URL_MAP_C0, false);
                 if (status != LXB_STATUS_OK) {
                     lxb_url_parse_return(orig_data, buf, status);
@@ -2407,7 +2393,8 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
 
                 status = lxb_url_percent_encode_after_encoding(begin, p,
                                                                &url->query,
-                                                               url->mraw, enc,
+                                                               url->mraw,
+                                                               lxb_url_map, enc,
                                                                map_type, false);
                 if (status != LXB_STATUS_OK) {
                     lxb_url_parse_return(orig_data, buf, status);
@@ -2461,7 +2448,7 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
         }
 
         status = lxb_url_percent_encode_after_utf_8(begin, p, &url->fragment,
-                                                    url->mraw,
+                                                    url->mraw, lxb_url_map,
                                                     LXB_URL_MAP_FRAGMENT, false);
         lxb_url_parse_return(orig_data, buf, status);
 
@@ -3161,10 +3148,23 @@ lxb_url_scheme_find(const lxb_char_t *data, size_t length)
     return &lxb_url_scheme_res[LXB_URL_SCHEMEL_TYPE__UNKNOWN];
 }
 
+lxb_status_t
+lxb_url_percent_encode_encoding(const lxb_char_t *data, size_t length,
+                                lexbor_str_t *str, lexbor_mraw_t *mraw,
+                                const uint8_t *url_map,
+                                const lxb_encoding_data_t *encoding,
+                                lxb_url_map_type_t enmap,
+                                bool space_as_plus)
+{
+    return lxb_url_percent_encode_after_encoding(data, data + length, str, mraw,
+                                                 url_map, encoding, enmap,
+                                                 space_as_plus);
+}
+
 static lxb_status_t
 lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
                                       const lxb_char_t *end, lexbor_str_t *str,
-                                      lexbor_mraw_t *mraw,
+                                      lexbor_mraw_t *mraw, const uint8_t *url_map,
                                       const lxb_encoding_data_t *encoding,
                                       lxb_url_map_type_t enmap,
                                       bool space_as_plus)
@@ -3182,7 +3182,8 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
 
     if (encoding->encoding == LXB_ENCODING_UTF_8) {
         return lxb_url_percent_encode_after_utf_8(data, end, str, mraw,
-                                                  enmap, space_as_plus);
+                                                  url_map, enmap,
+                                                  space_as_plus);
     }
 
     lxb_url_encoding_init(encoding, &encode);
@@ -3193,7 +3194,7 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
     /* Only valid for UTF-8. */
 
     while (p < end) {
-        if (lxb_url_map[*p++] & enmap) {
+        if (url_map[*p++] & enmap) {
             length += 2;
         }
     }
@@ -3249,7 +3250,7 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
                     return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
                 }
             }
-            else if (lxb_url_map[c] & enmap) {
+            else if (url_map[c] & enmap) {
                 percent[1] = lexbor_str_res_char_to_two_hex_value[c][0];
                 percent[2] = lexbor_str_res_char_to_two_hex_value[c][1];
 
@@ -3280,10 +3281,20 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
     return LXB_STATUS_OK;
 }
 
+lxb_status_t
+lxb_url_percent_encode_utf_8(const lxb_char_t *data, size_t length,
+                             lexbor_str_t *str, lexbor_mraw_t *mraw,
+                             const uint8_t *url_map, lxb_url_map_type_t enmap,
+                             bool space_as_plus)
+{
+    return lxb_url_percent_encode_after_utf_8(data, data + length, str, mraw,
+                                              url_map, enmap, space_as_plus);
+}
+
 static lxb_status_t
 lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
                                    const lxb_char_t *end, lexbor_str_t *str,
-                                   lexbor_mraw_t *mraw,
+                                   lexbor_mraw_t *mraw, const uint8_t *url_map,
                                    lxb_url_map_type_t enmap,
                                    bool space_as_plus)
 {
@@ -3298,7 +3309,7 @@ lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
     /* Only valid for UTF-8. */
 
     while (p < end) {
-        if (lxb_url_map[*p++] & enmap) {
+        if (url_map[*p++] & enmap) {
             length += 2;
         }
     }
@@ -3317,7 +3328,7 @@ lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
         if (space_as_plus && c == ' ') {
             *pd++ = '+';
         }
-        else if (lxb_url_map[c] & enmap) {
+        else if (url_map[c] & enmap) {
             *pd++ = '%';
             *pd++ = lexbor_str_res_char_to_two_hex_value[c][0];
             *pd++ = lexbor_str_res_char_to_two_hex_value[c][1];
@@ -3335,6 +3346,12 @@ lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
     return LXB_STATUS_OK;
 }
 
+const uint8_t *
+lxb_url_get_percent_encoding_map(void)
+{
+    return lxb_url_map;
+}
+
 static lxb_status_t
 lxb_url_host_parse(lxb_url_parser_t *parser, const lxb_char_t *data,
                    const lxb_char_t *end, lxb_url_host_t *host,
@@ -3752,6 +3769,46 @@ lxb_url_is_ipv4(lxb_url_parser_t *parser, const lxb_char_t *data,
     return status != LXB_STATUS_ERROR;
 }
 
+lxb_status_t
+lxb_url_parse_host_ipv6(lxb_url_parser_t *parser, const lxb_char_t *data,
+                        size_t length, uint16_t *ipv6)
+{
+    lxb_status_t status;
+    lxb_url_parser_t self_parser;
+
+    if (parser == NULL) {
+        parser = &self_parser;
+
+        parser->log = NULL;
+        parser->idna = NULL;
+        parser->buffer = NULL;
+    }
+
+    if (data < data + length && *data == '[') {
+        if (data[length - 1] != ']') {
+            (void) lxb_url_log_append(parser, &data[length - 1],
+                                      LXB_URL_ERROR_TYPE_IPV6_UNCLOSED);
+
+            status = LXB_STATUS_ERROR_UNEXPECTED_DATA;
+
+            goto done;
+        }
+
+        data += 1;
+        length -= 2;
+    }
+
+    status = lxb_url_ipv6_parse(parser, data, data + length, ipv6);
+
+done:
+
+    if (parser == &self_parser) {
+        lxb_url_parser_destroy(parser, false);
+    }
+
+    return status;
+}
+
 static lxb_status_t
 lxb_url_ipv6_parse(lxb_url_parser_t *parser, const lxb_char_t *data,
                    const lxb_char_t *end, uint16_t *ipv6)
@@ -3763,6 +3820,8 @@ lxb_url_ipv6_parse(lxb_url_parser_t *parser, const lxb_char_t *data,
     const lxb_char_t *p;
     lxb_url_error_type_t err_type;
 
+    memset(ipv6, 0x00, sizeof(uint16_t) * 8);
+
     piece = ipv6;
     compress = NULL;
     p = data;
@@ -4023,7 +4082,7 @@ lxb_url_opaque_host_parse(lxb_url_parser_t *parser, const lxb_char_t *data,
     host->type = LXB_URL_HOST_TYPE_OPAQUE;
 
     return lxb_url_percent_encode_after_utf_8(data, end, &host->u.opaque, mraw,
-                                              LXB_URL_MAP_C0, false);
+                                              lxb_url_map, LXB_URL_MAP_C0, false);
 }
 
 static lxb_status_t
@@ -4302,7 +4361,8 @@ lxb_url_api_username_set(lxb_url_t *url,
 
     return lxb_url_percent_encode_after_utf_8(username, username + length,
                                               &url->username, url->mraw,
-                                              LXB_URL_MAP_USERINFO, false);
+                                              lxb_url_map, LXB_URL_MAP_USERINFO,
+                                              false);
 }
 
 lxb_status_t
@@ -4322,7 +4382,8 @@ lxb_url_api_password_set(lxb_url_t *url,
 
     return lxb_url_percent_encode_after_utf_8(password, password + length,
                                               &url->password, url->mraw,
-                                              LXB_URL_MAP_USERINFO, false);
+                                              lxb_url_map, LXB_URL_MAP_USERINFO,
+                                              false);
 }
 
 lxb_status_t
diff --git a/ext/lexbor/lexbor/url/url.h b/ext/lexbor/lexbor/url/url.h
index 6cc6f1081c8a..d2c93080c922 100644
--- a/ext/lexbor/lexbor/url/url.h
+++ b/ext/lexbor/lexbor/url/url.h
@@ -81,6 +81,20 @@ typedef enum {
 }
 lxb_url_state_t;
 
+typedef enum {
+    LXB_URL_MAP_UNDEF         = 0x00,
+    LXB_URL_MAP_C0            = 0x01,
+    LXB_URL_MAP_FRAGMENT      = 0x02,
+    LXB_URL_MAP_QUERY         = 0x04,
+    LXB_URL_MAP_SPECIAL_QUERY = 0x08,
+    LXB_URL_MAP_PATH          = 0x10,
+    LXB_URL_MAP_USERINFO      = 0x20,
+    LXB_URL_MAP_COMPONENT     = 0x40,
+    LXB_URL_MAP_X_WWW_FORM    = 0x80,
+    LXB_URL_MAP_ALL           = 0xff
+}
+lxb_url_map_type_t;
+
 /*
  * New values can only be added downwards.
  * Before LXB_URL_SCHEMEL_TYPE__LAST_ENTRY.
@@ -321,6 +335,114 @@ lxb_url_parse_basic(lxb_url_parser_t *parser, lxb_url_t *url,
                     const lxb_char_t *data, size_t length,
                     lxb_url_state_t override_state, lxb_encoding_t encoding);
 
+/*
+ * IPv6 parser.
+ *
+ * This function is an implementation of IPv6 parsing according to the WHATWG
+ * specification.
+ * https://url.spec.whatwg.org/#concept-ipv6-parser
+ *
+ * The address can be passed both with and without the surrounding square
+ * brackets: "::1" and "[::1]" give the same result. If the opening bracket is
+ * present, the closing one is required.
+ *
+ * The output buffer is zeroed by the function, there is no need to prepare it.
+ * Use the lxb_url_serialize_host_ipv6() function to serialize the result.
+ *
+ * @param[in] lxb_url_parser_t *. Can be NULL.
+ * @param[in] Pointer to the beginning of the data. Not NULL.
+ * @param[in] Length of the data. Can be 0.
+ * @param[out] Buffer for eight (uint16_t[8]) IPv6 pieces. Not NULL. The value
+ * is meaningful only if LXB_STATUS_OK is returned.
+ *
+ * @return LXB_STATUS_OK if successful, otherwise an error status value.
+ */
+LXB_API lxb_status_t
+lxb_url_parse_host_ipv6(lxb_url_parser_t *parser, const lxb_char_t *data,
+                        size_t length, uint16_t *ipv6);
+
+/*
+ * UTF-8 percent-encoder.
+ *
+ * Percent-encodes bytes from data according to url_map and appends the result
+ * to str. A byte is encoded as "%HH" when the result of
+ * (url_map[byte] & enmap) is non-zero. Uppercase hexadecimal digits are used.
+ * If space_as_plus is true, U+0020 SPACE is encoded as '+' before the map is
+ * checked.
+ *
+ * The input is expected to be valid UTF-8; the function does not validate it.
+ *
+ * @param[in] Pointer to UTF-8 data. Not NULL.
+ * @param[in] Length of data. Can be 0.
+ * @param[in, out] Output string. Can be uninitialized (data = NULL). Encoded
+ * data is appended to any existing content. Not NULL.
+ * @param[in] Memory object used to allocate or resize the output string. Not
+ * NULL.
+ * @param[in] Table of 256 entries indexed by input byte, each entry is a bit
+ * mask of lxb_url_map_type_t values. Not NULL.
+ * @param[in] Mask selecting the bytes to percent-encode.
+ * @param[in] Replace U+0020 SPACE with '+' if true.
+ *
+ * @return LXB_STATUS_OK if successful, otherwise an error status value.
+ */
+LXB_API lxb_status_t
+lxb_url_percent_encode_utf_8(const lxb_char_t *data, size_t length,
+                             lexbor_str_t *str, lexbor_mraw_t *mraw,
+                             const uint8_t *url_map, lxb_url_map_type_t enmap,
+                             bool space_as_plus);
+
+/*
+ * Percent-encode after encoding.
+ *
+ * Converts valid UTF-8 data to the specified encoding and appends the
+ * percent-encoded result to str. Each encoded byte for which
+ * (url_map[byte] & enmap) is non-zero is written as "%HH" using uppercase
+ * hexadecimal digits. If a code point cannot be represented in the target
+ * encoding, its percent-encoded numeric character reference is appended.
+ *
+ * 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.
+ *
+ * @param[in] Pointer to UTF-8 data. Not NULL.
+ * @param[in] Length of data. Can be 0.
+ * @param[in, out] Output string. Can be uninitialized (data = NULL). Encoded
+ * data is appended to any existing content. Not NULL.
+ * @param[in] Memory object used to allocate or resize the output string. Not
+ * NULL.
+ * @param[in] Table of 256 entries indexed by encoded byte, each entry is a bit
+ * mask of lxb_url_map_type_t values. Not NULL.
+ * @param[in] Target encoding. Not NULL.
+ * @param[in] Mask selecting the bytes to percent-encode.
+ * @param[in] Replace an encoded U+0020 SPACE with '+' if true.
+ *
+ * @return LXB_STATUS_OK if successful, otherwise an error status value.
+ */
+LXB_API lxb_status_t
+lxb_url_percent_encode_encoding(const lxb_char_t *data, size_t length,
+                                lexbor_str_t *str, lexbor_mraw_t *mraw,
+                                const uint8_t *url_map,
+                                const lxb_encoding_data_t *encoding,
+                                lxb_url_map_type_t enmap,
+                                bool space_as_plus);
+
+/*
+ * Get the URL percent-encoding map.
+ *
+ * Returns the built-in lookup table for the percent-encode sets defined by the
+ * URL specification. The table contains 256 entries indexed by byte value.
+ * Each entry is a bit mask of the lxb_url_map_type_t sets in which the byte
+ * must be percent-encoded.
+ *
+ * The returned map can be passed to lxb_url_percent_encode_utf_8() or
+ * lxb_url_percent_encode_encoding(). It has static storage duration and must
+ * not be modified or freed.
+ *
+ * @return Pointer to a read-only table of 256 entries. Never NULL.
+ */
+LXB_API const uint8_t *
+lxb_url_get_percent_encoding_map(void);
+
 /*
  * Erase URL.
  *
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 f23ec0f5034d..b9c4cda7a03a 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 <[email protected]>
 Date: Sat, 26 Aug 2023 15:08:59 +0200
-Subject: [PATCH 01/11] Expose line and column information for use in PHP
+Subject: [PATCH 01/13] 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 8758c09a2e8f..84868e209e75 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 <[email protected]>
 Date: Mon, 14 Aug 2023 20:18:51 +0200
-Subject: [PATCH 02/11] Track implied added nodes for options use in PHP
+Subject: [PATCH 02/13] 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 56458a49deea..357504376679 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 <[email protected]>
 Date: Thu, 24 Aug 2023 22:57:48 +0200
-Subject: [PATCH 03/11] Patch utilities and data structure to be able to
+Subject: [PATCH 03/13] 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 f4fe2050998d..029c0ff69335 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 <[email protected]>
 Date: Wed, 29 Nov 2023 21:26:47 +0100
-Subject: [PATCH 04/11] Remove unused upper case tag static data
+Subject: [PATCH 04/13] 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 f36a2d758181..a8771beea0f2 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 <[email protected]>
 Date: Wed, 29 Nov 2023 21:29:31 +0100
-Subject: [PATCH 05/11] Shrink size of static binary search tree
+Subject: [PATCH 05/13] 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 7c6e1beebf46..a81d614575ca 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 <[email protected]>
 Date: Sun, 7 Jan 2024 21:59:28 +0100
-Subject: [PATCH 06/11] Patch out unused CSS style code
+Subject: [PATCH 06/13] 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 50bdc2397ba5..c49cf53628b2 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?= <[email protected]>
 Date: Sun, 17 May 2026 22:17:14 +0200
-Subject: [PATCH 07/11] Add lxb_url_is_special() to the public API (#362)
+Subject: [PATCH 07/13] 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 4218461c3bad..c8c8e93686d6 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 <[email protected]>
 Date: Fri, 26 Jun 2026 18:55:56 +0300
-Subject: [PATCH 08/11] URL: fixed setters for empty hosts.
+Subject: [PATCH 08/13] 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 91e78a899f44..d95503378bef 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 <[email protected]>
 Date: Fri, 5 Jun 2026 22:13:32 +0300
-Subject: [PATCH 09/11] URL: fixed uninitialized memory in the path buffer
+Subject: [PATCH 09/13] 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 9cbf3e0094ed..624943a49248 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?= <[email protected]>
 Date: Thu, 9 Jul 2026 21:51:05 +0200
-Subject: [PATCH 10/11] Fix parsing for URL containing empty host and userinfo
+Subject: [PATCH 10/13] 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 2781fad5bcb9..f971dc39cff0 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?= <[email protected]>
 Date: Fri, 10 Jul 2026 22:31:16 +0200
-Subject: [PATCH 11/11] Percent-encode the caret in the path
+Subject: [PATCH 11/13] 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
new file mode 100644
index 000000000000..a005dd134919
--- /dev/null
+++ b/ext/lexbor/patches/0012-URL-added-public-IPv6-parser.patch
@@ -0,0 +1,320 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Alexander Borisov <[email protected]>
+Date: Wed, 12 Aug 2026 23:29:20 +0300
+Subject: [PATCH 12/13] URL: added public IPv6 parser.
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+Added lxb_url_parse_host_ipv6() — a public entry point to the IPv6
+parser from the WHATWG specification:
+https://url.spec.whatwg.org/#concept-ipv6-parser
+
+The address is accepted both with and without the surrounding square
+brackets: "::1" and "[::1]" give the same result.
+
+https://github.com/lexbor/lexbor/pull/402
+
+The API was requested in #402 for use by php/php-src#22268.
+
+Suggested-by: Máté Kocsis (@kocsismate)
+---
+ source/lexbor/url/url.c           |  42 +++++++
+ source/lexbor/url/url.h           |  26 ++++
+ test/lexbor/url/parse_host_ipv6.c | 190 ++++++++++++++++++++++++++++++
+ 3 files changed, 258 insertions(+)
+ create mode 100644 test/lexbor/url/parse_host_ipv6.c
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index 8099c12..7487762 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -3752,6 +3752,46 @@ lxb_url_is_ipv4(lxb_url_parser_t *parser, const lxb_char_t *data,
+     return status != LXB_STATUS_ERROR;
+ }
+ 
++lxb_status_t
++lxb_url_parse_host_ipv6(lxb_url_parser_t *parser, const lxb_char_t *data,
++                        size_t length, uint16_t *ipv6)
++{
++    lxb_status_t status;
++    lxb_url_parser_t self_parser;
++
++    if (parser == NULL) {
++        parser = &self_parser;
++
++        parser->log = NULL;
++        parser->idna = NULL;
++        parser->buffer = NULL;
++    }
++
++    if (data < data + length && *data == '[') {
++        if (data[length - 1] != ']') {
++            (void) lxb_url_log_append(parser, &data[length - 1],
++                                      LXB_URL_ERROR_TYPE_IPV6_UNCLOSED);
++
++            status = LXB_STATUS_ERROR_UNEXPECTED_DATA;
++
++            goto done;
++        }
++
++        data += 1;
++        length -= 2;
++    }
++
++    status = lxb_url_ipv6_parse(parser, data, data + length, ipv6);
++
++done:
++
++    if (parser == &self_parser) {
++        lxb_url_parser_destroy(parser, false);
++    }
++
++    return status;
++}
++
+ static lxb_status_t
+ lxb_url_ipv6_parse(lxb_url_parser_t *parser, const lxb_char_t *data,
+                    const lxb_char_t *end, uint16_t *ipv6)
+@@ -3763,6 +3803,8 @@ lxb_url_ipv6_parse(lxb_url_parser_t *parser, const lxb_char_t *data,
+     const lxb_char_t *p;
+     lxb_url_error_type_t err_type;
+ 
++    memset(ipv6, 0x00, sizeof(uint16_t) * 8);
++
+     piece = ipv6;
+     compress = NULL;
+     p = data;
+diff --git a/source/lexbor/url/url.h b/source/lexbor/url/url.h
+index 6cc6f10..aa50485 100644
+--- a/source/lexbor/url/url.h
++++ b/source/lexbor/url/url.h
+@@ -321,6 +321,32 @@ lxb_url_parse_basic(lxb_url_parser_t *parser, lxb_url_t *url,
+                     const lxb_char_t *data, size_t length,
+                     lxb_url_state_t override_state, lxb_encoding_t encoding);
+ 
++/*
++ * IPv6 parser.
++ *
++ * This function is an implementation of IPv6 parsing according to the WHATWG
++ * specification.
++ * https://url.spec.whatwg.org/#concept-ipv6-parser
++ *
++ * The address can be passed both with and without the surrounding square
++ * brackets: "::1" and "[::1]" give the same result. If the opening bracket is
++ * present, the closing one is required.
++ *
++ * The output buffer is zeroed by the function, there is no need to prepare it.
++ * Use the lxb_url_serialize_host_ipv6() function to serialize the result.
++ *
++ * @param[in] lxb_url_parser_t *. Can be NULL.
++ * @param[in] Pointer to the beginning of the data. Not NULL.
++ * @param[in] Length of the data. Can be 0.
++ * @param[out] Buffer for eight (uint16_t[8]) IPv6 pieces. Not NULL. The value
++ * is meaningful only if LXB_STATUS_OK is returned.
++ *
++ * @return LXB_STATUS_OK if successful, otherwise an error status value.
++ */
++LXB_API lxb_status_t
++lxb_url_parse_host_ipv6(lxb_url_parser_t *parser, const lxb_char_t *data,
++                        size_t length, uint16_t *ipv6);
++
+ /*
+  * Erase URL.
+  *
+diff --git a/test/lexbor/url/parse_host_ipv6.c b/test/lexbor/url/parse_host_ipv6.c
+new file mode 100644
+index 0000000..bbf5293
+--- /dev/null
++++ b/test/lexbor/url/parse_host_ipv6.c
+@@ -0,0 +1,190 @@
++/*
++ * Copyright (C) 2026 Alexander Borisov
++ *
++ * Author: Alexander Borisov <[email protected]>
++ */
++
++#include <unit/test.h>
++#include <lexbor/url/url.h>
++
++
++typedef struct {
++    const lxb_char_t *input;
++    size_t           length;
++    uint16_t         ipv6[8];
++}
++ipv6_success_t;
++
++typedef struct {
++    const lxb_char_t     *input;
++    lxb_url_error_type_t error;
++}
++ipv6_failure_t;
++
++
++static const ipv6_success_t success_entries[] = {
++    {
++        (const lxb_char_t *) "::",
++        sizeof("::") - 1,
++        {0, 0, 0, 0, 0, 0, 0, 0}
++    },
++    {
++        (const lxb_char_t *) "::1",
++        sizeof("::1") - 1,
++        {0, 0, 0, 0, 0, 0, 0, 1}
++    },
++    {
++        (const lxb_char_t *) "[::1]",
++        sizeof("[::1]") - 1,
++        {0, 0, 0, 0, 0, 0, 0, 1}
++    },
++    {
++        (const lxb_char_t *) "1:2:3:4:5:6:7:8",
++        sizeof("1:2:3:4:5:6:7:8") - 1,
++        {1, 2, 3, 4, 5, 6, 7, 8}
++    },
++    {
++        (const lxb_char_t *) "2001:db8::ff00:42:8329",
++        sizeof("2001:db8::ff00:42:8329") - 1,
++        {0x2001, 0x0db8, 0, 0, 0, 0xff00, 0x0042, 0x8329}
++    },
++    {
++        (const lxb_char_t *) "::ffff:192.0.2.1",
++        sizeof("::ffff:192.0.2.1") - 1,
++        {0, 0, 0, 0, 0, 0xffff, 0xc000, 0x0201}
++    },
++    {
++        (const lxb_char_t *) "[::1]ignored",
++        5,
++        {0, 0, 0, 0, 0, 0, 0, 1}
++    }
++};
++
++static const ipv6_failure_t failure_entries[] = {
++    {
++        (const lxb_char_t *) "",
++        LXB_URL_ERROR_TYPE_IPV6_TOO_FEW_PIECES
++    },
++    {
++        (const lxb_char_t *) "[::1",
++        LXB_URL_ERROR_TYPE_IPV6_UNCLOSED
++    },
++    {
++        (const lxb_char_t *) ":",
++        LXB_URL_ERROR_TYPE_IPV6_INVALID_COMPRESSION
++    },
++    {
++        (const lxb_char_t *) "1::2::3",
++        LXB_URL_ERROR_TYPE_IPV6_MULTIPLE_COMPRESSION
++    },
++    {
++        (const lxb_char_t *) "1:2:3:4:5:6:7:8:9",
++        LXB_URL_ERROR_TYPE_IPV6_TOO_MANY_PIECES
++    },
++    {
++        (const lxb_char_t *) "1:2:3:4:5:6:7",
++        LXB_URL_ERROR_TYPE_IPV6_TOO_FEW_PIECES
++    },
++    {
++        (const lxb_char_t *) "1:2:3:4:5:6:7:g",
++        LXB_URL_ERROR_TYPE_IPV6_INVALID_CODE_POINT
++    },
++    {
++        (const lxb_char_t *) "1:2:3:4:5:6:7:1.2.3.4",
++        LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_TOO_MANY_PIECES
++    },
++    {
++        (const lxb_char_t *) "::ffff:.1.2.3",
++        LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_INVALID_CODE_POINT
++    },
++    {
++        (const lxb_char_t *) "::ffff:192.0.2.256",
++        LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_OUT_OF_RANGE_PART
++    },
++    {
++        (const lxb_char_t *) "::ffff:192.0.2",
++        LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_TOO_FEW_PARTS
++    }
++};
++
++
++TEST_BEGIN(parse_success)
++{
++    size_t length;
++    lxb_status_t status;
++    uint16_t ipv6[8];
++
++    length = sizeof(success_entries) / sizeof(ipv6_success_t);
++
++    for (size_t i = 0; i < length; i++) {
++        memset(ipv6, 0xff, sizeof(ipv6));
++
++        status = lxb_url_parse_host_ipv6(NULL, success_entries[i].input,
++                                         success_entries[i].length, ipv6);
++        test_eq(status, LXB_STATUS_OK);
++
++        for (size_t j = 0; j < 8; j++) {
++            test_eq_u_short(ipv6[j], success_entries[i].ipv6[j]);
++        }
++    }
++}
++TEST_END
++
++TEST_BEGIN(parse_failure)
++{
++    size_t length;
++    lxb_status_t status;
++    lxb_url_parser_t parser;
++    lexbor_plog_entry_t *error;
++
++    status = lxb_url_parser_init(&parser, NULL);
++    test_eq(status, LXB_STATUS_OK);
++
++    length = sizeof(failure_entries) / sizeof(ipv6_failure_t);
++
++    for (size_t i = 0; i < length; i++) {
++        status = lxb_url_parse_host_ipv6(
++            &parser, failure_entries[i].input,
++            strlen((const char *) failure_entries[i].input),
++            (uint16_t[8]) {0});
++
++        test_eq(status, LXB_STATUS_ERROR_UNEXPECTED_DATA);
++        test_ne(parser.log, NULL);
++        test_eq_size(lexbor_plog_length(parser.log), 1UL);
++
++        error = lexbor_array_obj_get(&parser.log->list, 0);
++        test_ne(error, NULL);
++        test_eq(error->id, failure_entries[i].error);
++
++        lxb_url_parser_clean(&parser);
++    }
++
++    lxb_url_parser_memory_destroy(&parser);
++    lxb_url_parser_destroy(&parser, false);
++}
++TEST_END
++
++TEST_BEGIN(parse_failure_without_parser)
++{
++    lxb_status_t status;
++    uint16_t ipv6[8];
++
++    static const lexbor_str_t input = lexbor_str("::ffff:192.0.2.256");
++
++    status = lxb_url_parse_host_ipv6(NULL, input.data, input.length, ipv6);
++    test_eq(status, LXB_STATUS_ERROR_UNEXPECTED_DATA);
++}
++TEST_END
++
++int
++main(int argc, const char *argv[])
++{
++    TEST_INIT();
++
++    TEST_ADD(parse_success);
++    TEST_ADD(parse_failure);
++    TEST_ADD(parse_failure_without_parser);
++
++    TEST_RUN("lexbor/url/parse_host_ipv6");
++    TEST_RELEASE();
++}
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
new file mode 100644
index 000000000000..c5d11c8f64f1
--- /dev/null
+++ b/ext/lexbor/patches/0013-URL-added-public-percent-encoder-API.patch
@@ -0,0 +1,626 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Alexander Borisov <[email protected]>
+Date: Thu, 13 Aug 2026 23:29:16 +0300
+Subject: [PATCH 13/13] URL: added public percent-encoder API.
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+Added public entry points to the percent-encoder from the WHATWG
+specification:
+https://url.spec.whatwg.org/#percent-encoded-bytes
+
+    lxb_url_percent_encode_utf_8()
+    lxb_url_percent_encode_encoding()
+    lxb_url_get_percent_encoding_map()
+
+Both encoders take a caller-supplied table of 256 entries indexed by byte
+value, where each entry is a bit mask of lxb_url_map_type_t values, so the
+percent-encode sets can be adjusted without patching the library.
+lxb_url_get_percent_encoding_map() returns the built-in table for callers
+that need only the sets defined by the specification.
+
+https://github.com/lexbor/lexbor/pull/404
+
+The API was requested in #404 for use by PHP:
+https://wiki.php.net/rfc/uri_followup#percent-encoding_support
+
+Based-on-patch-by: Máté Kocsis (@kocsismate)
+---
+ source/lexbor/url/url.c          |  93 ++++++++-----
+ source/lexbor/url/url.h          |  96 +++++++++++++
+ test/lexbor/url/percent_encode.c | 228 +++++++++++++++++++++++++++++++
+ 3 files changed, 380 insertions(+), 37 deletions(-)
+ create mode 100644 test/lexbor/url/percent_encode.c
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index 7487762..69d9196 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -27,20 +27,6 @@
+ #define LXB_URL_BUFFER_NUM_SIZE 128
+ 
+ 
+-typedef enum {
+-    LXB_URL_MAP_UNDEF         = 0x00,
+-    LXB_URL_MAP_C0            = 0x01,
+-    LXB_URL_MAP_FRAGMENT      = 0x02,
+-    LXB_URL_MAP_QUERY         = 0x04,
+-    LXB_URL_MAP_SPECIAL_QUERY = 0x08,
+-    LXB_URL_MAP_PATH          = 0x10,
+-    LXB_URL_MAP_USERINFO      = 0x20,
+-    LXB_URL_MAP_COMPONENT     = 0x40,
+-    LXB_URL_MAP_X_WWW_FORM    = 0x80,
+-    LXB_URL_MAP_ALL           = 0xff
+-}
+-lxb_url_map_type_t;
+-
+ typedef enum {
+     LXB_URL_HOST_OPT_UNDEF       = 0 << 0,
+     LXB_URL_HOST_OPT_NOT_SPECIAL = 1 << 0,
+@@ -563,7 +549,7 @@ lxb_url_path_fix_windows_drive(lxb_url_t *url, lxb_char_t *sbuf,
+ static lxb_status_t
+ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
+                                       const lxb_char_t *end, lexbor_str_t *str,
+-                                      lexbor_mraw_t *mraw,
++                                      lexbor_mraw_t *mraw, const uint8_t *url_map,
+                                       const lxb_encoding_data_t *encoding,
+                                       lxb_url_map_type_t enmap,
+                                       bool space_as_plus);
+@@ -571,7 +557,7 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
+ static lxb_status_t
+ lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
+                                    const lxb_char_t *end, lexbor_str_t *str,
+-                                   lexbor_mraw_t *mraw,
++                                   lexbor_mraw_t *mraw, const uint8_t *url_map,
+                                    lxb_url_map_type_t enmap,
+                                    bool space_as_plus);
+ 
+@@ -1757,9 +1743,9 @@ again:
+                         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_USERINFO, false);
++                            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);
+                             }
+@@ -1768,8 +1754,8 @@ again:
+ 
+                     if (pswd != NULL && p > pswd) {
+                         status = lxb_url_percent_encode_after_utf_8(pswd, p,
+-                                                    &url->password, url->mraw,
+-                                                    LXB_URL_MAP_USERINFO, false);
++                                        &url->password, url->mraw, lxb_url_map,
++                                        LXB_URL_MAP_USERINFO, false);
+                         if (status != LXB_STATUS_OK) {
+                             lxb_url_parse_return(orig_data, buf, status);
+                         }
+@@ -2319,8 +2305,8 @@ again:
+             if (p >= end) {
+                 tmp_str.data = NULL;
+ 
+-                status = lxb_url_percent_encode_after_utf_8(begin, p,
+-                                                            &tmp_str, url->mraw,
++                status = lxb_url_percent_encode_after_utf_8(begin, p, &tmp_str,
++                                                            url->mraw, lxb_url_map,
+                                                             LXB_URL_MAP_C0, false);
+                 if (status != LXB_STATUS_OK) {
+                     lxb_url_parse_return(orig_data, buf, status);
+@@ -2336,8 +2322,8 @@ again:
+             if (c == '#' || c == '?') {
+                 tmp_str.data = NULL;
+ 
+-                status = lxb_url_percent_encode_after_utf_8(begin, p,
+-                                                            &tmp_str, url->mraw,
++                status = lxb_url_percent_encode_after_utf_8(begin, p, &tmp_str,
++                                                            url->mraw, lxb_url_map,
+                                                             LXB_URL_MAP_C0, false);
+                 if (status != LXB_STATUS_OK) {
+                     lxb_url_parse_return(orig_data, buf, status);
+@@ -2407,7 +2393,8 @@ again:
+ 
+                 status = lxb_url_percent_encode_after_encoding(begin, p,
+                                                                &url->query,
+-                                                               url->mraw, enc,
++                                                               url->mraw,
++                                                               lxb_url_map, enc,
+                                                                map_type, false);
+                 if (status != LXB_STATUS_OK) {
+                     lxb_url_parse_return(orig_data, buf, status);
+@@ -2461,7 +2448,7 @@ again:
+         }
+ 
+         status = lxb_url_percent_encode_after_utf_8(begin, p, &url->fragment,
+-                                                    url->mraw,
++                                                    url->mraw, lxb_url_map,
+                                                     LXB_URL_MAP_FRAGMENT, false);
+         lxb_url_parse_return(orig_data, buf, status);
+ 
+@@ -3161,10 +3148,23 @@ lxb_url_scheme_find(const lxb_char_t *data, size_t length)
+     return &lxb_url_scheme_res[LXB_URL_SCHEMEL_TYPE__UNKNOWN];
+ }
+ 
++lxb_status_t
++lxb_url_percent_encode_encoding(const lxb_char_t *data, size_t length,
++                                lexbor_str_t *str, lexbor_mraw_t *mraw,
++                                const uint8_t *url_map,
++                                const lxb_encoding_data_t *encoding,
++                                lxb_url_map_type_t enmap,
++                                bool space_as_plus)
++{
++    return lxb_url_percent_encode_after_encoding(data, data + length, str, mraw,
++                                                 url_map, encoding, enmap,
++                                                 space_as_plus);
++}
++
+ static lxb_status_t
+ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
+                                       const lxb_char_t *end, lexbor_str_t *str,
+-                                      lexbor_mraw_t *mraw,
++                                      lexbor_mraw_t *mraw, const uint8_t *url_map,
+                                       const lxb_encoding_data_t *encoding,
+                                       lxb_url_map_type_t enmap,
+                                       bool space_as_plus)
+@@ -3182,7 +3182,8 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
+ 
+     if (encoding->encoding == LXB_ENCODING_UTF_8) {
+         return lxb_url_percent_encode_after_utf_8(data, end, str, mraw,
+-                                                  enmap, space_as_plus);
++                                                  url_map, enmap,
++                                                  space_as_plus);
+     }
+ 
+     lxb_url_encoding_init(encoding, &encode);
+@@ -3193,7 +3194,7 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
+     /* Only valid for UTF-8. */
+ 
+     while (p < end) {
+-        if (lxb_url_map[*p++] & enmap) {
++        if (url_map[*p++] & enmap) {
+             length += 2;
+         }
+     }
+@@ -3249,7 +3250,7 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
+                     return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
+                 }
+             }
+-            else if (lxb_url_map[c] & enmap) {
++            else if (url_map[c] & enmap) {
+                 percent[1] = lexbor_str_res_char_to_two_hex_value[c][0];
+                 percent[2] = lexbor_str_res_char_to_two_hex_value[c][1];
+ 
+@@ -3280,10 +3281,20 @@ lxb_url_percent_encode_after_encoding(const lxb_char_t *data,
+     return LXB_STATUS_OK;
+ }
+ 
++lxb_status_t
++lxb_url_percent_encode_utf_8(const lxb_char_t *data, size_t length,
++                             lexbor_str_t *str, lexbor_mraw_t *mraw,
++                             const uint8_t *url_map, lxb_url_map_type_t enmap,
++                             bool space_as_plus)
++{
++    return lxb_url_percent_encode_after_utf_8(data, data + length, str, mraw,
++                                              url_map, enmap, space_as_plus);
++}
++
+ static lxb_status_t
+ lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
+                                    const lxb_char_t *end, lexbor_str_t *str,
+-                                   lexbor_mraw_t *mraw,
++                                   lexbor_mraw_t *mraw, const uint8_t *url_map,
+                                    lxb_url_map_type_t enmap,
+                                    bool space_as_plus)
+ {
+@@ -3298,7 +3309,7 @@ lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
+     /* Only valid for UTF-8. */
+ 
+     while (p < end) {
+-        if (lxb_url_map[*p++] & enmap) {
++        if (url_map[*p++] & enmap) {
+             length += 2;
+         }
+     }
+@@ -3317,7 +3328,7 @@ lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
+         if (space_as_plus && c == ' ') {
+             *pd++ = '+';
+         }
+-        else if (lxb_url_map[c] & enmap) {
++        else if (url_map[c] & enmap) {
+             *pd++ = '%';
+             *pd++ = lexbor_str_res_char_to_two_hex_value[c][0];
+             *pd++ = lexbor_str_res_char_to_two_hex_value[c][1];
+@@ -3335,6 +3346,12 @@ lxb_url_percent_encode_after_utf_8(const lxb_char_t *data,
+     return LXB_STATUS_OK;
+ }
+ 
++const uint8_t *
++lxb_url_get_percent_encoding_map(void)
++{
++    return lxb_url_map;
++}
++
+ static lxb_status_t
+ lxb_url_host_parse(lxb_url_parser_t *parser, const lxb_char_t *data,
+                    const lxb_char_t *end, lxb_url_host_t *host,
+@@ -4065,7 +4082,7 @@ lxb_url_opaque_host_parse(lxb_url_parser_t *parser, const lxb_char_t *data,
+     host->type = LXB_URL_HOST_TYPE_OPAQUE;
+ 
+     return lxb_url_percent_encode_after_utf_8(data, end, &host->u.opaque, mraw,
+-                                              LXB_URL_MAP_C0, false);
++                                              lxb_url_map, LXB_URL_MAP_C0, false);
+ }
+ 
+ static lxb_status_t
+@@ -4344,7 +4361,8 @@ lxb_url_api_username_set(lxb_url_t *url,
+ 
+     return lxb_url_percent_encode_after_utf_8(username, username + length,
+                                               &url->username, url->mraw,
+-                                              LXB_URL_MAP_USERINFO, false);
++                                              lxb_url_map, LXB_URL_MAP_USERINFO,
++                                              false);
+ }
+ 
+ lxb_status_t
+@@ -4364,7 +4382,8 @@ lxb_url_api_password_set(lxb_url_t *url,
+ 
+     return lxb_url_percent_encode_after_utf_8(password, password + length,
+                                               &url->password, url->mraw,
+-                                              LXB_URL_MAP_USERINFO, false);
++                                              lxb_url_map, LXB_URL_MAP_USERINFO,
++                                              false);
+ }
+ 
+ lxb_status_t
+diff --git a/source/lexbor/url/url.h b/source/lexbor/url/url.h
+index aa50485..d2c9308 100644
+--- a/source/lexbor/url/url.h
++++ b/source/lexbor/url/url.h
+@@ -81,6 +81,20 @@ typedef enum {
+ }
+ lxb_url_state_t;
+ 
++typedef enum {
++    LXB_URL_MAP_UNDEF         = 0x00,
++    LXB_URL_MAP_C0            = 0x01,
++    LXB_URL_MAP_FRAGMENT      = 0x02,
++    LXB_URL_MAP_QUERY         = 0x04,
++    LXB_URL_MAP_SPECIAL_QUERY = 0x08,
++    LXB_URL_MAP_PATH          = 0x10,
++    LXB_URL_MAP_USERINFO      = 0x20,
++    LXB_URL_MAP_COMPONENT     = 0x40,
++    LXB_URL_MAP_X_WWW_FORM    = 0x80,
++    LXB_URL_MAP_ALL           = 0xff
++}
++lxb_url_map_type_t;
++
+ /*
+  * New values can only be added downwards.
+  * Before LXB_URL_SCHEMEL_TYPE__LAST_ENTRY.
+@@ -347,6 +361,88 @@ LXB_API lxb_status_t
+ lxb_url_parse_host_ipv6(lxb_url_parser_t *parser, const lxb_char_t *data,
+                         size_t length, uint16_t *ipv6);
+ 
++/*
++ * UTF-8 percent-encoder.
++ *
++ * Percent-encodes bytes from data according to url_map and appends the result
++ * to str. A byte is encoded as "%HH" when the result of
++ * (url_map[byte] & enmap) is non-zero. Uppercase hexadecimal digits are used.
++ * If space_as_plus is true, U+0020 SPACE is encoded as '+' before the map is
++ * checked.
++ *
++ * The input is expected to be valid UTF-8; the function does not validate it.
++ *
++ * @param[in] Pointer to UTF-8 data. Not NULL.
++ * @param[in] Length of data. Can be 0.
++ * @param[in, out] Output string. Can be uninitialized (data = NULL). Encoded
++ * data is appended to any existing content. Not NULL.
++ * @param[in] Memory object used to allocate or resize the output string. Not
++ * NULL.
++ * @param[in] Table of 256 entries indexed by input byte, each entry is a bit
++ * mask of lxb_url_map_type_t values. Not NULL.
++ * @param[in] Mask selecting the bytes to percent-encode.
++ * @param[in] Replace U+0020 SPACE with '+' if true.
++ *
++ * @return LXB_STATUS_OK if successful, otherwise an error status value.
++ */
++LXB_API lxb_status_t
++lxb_url_percent_encode_utf_8(const lxb_char_t *data, size_t length,
++                             lexbor_str_t *str, lexbor_mraw_t *mraw,
++                             const uint8_t *url_map, lxb_url_map_type_t enmap,
++                             bool space_as_plus);
++
++/*
++ * Percent-encode after encoding.
++ *
++ * Converts valid UTF-8 data to the specified encoding and appends the
++ * percent-encoded result to str. Each encoded byte for which
++ * (url_map[byte] & enmap) is non-zero is written as "%HH" using uppercase
++ * hexadecimal digits. If a code point cannot be represented in the target
++ * encoding, its percent-encoded numeric character reference is appended.
++ *
++ * 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.
++ *
++ * @param[in] Pointer to UTF-8 data. Not NULL.
++ * @param[in] Length of data. Can be 0.
++ * @param[in, out] Output string. Can be uninitialized (data = NULL). Encoded
++ * data is appended to any existing content. Not NULL.
++ * @param[in] Memory object used to allocate or resize the output string. Not
++ * NULL.
++ * @param[in] Table of 256 entries indexed by encoded byte, each entry is a bit
++ * mask of lxb_url_map_type_t values. Not NULL.
++ * @param[in] Target encoding. Not NULL.
++ * @param[in] Mask selecting the bytes to percent-encode.
++ * @param[in] Replace an encoded U+0020 SPACE with '+' if true.
++ *
++ * @return LXB_STATUS_OK if successful, otherwise an error status value.
++ */
++LXB_API lxb_status_t
++lxb_url_percent_encode_encoding(const lxb_char_t *data, size_t length,
++                                lexbor_str_t *str, lexbor_mraw_t *mraw,
++                                const uint8_t *url_map,
++                                const lxb_encoding_data_t *encoding,
++                                lxb_url_map_type_t enmap,
++                                bool space_as_plus);
++
++/*
++ * Get the URL percent-encoding map.
++ *
++ * Returns the built-in lookup table for the percent-encode sets defined by the
++ * URL specification. The table contains 256 entries indexed by byte value.
++ * Each entry is a bit mask of the lxb_url_map_type_t sets in which the byte
++ * must be percent-encoded.
++ *
++ * The returned map can be passed to lxb_url_percent_encode_utf_8() or
++ * lxb_url_percent_encode_encoding(). It has static storage duration and must
++ * not be modified or freed.
++ *
++ * @return Pointer to a read-only table of 256 entries. Never NULL.
++ */
++LXB_API const uint8_t *
++lxb_url_get_percent_encoding_map(void);
++
+ /*
+  * Erase URL.
+  *
+diff --git a/test/lexbor/url/percent_encode.c b/test/lexbor/url/percent_encode.c
+new file mode 100644
+index 0000000..361e22e
+--- /dev/null
++++ b/test/lexbor/url/percent_encode.c
+@@ -0,0 +1,228 @@
++/*
++ * Copyright (C) 2026 Alexander Borisov
++ *
++ * Author: Alexander Borisov <[email protected]>
++ */
++
++#include <unit/test.h>
++#include <lexbor/url/url.h>
++
++
++typedef struct {
++    const lexbor_str_t    input;
++    const lexbor_str_t    output;
++    lxb_encoding_t        encoding;
++    lxb_url_map_type_t    enmap;
++    bool                  space_as_plus;
++    const uint8_t         *url_map;
++    const lexbor_str_t    initial;
++}
++percent_encode_entry_t;
++
++
++static const uint8_t custom_url_map[256] = {
++    ['a'] = LXB_URL_MAP_QUERY,
++    ['b'] = LXB_URL_MAP_PATH
++};
++
++static const percent_encode_entry_t entries[] = {
++    {
++        lexbor_str(""),
++        lexbor_str(""),
++        LXB_ENCODING_UTF_8,
++        LXB_URL_MAP_COMPONENT,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("AZaz09-._~!*'()"),
++        lexbor_str("AZaz09-._~!*'()"),
++        LXB_ENCODING_UTF_8,
++        LXB_URL_MAP_COMPONENT,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("\x00" "A /?\xC3\xA9"),
++        lexbor_str("%00A%20%2F%3F%C3%A9"),
++        LXB_ENCODING_UTF_8,
++        LXB_URL_MAP_COMPONENT,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("A b"),
++        lexbor_str("prefix:A+b"),
++        LXB_ENCODING_UTF_8,
++        LXB_URL_MAP_COMPONENT,
++        true,
++        NULL,
++        lexbor_str("prefix:")
++    },
++    {
++        lexbor_str("\xE2\x89\xA1\xE2\x80\xBD"),
++        lexbor_str("%E2%89%A1%E2%80%BD"),
++        LXB_ENCODING_UTF_8,
++        LXB_URL_MAP_SPECIAL_QUERY,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("\xE2\x89\xA1"),
++        lexbor_str("%81%DF"),
++        LXB_ENCODING_SHIFT_JIS,
++        LXB_URL_MAP_SPECIAL_QUERY,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("\xE2\x80\xBD"),
++        lexbor_str("%26%238253%3B"),
++        LXB_ENCODING_SHIFT_JIS,
++        LXB_URL_MAP_SPECIAL_QUERY,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("1+1 \xE2\x89\xA1 2%20\xE2\x80\xBD"),
++        lexbor_str("1+1%20%81%DF%202%20%26%238253%3B"),
++        LXB_ENCODING_SHIFT_JIS,
++        LXB_URL_MAP_SPECIAL_QUERY,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("\xC2\xA5"),
++        lexbor_str("%1B(J\\%1B(B"),
++        LXB_ENCODING_ISO_2022_JP,
++        LXB_URL_MAP_SPECIAL_QUERY,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("caf\xC3\xA9"),
++        lexbor_str("caf%E9"),
++        LXB_ENCODING_WINDOWS_1252,
++        LXB_URL_MAP_COMPONENT,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("\xD0\xAF"),
++        lexbor_str("%DF"),
++        LXB_ENCODING_WINDOWS_1251,
++        LXB_URL_MAP_COMPONENT,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("\xE4\xB8\xAD\xE6\x96\x87"),
++        lexbor_str("%A4%A4%A4%E5"),
++        LXB_ENCODING_BIG5,
++        LXB_URL_MAP_COMPONENT,
++        false,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("a b+c~"),
++        lexbor_str("a+b%2Bc%7E"),
++        LXB_ENCODING_UTF_8,
++        LXB_URL_MAP_X_WWW_FORM,
++        true,
++        NULL,
++        lexbor_str("")
++    },
++    {
++        lexbor_str("abc"),
++        lexbor_str("%61bc"),
++        LXB_ENCODING_UTF_8,
++        LXB_URL_MAP_QUERY,
++        false,
++        custom_url_map,
++        lexbor_str("")
++    }
++};
++
++
++TEST_BEGIN(percent_encode)
++{
++    size_t length;
++    lxb_char_t *data;
++    lxb_status_t status;
++    lexbor_mraw_t mraw;
++    lexbor_str_t str;
++    const uint8_t *url_map, *default_url_map;
++    const lxb_encoding_data_t *encoding;
++    const percent_encode_entry_t *entry;
++
++    status = lexbor_mraw_init(&mraw, 1024);
++    test_eq(status, LXB_STATUS_OK);
++
++    default_url_map = lxb_url_get_percent_encoding_map();
++    test_ne(default_url_map, NULL);
++
++    length = sizeof(entries) / sizeof(percent_encode_entry_t);
++
++    for (size_t i = 0; i < length; i++) {
++        entry = &entries[i];
++        encoding = lxb_encoding_data(entry->encoding);
++        test_ne(encoding, NULL);
++
++        str = (lexbor_str_t) {0};
++
++        if (entry->initial.length != 0) {
++            data = lexbor_str_init_append(&str, &mraw, entry->initial.data,
++                                          entry->initial.length);
++            test_ne(data, NULL);
++        }
++
++        url_map = entry->url_map;
++        if (url_map == NULL) {
++            url_map = default_url_map;
++        }
++
++        status = lxb_url_percent_encode_encoding(entry->input.data,
++                                                 entry->input.length,
++                                                 &str, &mraw, url_map, encoding,
++                                                 entry->enmap,
++                                                 entry->space_as_plus);
++        test_eq(status, LXB_STATUS_OK);
++
++        if (str.length != entry->output.length
++            || memcmp(str.data, entry->output.data, str.length) != 0)
++        {
++            TEST_PRINTLN("Percent-encode entry %zu (%s)", i + 1,
++                         encoding->name);
++        }
++
++        test_eq_str_n(str.data, str.length, entry->output.data,
++                      entry->output.length);
++
++        lexbor_str_destroy(&str, &mraw, false);
++    }
++
++    lexbor_mraw_destroy(&mraw, false);
++}
++TEST_END
++
++int
++main(int argc, const char *argv[])
++{
++    TEST_INIT();
++
++    TEST_ADD(percent_encode);
++
++    TEST_RUN("lexbor/url/percent_encode");
++    TEST_RELEASE();
++}
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.