From deb8734463efc55c652688795c8ee9af4ba0201e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Thu, 27 Aug 2026 11:52:11 +0200 Subject: [PATCH 1/8] Implement "Followup improvements for ext/uri" RFC - URL building with base URL RFC: https://wiki.php.net/rfc/uri_followup#uri_building Add support for passing a non-null $baseUrl parameter for Uri\WhatWg\UrlBuilder::build(). --- ext/lexbor/lexbor/url/url.c | 4 +- ext/lexbor/lexbor/url/url.h | 6 + ext/uri/php_uri.c | 2 - .../builder/basic_error_with_opaque_base.phpt | 17 ++ .../builder/basic_success_with_base.phpt | 2 - ...asic_success_with_scheme_relative_url.phpt | 37 ++++ .../fragment_success_with_opaque_base.phpt | 35 ++++ ext/uri/uri_parser_whatwg.c | 174 ++++++++++++++++-- 8 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt create mode 100644 ext/uri/tests/whatwg/builder/fragment_success_with_opaque_base.phpt diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c index 69d91969a6a1..93afbd65b7e5 100644 --- a/ext/lexbor/lexbor/url/url.c +++ b/ext/lexbor/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); diff --git a/ext/lexbor/lexbor/url/url.h b/ext/lexbor/lexbor/url/url.h index d2c93080c922..bf4e68c50ece 100644 --- a/ext/lexbor/lexbor/url/url.h +++ b/ext/lexbor/lexbor/url/url.h @@ -894,6 +894,12 @@ lxb_url_search_params_serialize(lxb_url_search_params_t *search_params, LXB_API bool lxb_url_is_special(const lxb_url_t *url); +LXB_API void +lxb_url_path_set_null(lxb_url_t *url); + +LXB_API void +lxb_url_host_set_empty(lxb_url_host_t *host, lexbor_mraw_t *mraw); + /* * Inline functions. */ diff --git a/ext/uri/php_uri.c b/ext/uri/php_uri.c index aab94e946185..bdf1201e425e 100644 --- a/ext/uri/php_uri.c +++ b/ext/uri/php_uri.c @@ -1425,8 +1425,6 @@ PHP_METHOD(Uri_WhatWg_UrlBuilder, build) lxb_url_t *base_url = NULL; if (base_url_zv != NULL) { - zend_argument_error(NULL, 1, "is not supported yet, and therefore, null must be passed"); - RETURN_THROWS(); base_url = Z_URI_OBJECT_P(base_url_zv)->uri; } diff --git a/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt new file mode 100644 index 000000000000..a9b3528b4d89 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder basic - error - with base URL containing opaque path +--FILE-- +setPath("/foo/bar/baz"); + +try { + $builder->build(new Uri\WhatWg\Url("scheme:opaque-path")); +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified path is malformed (MissingSchemeNonRelativeUrl) diff --git a/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt b/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt index 1fc354581db6..fcf4c42085e5 100644 --- a/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt +++ b/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt @@ -1,7 +1,5 @@ --TEST-- Test Uri\WhatWg\UrlBuilder basic - success - with base URL ---XFAIL-- -Support for passing $baseUrl to Uri\WhatWg\UrlBuilder::build() is not implemented yet. --FILE-- setHost("example.net"); +$builder->setPath("/foo/bar/baz"); +$builder->setPort(124); +$url = $builder->build(new Uri\WhatWg\Url("https://user:pass@example.com:123/foo/bar?query#hash")); + +var_dump($url->toAsciiString()); +var_dump($url); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECTF-- +string(45) "https://user:pass@example.net:124/foo/bar/baz" +object(Uri\WhatWg\Url)#%d (%d) { + ["scheme"]=> + string(5) "https" + ["username"]=> + string(4) "user" + ["password"]=> + string(4) "pass" + ["host"]=> + string(11) "example.net" + ["port"]=> + int(124) + ["path"]=> + string(12) "/foo/bar/baz" + ["query"]=> + NULL + ["fragment"]=> + NULL +} +bool(true) diff --git a/ext/uri/tests/whatwg/builder/fragment_success_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/fragment_success_with_opaque_base.phpt new file mode 100644 index 000000000000..53e0af4f66f6 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/fragment_success_with_opaque_base.phpt @@ -0,0 +1,35 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setFragment() - success - with base URL with opaque path +--FILE-- +setFragment("foo"); +$url = $builder->build(new Uri\WhatWg\Url("scheme:opaque-path")); + +var_dump($url->toAsciiString()); +var_dump($url); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECTF-- +string(22) "scheme:opaque-path#foo" +object(Uri\WhatWg\Url)#%d (%d) { + ["scheme"]=> + string(6) "scheme" + ["username"]=> + NULL + ["password"]=> + NULL + ["host"]=> + NULL + ["port"]=> + NULL + ["path"]=> + string(11) "opaque-path" + ["query"]=> + NULL + ["fragment"]=> + string(3) "foo" +} +bool(true) diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index 8fee839b1cc4..3a0d2ceb9952 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -203,10 +203,8 @@ ZEND_ATTRIBUTE_NONNULL static const char *fill_errors(zval *errors) return fill_errors_inner(Z_ARRVAL_P(errors)); } -static void throw_invalid_url_exception_during_write(zval *errors, const char *component) +static void throw_invalid_url_exception_with_reason(zval *errors, const char *component, const char *reason, zval *err) { - zval err; - const char *reason = fill_errors(&err); zend_object *exception = zend_throw_exception_ex( php_uri_ce_whatwg_invalid_url_exception, 0, @@ -216,15 +214,23 @@ static void throw_invalid_url_exception_during_write(zval *errors, const char *c reason ? reason : "", reason ? ")" : "" ); - zend_update_property(exception->ce, exception, ZEND_STRL("errors"), &err); + zend_update_property(exception->ce, exception, ZEND_STRL("errors"), err); if (errors) { zval_ptr_dtor(errors); - ZVAL_COPY_VALUE(errors, &err); + ZVAL_COPY_VALUE(errors, err); } else { - zval_ptr_dtor(&err); + zval_ptr_dtor(err); } } +static void throw_invalid_url_exception_during_write(zval *errors, const char *component) +{ + zval err; + const char *reason = fill_errors(&err); + + throw_invalid_url_exception_with_reason(errors, component, reason, &err); +} + static lxb_status_t serialize_to_smart_str_callback(const lxb_char_t *data, const size_t length, void *ctx) { smart_str *uri_str = ctx; @@ -968,12 +974,12 @@ ZEND_ATTRIBUTE_NONNULL static lxb_url_scheme_type_t php_uri_parser_whatwg_get_sp return LXB_URL_SCHEMEL_TYPE__UNDEF; } -ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors(zval *errors) +ZEND_ATTRIBUTE_NONNULL const char *php_uri_parser_whatwg_build_errors(zval *errors) { size_t log_len; if (lexbor_parser.log == NULL || (log_len = lexbor_plog_length(lexbor_parser.log)) == 0) { - return; + return NULL; } if (Z_TYPE_P(errors) != IS_ARRAY) { @@ -981,7 +987,146 @@ ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors(zval *erro array_init_size(errors, log_len); } - fill_errors_inner(Z_ARRVAL_P(errors)); + return fill_errors_inner(Z_ARRVAL_P(errors)); +} + +ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors_and_throw(const lxb_status_t status, const char *component, zval *errors) +{ + zval err; + ZVAL_UNDEF(&err); + + const char *reason = php_uri_parser_whatwg_build_errors(&err); + + if (status != LXB_STATUS_OK) { + throw_invalid_url_exception_with_reason(errors, component, reason, &err); + } +} + +ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_whatwg_resolve_reference_from_zval( + lxb_url_t *lexbor_base_url, const zval *scheme, const zval *username, const zval *password, + const zval *host, const zval *port, const zval *path, const zval *query, const zval *fragment, + zval *errors_zv +) { + lxb_status_t status; + zval errors; + ZVAL_UNDEF(&errors); + + lxb_url_t *lexbor_url = php_uri_parser_whatwg_clone(lexbor_base_url); + if (Z_TYPE_P(username) == IS_STRING) { + status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, + (lxb_char_t *) Z_STRVAL_P(username), Z_STRLEN_P(username), + LXB_URL_STATE_AUTHORITY_STATE, LXB_ENCODING_AUTO + ); + php_uri_parser_whatwg_build_errors_and_throw(status, "username", &errors); + if (status != LXB_STATUS_OK) { + goto failure; + } + } + + if (Z_TYPE_P(password) == IS_STRING) { + status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, + (lxb_char_t *) Z_STRVAL_P(password), Z_STRLEN_P(password), + LXB_URL_STATE_AUTHORITY_STATE, LXB_ENCODING_AUTO + ); + php_uri_parser_whatwg_build_errors_and_throw(status, "password", &errors); + if (status != LXB_STATUS_OK) { + goto failure; + } + } + + if (Z_TYPE_P(host) == IS_STRING) { + lxb_url_host_set_empty(&lexbor_url->host, &lexbor_mraw); + status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, + (lxb_char_t *) Z_STRVAL_P(host), Z_STRLEN_P(host), + LXB_URL_STATE_HOST_STATE, LXB_ENCODING_AUTO + ); + php_uri_parser_whatwg_build_errors_and_throw(status, "host", &errors); + if (status != LXB_STATUS_OK) { + goto failure; + } + } + + if (Z_TYPE_P(port) == IS_LONG) { + lexbor_str_t port_str = {0}; + zval_long_or_null_to_lexbor_str(port, &port_str); + + status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, + port_str.data, port_str.length, LXB_URL_STATE_PORT_STATE, LXB_ENCODING_AUTO + ); + php_uri_parser_whatwg_build_errors_and_throw(status, "port", &errors); + if (status != LXB_STATUS_OK) { + goto failure; + } + } + + if (Z_TYPE_P(path) == IS_STRING && Z_STRLEN_P(path) > 0) { + lxb_url_path_set_null(lexbor_url); + status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, + (lxb_char_t *) Z_STRVAL_P(path), Z_STRLEN_P(path), + lexbor_base_url->path.opaque ? LXB_URL_STATE_NO_SCHEME_STATE : LXB_URL_STATE_PATH_START_STATE, LXB_ENCODING_AUTO + ); + php_uri_parser_whatwg_build_errors_and_throw(status, "path", &errors); + if (status != LXB_STATUS_OK) { + goto failure; + } + } else if (lexbor_base_url->path.str.data != NULL) { + zval zv; + ZVAL_NULL(&zv); + const zend_result result = php_uri_parser_whatwg_query_write(lexbor_url, &zv, NULL); + php_uri_parser_whatwg_build_errors(&errors); + if (result == FAILURE) { + goto failure; + } + } + + if (Z_TYPE_P(query) == IS_STRING) { + status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, + (lxb_char_t *) Z_STRVAL_P(query), Z_STRLEN_P(query), + LXB_URL_STATE_QUERY_STATE, LXB_ENCODING_AUTO + ); + php_uri_parser_whatwg_build_errors_and_throw(status, "query", &errors); + if (status != LXB_STATUS_OK) { + goto failure; + } + } else if (lexbor_base_url->query.data != NULL) { + zval zv; + ZVAL_NULL(&zv); + const zend_result result = php_uri_parser_whatwg_query_write(lexbor_url, &zv, NULL); + php_uri_parser_whatwg_build_errors(&errors); + if (result == FAILURE) { + goto failure; + } + } + + if (Z_TYPE_P(fragment) == IS_STRING) { + status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, + (lxb_char_t *) Z_STRVAL_P(fragment), Z_STRLEN_P(fragment), + LXB_URL_STATE_FRAGMENT_STATE, LXB_ENCODING_AUTO + ); + php_uri_parser_whatwg_build_errors_and_throw(status, "fragment", &errors); + if (status != LXB_STATUS_OK) { + goto failure; + } + } else if (lexbor_base_url->fragment.data != NULL) { + zval zv; + ZVAL_NULL(&zv); + const zend_result result = php_uri_parser_whatwg_fragment_write(lexbor_url, &zv, NULL); + php_uri_parser_whatwg_build_errors(&errors); + if (result == FAILURE) { + goto failure; + } + } + + if (php_uri_pass_errors_by_ref_and_free(errors_zv, &errors) == FAILURE) { + goto failure; + } + + return lexbor_url; + +failure: + zval_ptr_dtor(&errors); + lxb_url_destroy(lexbor_url); + return NULL; } ZEND_ATTRIBUTE_NONNULL static zend_result php_uri_parser_whatwg_build_path( @@ -1041,6 +1186,12 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh ) { lxb_url_parser_clean(&lexbor_parser); + if (lexbor_base_url != NULL && Z_TYPE_P(scheme) == IS_STRING && Z_STRLEN_P(scheme) == 0) { + return php_uri_parser_whatwg_resolve_reference_from_zval( + lexbor_base_url, scheme, username, password, host, port, path, query, fragment, soft_errors_zv + ); + } + lxb_url_t *lexbor_url = lexbor_mraw_calloc(lexbor_parser.mraw, sizeof(*lexbor_url)); if (lexbor_url == NULL) { zend_throw_exception(php_uri_ce_error, "Memory allocation error", 0); @@ -1148,10 +1299,6 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh } } - if (lexbor_base_url != NULL) { - /* TODO */ - } - if (php_uri_pass_errors_by_ref_and_free(soft_errors_zv, &errors) == FAILURE) { /* The errors zval was already consumed; goto failure would destroy it again. */ lxb_url_destroy(lexbor_url); @@ -1159,7 +1306,6 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh } return lexbor_url; - failure: ZEND_ASSERT(EG(exception)); From 99dc30195a6fe0a8ff35de6a183d3bb77ad4e286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Sat, 5 Sep 2026 21:10:44 +0200 Subject: [PATCH 2/8] Review fixes --- ext/lexbor/lexbor/url/url.c | 10 +++++- ext/lexbor/lexbor/url/url.h | 6 ++++ .../builder/basic_error_with_opaque_base.phpt | 2 +- ...asic_success_with_scheme_relative_url.phpt | 6 ++-- .../builder/fragment_success_with_base.phpt | 35 +++++++++++++++++++ .../builder/password_error_with_base.phpt | 17 +++++++++ ...t_error_missing_opaque_host_with_base.phpt | 17 +++++++++ .../builder/query_success_with_base.phpt | 35 +++++++++++++++++++ .../builder/username_error_with_base.phpt | 17 +++++++++ ext/uri/uri_parser_whatwg.c | 26 ++++++++++++++ 10 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/fragment_success_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/password_error_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/port_error_missing_opaque_host_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/query_success_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/username_error_with_base.phpt diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c index 93afbd65b7e5..76348f879a41 100644 --- a/ext/lexbor/lexbor/url/url.c +++ b/ext/lexbor/lexbor/url/url.c @@ -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/ext/lexbor/lexbor/url/url.h b/ext/lexbor/lexbor/url/url.h index bf4e68c50ece..25762d6d0f3e 100644 --- a/ext/lexbor/lexbor/url/url.h +++ b/ext/lexbor/lexbor/url/url.h @@ -900,6 +900,12 @@ lxb_url_path_set_null(lxb_url_t *url); LXB_API void lxb_url_host_set_empty(lxb_url_host_t *host, lexbor_mraw_t *mraw); +LXB_API void +lxb_url_query_set_null(lxb_url_t *url); + +LXB_API void +lxb_url_fragment_set_null(lxb_url_t *url); + /* * Inline functions. */ diff --git a/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt index a9b3528b4d89..3e73978b86e8 100644 --- a/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt +++ b/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt @@ -9,7 +9,7 @@ $builder->setPath("/foo/bar/baz"); try { $builder->build(new Uri\WhatWg\Url("scheme:opaque-path")); } catch (Throwable $e) { - echo $e::class, ": ", $e->getMessage(), PHP_EOL; + echo $e::class, ': ', $e->getMessage(), "\n"; } ?> diff --git a/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt b/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt index fe55b904083c..1f590e8a93cf 100644 --- a/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt +++ b/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt @@ -15,14 +15,14 @@ var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); ?> --EXPECTF-- -string(45) "https://user:pass@example.net:124/foo/bar/baz" +string(35) "https://example.net:124/foo/bar/baz" object(Uri\WhatWg\Url)#%d (%d) { ["scheme"]=> string(5) "https" ["username"]=> - string(4) "user" + NULL ["password"]=> - string(4) "pass" + NULL ["host"]=> string(11) "example.net" ["port"]=> diff --git a/ext/uri/tests/whatwg/builder/fragment_success_with_base.phpt b/ext/uri/tests/whatwg/builder/fragment_success_with_base.phpt new file mode 100644 index 000000000000..7d2a84b59eaa --- /dev/null +++ b/ext/uri/tests/whatwg/builder/fragment_success_with_base.phpt @@ -0,0 +1,35 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setFragment() - success - with base URL +--FILE-- +setFragment("foo"); +$url = $builder->build(new Uri\WhatWg\Url("https://example.com/#bar")); + +var_dump($url->toAsciiString()); +var_dump($url); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECTF-- +string(24) "https://example.com/#foo" +object(Uri\WhatWg\Url)#%d (%d) { + ["scheme"]=> + string(5) "https" + ["username"]=> + NULL + ["password"]=> + NULL + ["host"]=> + string(11) "example.com" + ["port"]=> + NULL + ["path"]=> + string(1) "/" + ["query"]=> + NULL + ["fragment"]=> + string(3) "foo" +} +bool(true) diff --git a/ext/uri/tests/whatwg/builder/password_error_with_base.phpt b/ext/uri/tests/whatwg/builder/password_error_with_base.phpt new file mode 100644 index 000000000000..c6ade2d69d33 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/password_error_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setPassword() - error - missing opaque host with base URL +--FILE-- +setPassword("password"); + +try { + $builder->build(new Uri\WhatWg\Url("https://example.com")); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified URL cannot have password diff --git a/ext/uri/tests/whatwg/builder/port_error_missing_opaque_host_with_base.phpt b/ext/uri/tests/whatwg/builder/port_error_missing_opaque_host_with_base.phpt new file mode 100644 index 000000000000..dc6b021f0841 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/port_error_missing_opaque_host_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setPort() - error - missing opaque host with base URL +--FILE-- +setPort(123); + +try { + $builder->build(new Uri\WhatWg\Url("https://example.com")); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified URL cannot have port diff --git a/ext/uri/tests/whatwg/builder/query_success_with_base.phpt b/ext/uri/tests/whatwg/builder/query_success_with_base.phpt new file mode 100644 index 000000000000..5dc39a92ecf2 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/query_success_with_base.phpt @@ -0,0 +1,35 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setQuery() - success - with base URL +--FILE-- +setQuery("foo=bar"); +$url = $builder->build(new Uri\WhatWg\Url("https://example.com/?baz")); + +var_dump($url->toAsciiString()); +var_dump($url); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECTF-- +string(28) "https://example.com/?foo=bar" +object(Uri\WhatWg\Url)#%d (%d) { + ["scheme"]=> + string(5) "https" + ["username"]=> + NULL + ["password"]=> + NULL + ["host"]=> + string(11) "example.com" + ["port"]=> + NULL + ["path"]=> + string(1) "/" + ["query"]=> + string(7) "foo=bar" + ["fragment"]=> + NULL +} +bool(true) diff --git a/ext/uri/tests/whatwg/builder/username_error_with_base.phpt b/ext/uri/tests/whatwg/builder/username_error_with_base.phpt new file mode 100644 index 000000000000..f58703309451 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/username_error_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setUsername() - error - missing opaque host with base URL +--FILE-- +setUsername("username"); + +try { + $builder->build(new Uri\WhatWg\Url("https://example.com")); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified URL cannot have username diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index 3a0d2ceb9952..c48abe987efb 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -1021,6 +1021,14 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (status != LXB_STATUS_OK) { goto failure; } + } else if (lexbor_base_url->username.data != NULL) { + zval zv; + ZVAL_NULL(&zv); + const zend_result result = php_uri_parser_whatwg_username_write(lexbor_url, &zv, NULL); + php_uri_parser_whatwg_build_errors(&errors); + if (result == FAILURE) { + goto failure; + } } if (Z_TYPE_P(password) == IS_STRING) { @@ -1032,6 +1040,14 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (status != LXB_STATUS_OK) { goto failure; } + } else if (lexbor_base_url->password.data != NULL) { + zval zv; + ZVAL_NULL(&zv); + const zend_result result = php_uri_parser_whatwg_password_write(lexbor_url, &zv, NULL); + php_uri_parser_whatwg_build_errors(&errors); + if (result == FAILURE) { + goto failure; + } } if (Z_TYPE_P(host) == IS_STRING) { @@ -1057,6 +1073,14 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (status != LXB_STATUS_OK) { goto failure; } + } else if (lexbor_base_url->has_port) { + zval zv; + ZVAL_NULL(&zv); + const zend_result result = php_uri_parser_whatwg_port_write(lexbor_url, &zv, NULL); + php_uri_parser_whatwg_build_errors(&errors); + if (result == FAILURE) { + goto failure; + } } if (Z_TYPE_P(path) == IS_STRING && Z_STRLEN_P(path) > 0) { @@ -1080,6 +1104,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser } if (Z_TYPE_P(query) == IS_STRING) { + lxb_url_query_set_null(lexbor_url); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(query), Z_STRLEN_P(query), LXB_URL_STATE_QUERY_STATE, LXB_ENCODING_AUTO @@ -1099,6 +1124,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser } if (Z_TYPE_P(fragment) == IS_STRING) { + lxb_url_fragment_set_null(lexbor_url); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(fragment), Z_STRLEN_P(fragment), LXB_URL_STATE_FRAGMENT_STATE, LXB_ENCODING_AUTO From c77297b35b43535fbc81440f39b40473cdfa8c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Mon, 7 Sep 2026 11:23:41 +0200 Subject: [PATCH 3/8] Fix query and fragment handling --- ...asic_success_with_scheme_relative_url.phpt | 6 ++--- ext/uri/uri_parser_whatwg.c | 24 ------------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt b/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt index 1f590e8a93cf..f7a946c8cf02 100644 --- a/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt +++ b/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt @@ -15,7 +15,7 @@ var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); ?> --EXPECTF-- -string(35) "https://example.net:124/foo/bar/baz" +string(46) "https://example.net:124/foo/bar/baz?query#hash" object(Uri\WhatWg\Url)#%d (%d) { ["scheme"]=> string(5) "https" @@ -30,8 +30,8 @@ object(Uri\WhatWg\Url)#%d (%d) { ["path"]=> string(12) "/foo/bar/baz" ["query"]=> - NULL + string(5) "query" ["fragment"]=> - NULL + string(4) "hash" } bool(true) diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index c48abe987efb..8059ba948f66 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -1093,14 +1093,6 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (status != LXB_STATUS_OK) { goto failure; } - } else if (lexbor_base_url->path.str.data != NULL) { - zval zv; - ZVAL_NULL(&zv); - const zend_result result = php_uri_parser_whatwg_query_write(lexbor_url, &zv, NULL); - php_uri_parser_whatwg_build_errors(&errors); - if (result == FAILURE) { - goto failure; - } } if (Z_TYPE_P(query) == IS_STRING) { @@ -1113,14 +1105,6 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (status != LXB_STATUS_OK) { goto failure; } - } else if (lexbor_base_url->query.data != NULL) { - zval zv; - ZVAL_NULL(&zv); - const zend_result result = php_uri_parser_whatwg_query_write(lexbor_url, &zv, NULL); - php_uri_parser_whatwg_build_errors(&errors); - if (result == FAILURE) { - goto failure; - } } if (Z_TYPE_P(fragment) == IS_STRING) { @@ -1133,14 +1117,6 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (status != LXB_STATUS_OK) { goto failure; } - } else if (lexbor_base_url->fragment.data != NULL) { - zval zv; - ZVAL_NULL(&zv); - const zend_result result = php_uri_parser_whatwg_fragment_write(lexbor_url, &zv, NULL); - php_uri_parser_whatwg_build_errors(&errors); - if (result == FAILURE) { - goto failure; - } } if (php_uri_pass_errors_by_ref_and_free(errors_zv, &errors) == FAILURE) { From 0a5a1d65e8ec9e66e6a4ec1a339d84d345c0492a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Fri, 11 Sep 2026 14:12:47 +0200 Subject: [PATCH 4/8] ext/uri: Preserve builder errors when resolving against a base URL Collect soft errors on success and attach earlier errors to validation exceptions. Reset the output on error-free success and avoid freeing it twice after a failed reference assignment. --- ...soft_errors_typed_reference_with_base.phpt | 23 ++++++ ...error_soft_errors_unchanged_with_base.phpt | 29 ++++++++ .../build_success_soft_errors_with_base.phpt | 29 ++++++++ ext/uri/uri_parser_whatwg.c | 70 ++++++++++++------- 4 files changed, 124 insertions(+), 27 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/build_error_soft_errors_typed_reference_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt diff --git a/ext/uri/tests/whatwg/builder/build_error_soft_errors_typed_reference_with_base.phpt b/ext/uri/tests/whatwg/builder/build_error_soft_errors_typed_reference_with_base.phpt new file mode 100644 index 000000000000..10b7e101dc96 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_error_soft_errors_typed_reference_with_base.phpt @@ -0,0 +1,23 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - soft errors assigned to a typed property with a base URL +--FILE-- +setFragment("a\tb"); +try { + $builder->build(new Uri\WhatWg\Url("https://example.com/"), $foo->errors); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} +var_dump($foo->errors); + +?> +--EXPECT-- +TypeError: Cannot assign array to reference held by property Foo::$errors of type string +string(9) "unchanged" diff --git a/ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged_with_base.phpt b/ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged_with_base.phpt new file mode 100644 index 000000000000..042495574bdd --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged_with_base.phpt @@ -0,0 +1,29 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - preserves soft errors output with an opaque base URL +--FILE-- +setPath("/a\tb"); +$softErrors = ["unchanged"]; + +try { + $builder->build(new Uri\WhatWg\Url("foo:opaque"), $softErrors); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + foreach ($e->errors as $error) { + var_dump($error->type); + } +} + +var_dump($softErrors); + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified path is malformed (MissingSchemeNonRelativeUrl) +enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl) +enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) +array(1) { + [0]=> + string(9) "unchanged" +} diff --git a/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt b/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt new file mode 100644 index 000000000000..d15a295e0a7e --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt @@ -0,0 +1,29 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - collects and resets soft errors with a base URL +--FILE-- +setPath("/a\tb"); +$builder->setFragment("x\ny"); +$base = new Uri\WhatWg\Url("https://example.com/"); +$softErrors = ["old"]; +$url = $builder->build($base, $softErrors); + +var_dump($url->toAsciiString()); +foreach ($softErrors as $error) { + var_dump($error->type); +} + +$builder->setPath("/ab"); +$builder->setFragment("xy"); +$builder->build($base, $softErrors); +var_dump($softErrors); + +?> +--EXPECT-- +string(25) "https://example.com/ab#xy" +enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) +enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) +array(0) { +} diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index 8059ba948f66..ce1deb93179c 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -974,7 +974,7 @@ ZEND_ATTRIBUTE_NONNULL static lxb_url_scheme_type_t php_uri_parser_whatwg_get_sp return LXB_URL_SCHEMEL_TYPE__UNDEF; } -ZEND_ATTRIBUTE_NONNULL const char *php_uri_parser_whatwg_build_errors(zval *errors) +ZEND_ATTRIBUTE_NONNULL static const char *php_uri_parser_whatwg_build_errors(zval *errors) { size_t log_len; @@ -990,29 +990,52 @@ ZEND_ATTRIBUTE_NONNULL const char *php_uri_parser_whatwg_build_errors(zval *erro return fill_errors_inner(Z_ARRVAL_P(errors)); } -ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors_and_throw(const lxb_status_t status, const char *component, zval *errors) +ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors_into_exception(zval *errors) { - zval err; - ZVAL_UNDEF(&err); + /* Include errors from earlier components in the exception raised by a later component. */ + if (zend_hash_num_elements(Z_ARRVAL_P(errors)) > 0 && EG(exception) + && instanceof_function(EG(exception)->ce, php_uri_ce_whatwg_invalid_url_exception)) { + zval rv; + zval *exception_errors = zend_read_property(php_uri_ce_whatwg_invalid_url_exception, + EG(exception), ZEND_STRL("errors"), true, &rv); + ZEND_ASSERT(Z_TYPE_P(exception_errors) == IS_ARRAY); - const char *reason = php_uri_parser_whatwg_build_errors(&err); + zval *error; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(exception_errors), error) { + Z_TRY_ADDREF_P(error); + zend_hash_next_index_insert(Z_ARRVAL_P(errors), error); + } ZEND_HASH_FOREACH_END(); + zval_ptr_dtor(exception_errors); + ZVAL_COPY(exception_errors, errors); + } +} + +ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors_and_throw(const lxb_status_t status, const char *component, zval *errors) +{ if (status != LXB_STATUS_OK) { - throw_invalid_url_exception_with_reason(errors, component, reason, &err); + throw_invalid_url_exception_during_write(NULL, component); + } else { + php_uri_parser_whatwg_build_errors(errors); } } ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_whatwg_resolve_reference_from_zval( lxb_url_t *lexbor_base_url, const zval *scheme, const zval *username, const zval *password, const zval *host, const zval *port, const zval *path, const zval *query, const zval *fragment, - zval *errors_zv + zval *soft_errors_zv ) { lxb_status_t status; zval errors; - ZVAL_UNDEF(&errors); + array_init(&errors); lxb_url_t *lexbor_url = php_uri_parser_whatwg_clone(lexbor_base_url); + if (lexbor_url == NULL) { + zend_throw_exception(php_uri_ce_error, "Memory allocation error", 0); + goto failure; + } if (Z_TYPE_P(username) == IS_STRING) { + lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(username), Z_STRLEN_P(username), LXB_URL_STATE_AUTHORITY_STATE, LXB_ENCODING_AUTO @@ -1032,6 +1055,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser } if (Z_TYPE_P(password) == IS_STRING) { + lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(password), Z_STRLEN_P(password), LXB_URL_STATE_AUTHORITY_STATE, LXB_ENCODING_AUTO @@ -1052,6 +1076,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (Z_TYPE_P(host) == IS_STRING) { lxb_url_host_set_empty(&lexbor_url->host, &lexbor_mraw); + lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(host), Z_STRLEN_P(host), LXB_URL_STATE_HOST_STATE, LXB_ENCODING_AUTO @@ -1066,6 +1091,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser lexbor_str_t port_str = {0}; zval_long_or_null_to_lexbor_str(port, &port_str); + lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, port_str.data, port_str.length, LXB_URL_STATE_PORT_STATE, LXB_ENCODING_AUTO ); @@ -1085,6 +1111,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (Z_TYPE_P(path) == IS_STRING && Z_STRLEN_P(path) > 0) { lxb_url_path_set_null(lexbor_url); + lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(path), Z_STRLEN_P(path), lexbor_base_url->path.opaque ? LXB_URL_STATE_NO_SCHEME_STATE : LXB_URL_STATE_PATH_START_STATE, LXB_ENCODING_AUTO @@ -1097,6 +1124,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (Z_TYPE_P(query) == IS_STRING) { lxb_url_query_set_null(lexbor_url); + lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(query), Z_STRLEN_P(query), LXB_URL_STATE_QUERY_STATE, LXB_ENCODING_AUTO @@ -1109,6 +1137,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser if (Z_TYPE_P(fragment) == IS_STRING) { lxb_url_fragment_set_null(lexbor_url); + lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(fragment), Z_STRLEN_P(fragment), LXB_URL_STATE_FRAGMENT_STATE, LXB_ENCODING_AUTO @@ -1119,13 +1148,16 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser } } - if (php_uri_pass_errors_by_ref_and_free(errors_zv, &errors) == FAILURE) { - goto failure; + if (php_uri_pass_errors_by_ref_and_free(soft_errors_zv, &errors) == FAILURE) { + /* The errors zval was already consumed; goto failure would destroy it again. */ + lxb_url_destroy(lexbor_url); + return NULL; } return lexbor_url; failure: + php_uri_parser_whatwg_build_errors_into_exception(&errors); zval_ptr_dtor(&errors); lxb_url_destroy(lexbor_url); return NULL; @@ -1310,23 +1342,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh return lexbor_url; failure: ZEND_ASSERT(EG(exception)); - - /* Include errors from earlier components in the exception raised by a later component. */ - if (zend_hash_num_elements(Z_ARRVAL(errors)) > 0 - && instanceof_function(EG(exception)->ce, php_uri_ce_whatwg_invalid_url_exception)) { - zval rv; - zval *exception_errors = zend_read_property(php_uri_ce_whatwg_invalid_url_exception, - EG(exception), ZEND_STRL("errors"), true, &rv); - ZEND_ASSERT(Z_TYPE_P(exception_errors) == IS_ARRAY); - - ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(exception_errors), zval *error) { - Z_TRY_ADDREF_P(error); - zend_hash_next_index_insert(Z_ARRVAL(errors), error); - } ZEND_HASH_FOREACH_END(); - - zval_ptr_dtor(exception_errors); - ZVAL_COPY(exception_errors, &errors); - } + php_uri_parser_whatwg_build_errors_into_exception(&errors); zval_ptr_dtor(&errors); lxb_url_destroy(lexbor_url); return NULL; From 20fb249aae1ffbec3e11893cb49041976c273cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Fri, 11 Sep 2026 14:15:05 +0200 Subject: [PATCH 5/8] ext/uri: Resolve builder authorities consistently Inherit the entire base authority when none is supplied, and reuse normal URL building for a replacement authority. This preserves credential encoding and normalized host validation without inheriting unrelated base components. --- .../builder/authority_success_with_base.phpt | 21 ++++ ...asic_success_with_scheme_relative_url.phpt | 6 +- ...host_error_normalized_empty_with_base.phpt | 22 +++++ ext/uri/uri_parser_whatwg.c | 97 +++++-------------- 4 files changed, 70 insertions(+), 76 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/authority_success_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/host_error_normalized_empty_with_base.phpt diff --git a/ext/uri/tests/whatwg/builder/authority_success_with_base.phpt b/ext/uri/tests/whatwg/builder/authority_success_with_base.phpt new file mode 100644 index 000000000000..ad8bc581e119 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/authority_success_with_base.phpt @@ -0,0 +1,21 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - replaces the base authority with encoded credentials +--FILE-- +setHost("example.net"); +$builder->setUsername("a@b"); +$builder->setPassword("c:d"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); +var_dump($base->toAsciiString()); + +?> +--EXPECT-- +string(32) "https://a%40b:c%3Ad@example.net/" +bool(true) +string(43) "https://old:secret@example.com:81/a?old#old" diff --git a/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt b/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt index f7a946c8cf02..1f590e8a93cf 100644 --- a/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt +++ b/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt @@ -15,7 +15,7 @@ var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); ?> --EXPECTF-- -string(46) "https://example.net:124/foo/bar/baz?query#hash" +string(35) "https://example.net:124/foo/bar/baz" object(Uri\WhatWg\Url)#%d (%d) { ["scheme"]=> string(5) "https" @@ -30,8 +30,8 @@ object(Uri\WhatWg\Url)#%d (%d) { ["path"]=> string(12) "/foo/bar/baz" ["query"]=> - string(5) "query" + NULL ["fragment"]=> - string(4) "hash" + NULL } bool(true) diff --git a/ext/uri/tests/whatwg/builder/host_error_normalized_empty_with_base.phpt b/ext/uri/tests/whatwg/builder/host_error_normalized_empty_with_base.phpt new file mode 100644 index 000000000000..7c16d8d57060 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/host_error_normalized_empty_with_base.phpt @@ -0,0 +1,22 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - rejects credentials after host normalization with a base URL +--FILE-- +setHost("\t"); +$builder->setUsername("user"); + +try { + $builder->build(new Uri\WhatWg\Url("foo://example.com/")); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + foreach ($e->errors as $error) { + var_dump($error->type); + } +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified URL cannot have username +enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index ce1deb93179c..8cfbf51e64ee 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -1025,90 +1025,41 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser const zval *host, const zval *port, const zval *path, const zval *query, const zval *fragment, zval *soft_errors_zv ) { - lxb_status_t status; - zval errors; - array_init(&errors); - - lxb_url_t *lexbor_url = php_uri_parser_whatwg_clone(lexbor_base_url); - if (lexbor_url == NULL) { - zend_throw_exception(php_uri_ce_error, "Memory allocation error", 0); - goto failure; + if (Z_TYPE_P(host) == IS_STRING) { + /* A new authority inherits only the scheme, not the base URL's other components. */ + zval base_scheme; + php_uri_parser_whatwg_scheme_read(lexbor_base_url, PHP_URI_COMPONENT_READ_MODE_NORMALIZED_ASCII, &base_scheme); + lxb_url_t *url = php_uri_parser_whatwg_build_from_zval(NULL, &base_scheme, + username, password, host, port, path, query, fragment, soft_errors_zv); + zval_ptr_dtor(&base_scheme); + return url; } + + /* Credentials and ports require an authority in the reference itself. */ if (Z_TYPE_P(username) == IS_STRING) { - lxb_url_parser_clean(&lexbor_parser); - status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, - (lxb_char_t *) Z_STRVAL_P(username), Z_STRLEN_P(username), - LXB_URL_STATE_AUTHORITY_STATE, LXB_ENCODING_AUTO - ); - php_uri_parser_whatwg_build_errors_and_throw(status, "username", &errors); - if (status != LXB_STATUS_OK) { - goto failure; - } - } else if (lexbor_base_url->username.data != NULL) { - zval zv; - ZVAL_NULL(&zv); - const zend_result result = php_uri_parser_whatwg_username_write(lexbor_url, &zv, NULL); - php_uri_parser_whatwg_build_errors(&errors); - if (result == FAILURE) { - goto failure; - } + php_uri_parser_whatwg_throw_exception("The specified URL cannot have username"); + return NULL; } if (Z_TYPE_P(password) == IS_STRING) { - lxb_url_parser_clean(&lexbor_parser); - status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, - (lxb_char_t *) Z_STRVAL_P(password), Z_STRLEN_P(password), - LXB_URL_STATE_AUTHORITY_STATE, LXB_ENCODING_AUTO - ); - php_uri_parser_whatwg_build_errors_and_throw(status, "password", &errors); - if (status != LXB_STATUS_OK) { - goto failure; - } - } else if (lexbor_base_url->password.data != NULL) { - zval zv; - ZVAL_NULL(&zv); - const zend_result result = php_uri_parser_whatwg_password_write(lexbor_url, &zv, NULL); - php_uri_parser_whatwg_build_errors(&errors); - if (result == FAILURE) { - goto failure; - } + php_uri_parser_whatwg_throw_exception("The specified URL cannot have password"); + return NULL; } - if (Z_TYPE_P(host) == IS_STRING) { - lxb_url_host_set_empty(&lexbor_url->host, &lexbor_mraw); - lxb_url_parser_clean(&lexbor_parser); - status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, - (lxb_char_t *) Z_STRVAL_P(host), Z_STRLEN_P(host), - LXB_URL_STATE_HOST_STATE, LXB_ENCODING_AUTO - ); - php_uri_parser_whatwg_build_errors_and_throw(status, "host", &errors); - if (status != LXB_STATUS_OK) { - goto failure; - } + if (Z_TYPE_P(port) == IS_LONG) { + php_uri_parser_whatwg_throw_exception("The specified URL cannot have port"); + return NULL; } - if (Z_TYPE_P(port) == IS_LONG) { - lexbor_str_t port_str = {0}; - zval_long_or_null_to_lexbor_str(port, &port_str); + lxb_status_t status; + zval errors; + array_init(&errors); - lxb_url_parser_clean(&lexbor_parser); - status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, - port_str.data, port_str.length, LXB_URL_STATE_PORT_STATE, LXB_ENCODING_AUTO - ); - php_uri_parser_whatwg_build_errors_and_throw(status, "port", &errors); - if (status != LXB_STATUS_OK) { - goto failure; - } - } else if (lexbor_base_url->has_port) { - zval zv; - ZVAL_NULL(&zv); - const zend_result result = php_uri_parser_whatwg_port_write(lexbor_url, &zv, NULL); - php_uri_parser_whatwg_build_errors(&errors); - if (result == FAILURE) { - goto failure; - } + lxb_url_t *lexbor_url = php_uri_parser_whatwg_clone(lexbor_base_url); + if (lexbor_url == NULL) { + zend_throw_exception(php_uri_ce_error, "Memory allocation error", 0); + goto failure; } - if (Z_TYPE_P(path) == IS_STRING && Z_STRLEN_P(path) > 0) { lxb_url_path_set_null(lexbor_url); lxb_url_parser_clean(&lexbor_parser); From 0530253ccc399d99700ce337c7fca6e2211d26d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Fri, 11 Sep 2026 14:17:50 +0200 Subject: [PATCH 6/8] ext/uri: Resolve relative builder paths against the base directory Use reference resolution for relative paths while keeping path delimiters inside the component. Preserve file drive handling, clear inherited fragments, and inherit the query only for an empty path. --- ...sic_success_empty_reference_with_base.phpt | 17 +++++++ .../fragment_success_empty_with_base.phpt | 17 +++++++ .../path_success_absolute_with_file_base.phpt | 17 +++++++ ...h_success_drive_letter_with_file_base.phpt | 17 +++++++ ...success_first_segment_colon_with_base.phpt | 17 +++++++ ...ess_hierarchical_delimiters_with_base.phpt | 17 +++++++ ...uccess_leading_double_slash_with_base.phpt | 17 +++++++ ...s_leading_double_slash_with_file_base.phpt | 17 +++++++ ...path_success_parent_segment_with_base.phpt | 17 +++++++ .../path_success_relative_with_base.phpt | 17 +++++++ .../path_success_tab_newline_with_base.phpt | 19 +++++++ .../query_success_empty_with_base.phpt | 17 +++++++ .../query_success_hashmark_with_base.phpt | 17 +++++++ ext/uri/uri_parser_whatwg.c | 51 +++++++++++++++++-- 14 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/fragment_success_empty_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_absolute_with_file_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_drive_letter_with_file_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_first_segment_colon_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_file_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_parent_segment_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_relative_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_tab_newline_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/query_success_empty_with_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/query_success_hashmark_with_base.phpt diff --git a/ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_base.phpt b/ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_base.phpt new file mode 100644 index 000000000000..6d40f4b96f8c --- /dev/null +++ b/ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - inherits the path and query but not the fragment +--FILE-- +build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(25) "https://example.com/a?old" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/fragment_success_empty_with_base.phpt b/ext/uri/tests/whatwg/builder/fragment_success_empty_with_base.phpt new file mode 100644 index 000000000000..04f92a8f7764 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/fragment_success_empty_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - replaces the base fragment with an empty fragment +--FILE-- +setFragment(""); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(26) "https://example.com/a?old#" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/path_success_absolute_with_file_base.phpt b/ext/uri/tests/whatwg/builder/path_success_absolute_with_file_base.phpt new file mode 100644 index 000000000000..92a85ff9d15a --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_absolute_with_file_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - inherits the file drive for an absolute path +--FILE-- +setPath("/c"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(12) "file:///C:/c" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/path_success_drive_letter_with_file_base.phpt b/ext/uri/tests/whatwg/builder/path_success_drive_letter_with_file_base.phpt new file mode 100644 index 000000000000..7d15133b421c --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_drive_letter_with_file_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - replaces the file drive +--FILE-- +setPath("D:/c"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(12) "file:///D:/c" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/path_success_first_segment_colon_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_first_segment_colon_with_base.phpt new file mode 100644 index 000000000000..6e56b4c75f80 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_first_segment_colon_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - keeps a colon in the first path segment +--FILE-- +setPath("c:d"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(25) "https://example.com/a/c:d" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters_with_base.phpt new file mode 100644 index 000000000000..ea942178d055 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - keeps question mark and hashmark in the path +--FILE-- +setPath("a?b#c"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(31) "https://example.com/a/a%3Fb%23c" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_base.phpt new file mode 100644 index 000000000000..b38ec002d795 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - keeps a leading double slash in the path +--FILE-- +setPath("//other/x"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(28) "https://example.com//other/x" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_file_base.phpt b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_file_base.phpt new file mode 100644 index 000000000000..4b1cbf1289ce --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_file_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - keeps a leading double slash in a file path +--FILE-- +setPath("//other/x"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(16) "file:////other/x" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/path_success_parent_segment_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_parent_segment_with_base.phpt new file mode 100644 index 000000000000..9c418b25ae60 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_parent_segment_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - resolves a parent path segment +--FILE-- +setPath("../c"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(21) "https://example.com/c" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/path_success_relative_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_relative_with_base.phpt new file mode 100644 index 000000000000..cf8ee60288a5 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_relative_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - resolves a path against the base directory and clears its query and fragment +--FILE-- +setPath("c"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(36) "https://user:pass@example.com:81/a/c" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/path_success_tab_newline_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_tab_newline_with_base.phpt new file mode 100644 index 000000000000..ce299716c0a8 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_tab_newline_with_base.phpt @@ -0,0 +1,19 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - inherits the path and query after removing tabs and newlines +--FILE-- +setPath("\t\n"); +$url = $builder->build($base, $softErrors); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); +var_dump($softErrors[0]->type); + +?> +--EXPECT-- +string(25) "https://example.com/a?old" +bool(true) +enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) diff --git a/ext/uri/tests/whatwg/builder/query_success_empty_with_base.phpt b/ext/uri/tests/whatwg/builder/query_success_empty_with_base.phpt new file mode 100644 index 000000000000..e2e41cb7a127 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/query_success_empty_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - replaces the base query with an empty query +--FILE-- +setQuery(""); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(22) "https://example.com/a?" +bool(true) diff --git a/ext/uri/tests/whatwg/builder/query_success_hashmark_with_base.phpt b/ext/uri/tests/whatwg/builder/query_success_hashmark_with_base.phpt new file mode 100644 index 000000000000..ef17d3e94104 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/query_success_hashmark_with_base.phpt @@ -0,0 +1,17 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - keeps a hashmark in the query +--FILE-- +setQuery("a#b"); +$url = $builder->build($base); + +var_dump($url->toAsciiString()); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECT-- +string(27) "https://example.com/a?a%23b" +bool(true) diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index 8cfbf51e64ee..182965f31ad2 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -1060,17 +1060,60 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser zend_throw_exception(php_uri_ce_error, "Memory allocation error", 0); goto failure; } - if (Z_TYPE_P(path) == IS_STRING && Z_STRLEN_P(path) > 0) { + + /* Discard the base fragment; the reference fragment is applied below. */ + lxb_url_fragment_set_null(lexbor_url); + + if (Z_STRLEN_P(path) > 0) { + const char *first = Z_STRVAL_P(path); + const char *end = first + Z_STRLEN_P(path); + while (first < end && php_uri_whatwg_is_ascii_tab_or_newline(*first)) { + first++; + } + + /* Resolve relative paths against the base directory. Absolute paths use + * a path state so leading slashes cannot introduce a new authority. */ + lxb_url_state_t state = LXB_URL_STATE_NO_SCHEME_STATE; + if (!lexbor_base_url->path.opaque && first < end + && (*first == '/' || (lxb_url_is_special(lexbor_base_url) && *first == '\\'))) { + /* A path beginning with // must not replace the authority. */ + state = LXB_URL_STATE_PATH_START_STATE; + if (lexbor_base_url->scheme.type == LXB_URL_SCHEMEL_TYPE_FILE) { + const char *second = first + 1; + while (second < end && php_uri_whatwg_is_ascii_tab_or_newline(*second)) { + second++; + } + if (second == end || (*second != '/' && *second != '\\')) { + state = LXB_URL_STATE_FILE_STATE; + } + } + } + + /* Keep delimiters inside the path when entering the reference parser. */ + smart_str reference = {0}; + for (const char *p = Z_STRVAL_P(path); p < end; p++) { + if (*p == '?') { + smart_str_appends(&reference, "%3F"); + } else if (*p == '#') { + smart_str_appends(&reference, "%23"); + } else { + smart_str_appendc(&reference, *p); + } + } + + zend_string *input = smart_str_extract(&reference); lxb_url_path_set_null(lexbor_url); lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, - (lxb_char_t *) Z_STRVAL_P(path), Z_STRLEN_P(path), - lexbor_base_url->path.opaque ? LXB_URL_STATE_NO_SCHEME_STATE : LXB_URL_STATE_PATH_START_STATE, LXB_ENCODING_AUTO - ); + (const lxb_char_t *) ZSTR_VAL(input), ZSTR_LEN(input), state, LXB_ENCODING_UTF_8); php_uri_parser_whatwg_build_errors_and_throw(status, "path", &errors); + zend_string_release(input); if (status != LXB_STATUS_OK) { goto failure; } + if (first < end) { + lxb_url_query_set_null(lexbor_url); + } } if (Z_TYPE_P(query) == IS_STRING) { From 92d23d43b8c3bdc1cf4916748fc74e31e54ea5c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Fri, 11 Sep 2026 14:19:27 +0200 Subject: [PATCH 7/8] ext/uri: Restrict opaque base URLs to fragment references Reject empty references, new authorities and query references against opaque bases. Keep fragment-only references working after tab and newline removal. --- ...rror_empty_reference_with_opaque_base.phpt | 19 ++++++++++++ ...agment_success_empty_with_opaque_base.phpt | 21 +++++++++++++ .../builder/host_error_with_opaque_base.phpt | 20 +++++++++++++ ...th_error_tab_newline_with_opaque_base.phpt | 20 +++++++++++++ .../query_error_empty_with_opaque_base.phpt | 22 ++++++++++++++ ext/uri/uri_parser_whatwg.c | 30 +++++++++++++++---- 6 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/basic_error_empty_reference_with_opaque_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/fragment_success_empty_with_opaque_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/host_error_with_opaque_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_error_tab_newline_with_opaque_base.phpt create mode 100644 ext/uri/tests/whatwg/builder/query_error_empty_with_opaque_base.phpt diff --git a/ext/uri/tests/whatwg/builder/basic_error_empty_reference_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/basic_error_empty_reference_with_opaque_base.phpt new file mode 100644 index 000000000000..d3fa225a61bc --- /dev/null +++ b/ext/uri/tests/whatwg/builder/basic_error_empty_reference_with_opaque_base.phpt @@ -0,0 +1,19 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - empty reference with an opaque base +--FILE-- +build($base); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors[0]->type); +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified path is malformed (MissingSchemeNonRelativeUrl) +enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl) diff --git a/ext/uri/tests/whatwg/builder/fragment_success_empty_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/fragment_success_empty_with_opaque_base.phpt new file mode 100644 index 000000000000..bcdb4d277b97 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/fragment_success_empty_with_opaque_base.phpt @@ -0,0 +1,21 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - resolves an empty fragment against an opaque base +--FILE-- +setPath("\t\n"); +$builder->setFragment(""); +$url = $builder->build($base, $softErrors); +var_dump($url->toAsciiString()); +var_dump($softErrors[0]->type); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); +var_dump($base->toAsciiString()); + +?> +--EXPECT-- +string(15) "foo:opaque?old#" +enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) +bool(true) +string(18) "foo:opaque?old#old" diff --git a/ext/uri/tests/whatwg/builder/host_error_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/host_error_with_opaque_base.phpt new file mode 100644 index 000000000000..5194b7e6b6ff --- /dev/null +++ b/ext/uri/tests/whatwg/builder/host_error_with_opaque_base.phpt @@ -0,0 +1,20 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - new authority with an opaque base +--FILE-- +setHost("example.com"); + +try { + $builder->build($base); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors[0]->type); +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified host is malformed (MissingSchemeNonRelativeUrl) +enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl) diff --git a/ext/uri/tests/whatwg/builder/path_error_tab_newline_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/path_error_tab_newline_with_opaque_base.phpt new file mode 100644 index 000000000000..0f53f745487f --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_error_tab_newline_with_opaque_base.phpt @@ -0,0 +1,20 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - ignored path characters without a fragment and with an opaque base +--FILE-- +setPath("\t\n"); + +try { + $builder->build($base); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors[0]->type); +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified path is malformed (MissingSchemeNonRelativeUrl) +enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl) diff --git a/ext/uri/tests/whatwg/builder/query_error_empty_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/query_error_empty_with_opaque_base.phpt new file mode 100644 index 000000000000..f970ba3081ca --- /dev/null +++ b/ext/uri/tests/whatwg/builder/query_error_empty_with_opaque_base.phpt @@ -0,0 +1,22 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - empty query with an opaque base and ignored path characters +--FILE-- +setPath("\t\n"); +$builder->setQuery(""); +$builder->setFragment("new"); + +try { + $builder->build($base); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors[0]->type); +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified query is malformed (MissingSchemeNonRelativeUrl) +enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl) diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index 182965f31ad2..98d7e47351d9 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -1026,6 +1026,11 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser zval *soft_errors_zv ) { if (Z_TYPE_P(host) == IS_STRING) { + if (lexbor_base_url->path.opaque) { + php_uri_parser_whatwg_component_error("host", LXB_URL_ERROR_TYPE_MISSING_SCHEME_NON_RELATIVE_URL); + return NULL; + } + /* A new authority inherits only the scheme, not the base URL's other components. */ zval base_scheme; php_uri_parser_whatwg_scheme_read(lexbor_base_url, PHP_URI_COMPONENT_READ_MODE_NORMALIZED_ASCII, &base_scheme); @@ -1051,6 +1056,12 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser return NULL; } + const char *first = Z_STRVAL_P(path); + const char *end = first + Z_STRLEN_P(path); + while (first < end && php_uri_whatwg_is_ascii_tab_or_newline(*first)) { + first++; + } + lxb_status_t status; zval errors; array_init(&errors); @@ -1060,17 +1071,17 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser zend_throw_exception(php_uri_ce_error, "Memory allocation error", 0); goto failure; } + if (lexbor_base_url->path.opaque && first == end + && (Z_TYPE_P(query) == IS_STRING || Z_TYPE_P(fragment) == IS_NULL)) { + php_uri_parser_whatwg_component_error(Z_TYPE_P(query) == IS_STRING ? "query" : "path", + LXB_URL_ERROR_TYPE_MISSING_SCHEME_NON_RELATIVE_URL); + goto failure; + } /* Discard the base fragment; the reference fragment is applied below. */ lxb_url_fragment_set_null(lexbor_url); if (Z_STRLEN_P(path) > 0) { - const char *first = Z_STRVAL_P(path); - const char *end = first + Z_STRLEN_P(path); - while (first < end && php_uri_whatwg_is_ascii_tab_or_newline(*first)) { - first++; - } - /* Resolve relative paths against the base directory. Absolute paths use * a path state so leading slashes cannot introduce a new authority. */ lxb_url_state_t state = LXB_URL_STATE_NO_SCHEME_STATE; @@ -1101,6 +1112,13 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser } } + /* After removing tabs and newlines, the parser must see a fragment + * reference to accept an opaque base. Its actual value is applied below. */ + if (lexbor_base_url->path.opaque && first == end + && Z_TYPE_P(query) == IS_NULL && Z_TYPE_P(fragment) != IS_NULL) { + smart_str_appendc(&reference, '#'); + } + zend_string *input = smart_str_extract(&reference); lxb_url_path_set_null(lexbor_url); lxb_url_parser_clean(&lexbor_parser); From 70c4c106409a9dd467dc6237b7991db431cf9dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Sun, 20 Sep 2026 08:29:02 +0200 Subject: [PATCH 8/8] Temporarily get rid of ext/lexbor changes --- ext/lexbor/lexbor/url/url.c | 14 ++----- ext/lexbor/lexbor/url/url.h | 12 ------ ...ld_error_soft_errors_reset_with_base.phpt} | 6 +-- ...error_soft_errors_reset_without_base.phpt} | 0 .../build_success_soft_errors_with_base.phpt | 31 ++++++++++---- ext/uri/uri_parser_whatwg.c | 40 ++++++++++++++++--- 6 files changed, 64 insertions(+), 39 deletions(-) rename ext/uri/tests/whatwg/builder/{build_error_soft_errors_unchanged_with_base.phpt => build_error_soft_errors_reset_with_base.phpt} (90%) rename ext/uri/tests/whatwg/builder/{build_error_soft_errors_reset.phpt => build_error_soft_errors_reset_without_base.phpt} (100%) diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c index 76348f879a41..69d91969a6a1 100644 --- a/ext/lexbor/lexbor/url/url.c +++ b/ext/lexbor/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); } -void +static 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) } } -void +static void lxb_url_host_set_empty(lxb_url_host_t *host, lexbor_mraw_t *mraw) { lxb_url_host_destroy(host, mraw); @@ -1183,15 +1183,7 @@ lxb_url_port_set(lxb_url_t *url, uint16_t port) url->has_port = true; } -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 +static void lxb_url_fragment_set_null(lxb_url_t *url) { if (url->fragment.data != NULL) { diff --git a/ext/lexbor/lexbor/url/url.h b/ext/lexbor/lexbor/url/url.h index 25762d6d0f3e..d2c93080c922 100644 --- a/ext/lexbor/lexbor/url/url.h +++ b/ext/lexbor/lexbor/url/url.h @@ -894,18 +894,6 @@ lxb_url_search_params_serialize(lxb_url_search_params_t *search_params, LXB_API bool lxb_url_is_special(const lxb_url_t *url); -LXB_API void -lxb_url_path_set_null(lxb_url_t *url); - -LXB_API void -lxb_url_host_set_empty(lxb_url_host_t *host, lexbor_mraw_t *mraw); - -LXB_API void -lxb_url_query_set_null(lxb_url_t *url); - -LXB_API void -lxb_url_fragment_set_null(lxb_url_t *url); - /* * Inline functions. */ diff --git a/ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged_with_base.phpt b/ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_with_base.phpt similarity index 90% rename from ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged_with_base.phpt rename to ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_with_base.phpt index 042495574bdd..b1a8b221849e 100644 --- a/ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged_with_base.phpt +++ b/ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_with_base.phpt @@ -5,7 +5,7 @@ Test Uri\WhatWg\UrlBuilder::build() - error - preserves soft errors output with $builder = new Uri\WhatWg\UrlBuilder(); $builder->setPath("/a\tb"); -$softErrors = ["unchanged"]; +$softErrors = ["previous error"]; try { $builder->build(new Uri\WhatWg\Url("foo:opaque"), $softErrors); @@ -23,7 +23,5 @@ var_dump($softErrors); Uri\WhatWg\InvalidUrlException: The specified path is malformed (MissingSchemeNonRelativeUrl) enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl) enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) -array(1) { - [0]=> - string(9) "unchanged" +array(0) { } diff --git a/ext/uri/tests/whatwg/builder/build_error_soft_errors_reset.phpt b/ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_without_base.phpt similarity index 100% rename from ext/uri/tests/whatwg/builder/build_error_soft_errors_reset.phpt rename to ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_without_base.phpt diff --git a/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt b/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt index d15a295e0a7e..7f2b37146627 100644 --- a/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt +++ b/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt @@ -7,13 +7,11 @@ $builder = new Uri\WhatWg\UrlBuilder(); $builder->setPath("/a\tb"); $builder->setFragment("x\ny"); $base = new Uri\WhatWg\Url("https://example.com/"); -$softErrors = ["old"]; +$softErrors = ["previous error"]; $url = $builder->build($base, $softErrors); var_dump($url->toAsciiString()); -foreach ($softErrors as $error) { - var_dump($error->type); -} +var_dump($softErrors); $builder->setPath("/ab"); $builder->setFragment("xy"); @@ -21,9 +19,28 @@ $builder->build($base, $softErrors); var_dump($softErrors); ?> ---EXPECT-- +--EXPECTF-- string(25) "https://example.com/ab#xy" -enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) -enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) +array(2) { + [0]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(2) " b" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) + ["failure"]=> + bool(false) + } + [1]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(2) " +y" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) + ["failure"]=> + bool(false) + } +} array(0) { } diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index 98d7e47351d9..1a258c6c940c 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -1020,6 +1020,36 @@ ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors_and_throw( } } +/* TODO: Replace with lxb_url_path_set_null() once https://github.com/lexbor/lexbor/pull/415 is available. */ +ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_path_set_null(lxb_url_t *url) +{ + if (url->path.str.data == NULL) { + return; + } + + (void) lexbor_str_destroy(&url->path.str, url->mraw, false); + + url->path.str.length = 0; + url->path.length = 0; + url->path.opaque = false; +} + +/* TODO: Replace with lxb_url_query_set_null() once https://github.com/lexbor/lexbor/pull/415 is available. */ +ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_query_set_null(lxb_url_t *url) +{ + if (url->query.data != NULL) { + (void) lexbor_str_destroy(&url->query, url->mraw, false); + } +} + +/* TODO: Replace with lxb_url_fragment_set_null() once https://github.com/lexbor/lexbor/pull/415 is available. */ +ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_fragment_set_null(lxb_url_t *url) +{ + if (url->fragment.data != NULL) { + (void) lexbor_str_destroy(&url->fragment, url->mraw, false); + } +} + ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_whatwg_resolve_reference_from_zval( lxb_url_t *lexbor_base_url, const zval *scheme, const zval *username, const zval *password, const zval *host, const zval *port, const zval *path, const zval *query, const zval *fragment, @@ -1079,7 +1109,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser } /* Discard the base fragment; the reference fragment is applied below. */ - lxb_url_fragment_set_null(lexbor_url); + php_uri_parser_whatwg_fragment_set_null(lexbor_url); if (Z_STRLEN_P(path) > 0) { /* Resolve relative paths against the base directory. Absolute paths use @@ -1120,7 +1150,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser } zend_string *input = smart_str_extract(&reference); - lxb_url_path_set_null(lexbor_url); + php_uri_parser_whatwg_path_set_null(lexbor_url); lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (const lxb_char_t *) ZSTR_VAL(input), ZSTR_LEN(input), state, LXB_ENCODING_UTF_8); @@ -1130,12 +1160,12 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser goto failure; } if (first < end) { - lxb_url_query_set_null(lexbor_url); + php_uri_parser_whatwg_query_set_null(lexbor_url); } } if (Z_TYPE_P(query) == IS_STRING) { - lxb_url_query_set_null(lexbor_url); + php_uri_parser_whatwg_query_set_null(lexbor_url); lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(query), Z_STRLEN_P(query), @@ -1148,7 +1178,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser } if (Z_TYPE_P(fragment) == IS_STRING) { - lxb_url_fragment_set_null(lexbor_url); + php_uri_parser_whatwg_fragment_set_null(lexbor_url); lxb_url_parser_clean(&lexbor_parser); status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url, (lxb_char_t *) Z_STRVAL_P(fragment), Z_STRLEN_P(fragment),