Commit 95ed6c14271 for php
commit 95ed6c14271ef62032ac75ce30e43274915e4668
Author: NickSdot <32384907+NickSdot@users.noreply.github.com>
Date: Tue Oct 20 19:09:39 2026 +0700
ext/uri: Fixes url delimiters (#23815)
Fix Uri\WhatWg\UrlBuilder's query and fragment handling to be in line with the behavior of the WHATWG URL setter algorithms: Empty strings now remove the corresponding component instead of creating an empty one, both with and without a base URL. An empty component can still be created by passing "?" or "#".
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt b/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt
index 2fe13ffd702..3d7be9923ca 100644
--- a/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt
+++ b/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt
@@ -16,7 +16,7 @@
?>
--EXPECTF--
-string(21) "https://example.com/#"
+string(20) "https://example.com/"
object(Uri\WhatWg\Url)#%d (%d) {
["scheme"]=>
string(5) "https"
@@ -33,6 +33,6 @@
["query"]=>
NULL
["fragment"]=>
- string(0) ""
+ NULL
}
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
index 04f92a8f776..a18ef25fb88 100644
--- a/ext/uri/tests/whatwg/builder/fragment_success_empty_with_base.phpt
+++ b/ext/uri/tests/whatwg/builder/fragment_success_empty_with_base.phpt
@@ -1,5 +1,5 @@
--TEST--
-Test Uri\WhatWg\UrlBuilder::build() - success - replaces the base fragment with an empty fragment
+Test Uri\WhatWg\UrlBuilder::build() - success - clears the base fragment with an empty string
--FILE--
<?php
@@ -13,5 +13,5 @@
?>
--EXPECT--
-string(26) "https://example.com/a?old#"
+string(25) "https://example.com/a?old"
bool(true)
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
index bcdb4d277b9..fe256b5565f 100644
--- 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
@@ -1,5 +1,5 @@
--TEST--
-Test Uri\WhatWg\UrlBuilder::build() - success - resolves an empty fragment against an opaque base
+Test Uri\WhatWg\UrlBuilder::build() - success - clears the fragment of an opaque base with an empty string
--FILE--
<?php
@@ -15,7 +15,7 @@
?>
--EXPECT--
-string(15) "foo:opaque?old#"
+string(14) "foo:opaque?old"
enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
bool(true)
string(18) "foo:opaque?old#old"
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_hashmark_only.phpt b/ext/uri/tests/whatwg/builder/fragment_success_hashmark_only.phpt
new file mode 100644
index 00000000000..34c60b37d9d
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_hashmark_only.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - hashmark only
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setFragment("#");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()), Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(21) "https://example.com/#"
+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(0) ""
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_hashmark_only_with_base.phpt b/ext/uri/tests/whatwg/builder/fragment_success_hashmark_only_with_base.phpt
new file mode 100644
index 00000000000..c73538afe96
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_hashmark_only_with_base.phpt
@@ -0,0 +1,39 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - hashmark only with base URL
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url('https://example.com/base/path?oldQuery#oldFragment');
+
+$url = new Uri\WhatWg\UrlBuilder()
+ ->setFragment('#')
+ ->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($url->equals(new Uri\WhatWg\Url('#', $base), Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(39) "https://example.com/base/path?oldQuery#"
+object(Uri\WhatWg\Url)#%d (%d) {
+ ["scheme"]=>
+ string(5) "https"
+ ["username"]=>
+ NULL
+ ["password"]=>
+ NULL
+ ["host"]=>
+ string(11) "example.com"
+ ["port"]=>
+ NULL
+ ["path"]=>
+ string(10) "/base/path"
+ ["query"]=>
+ string(8) "oldQuery"
+ ["fragment"]=>
+ string(0) ""
+}
+bool(true)
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_empty_components.phpt b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_empty_components.phpt
new file mode 100644
index 00000000000..25bcdc19aff
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_empty_components.phpt
@@ -0,0 +1,48 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - trailing spaces with empty query and fragment inputs
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("abc ");
+$builder->setQuery("");
+$builder->setFragment("");
+$url = $builder->build(softErrors: $softErrors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($softErrors);
+
+?>
+--EXPECTF--
+string(8) "foo:abc "
+object(Uri\WhatWg\Url)#%d (%d) {
+ ["scheme"]=>
+ string(3) "foo"
+ ["username"]=>
+ NULL
+ ["password"]=>
+ NULL
+ ["host"]=>
+ NULL
+ ["port"]=>
+ NULL
+ ["path"]=>
+ string(4) "abc "
+ ["query"]=>
+ NULL
+ ["fragment"]=>
+ NULL
+}
+array(1) {
+ [0]=>
+ object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+ ["context"]=>
+ string(1) " "
+ ["type"]=>
+ enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+ ["failure"]=>
+ bool(false)
+ }
+}
diff --git a/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt b/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt
index 01cdb62a11c..a2d4a8a7448 100644
--- a/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt
+++ b/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt
@@ -16,7 +16,7 @@
?>
--EXPECTF--
-string(21) "https://example.com/?"
+string(20) "https://example.com/"
object(Uri\WhatWg\Url)#%d (%d) {
["scheme"]=>
string(5) "https"
@@ -31,7 +31,7 @@
["path"]=>
string(1) "/"
["query"]=>
- string(0) ""
+ NULL
["fragment"]=>
NULL
}
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
index e2e41cb7a12..fa9a374bf02 100644
--- a/ext/uri/tests/whatwg/builder/query_success_empty_with_base.phpt
+++ b/ext/uri/tests/whatwg/builder/query_success_empty_with_base.phpt
@@ -1,5 +1,5 @@
--TEST--
-Test Uri\WhatWg\UrlBuilder::build() - success - replaces the base query with an empty query
+Test Uri\WhatWg\UrlBuilder::build() - success - clears the base query with an empty string
--FILE--
<?php
@@ -13,5 +13,5 @@
?>
--EXPECT--
-string(22) "https://example.com/a?"
+string(21) "https://example.com/a"
bool(true)
diff --git a/ext/uri/tests/whatwg/builder/query_success_question_mark_only.phpt b/ext/uri/tests/whatwg/builder/query_success_question_mark_only.phpt
new file mode 100644
index 00000000000..e0217d4ce79
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_question_mark_only.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - question mark only
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setQuery("?");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(21) "https://example.com/?"
+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(0) ""
+ ["fragment"]=>
+ NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/query_success_question_mark_only_with_base.phpt b/ext/uri/tests/whatwg/builder/query_success_question_mark_only_with_base.phpt
new file mode 100644
index 00000000000..c42ae389fac
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_question_mark_only_with_base.phpt
@@ -0,0 +1,39 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - question mark only with base URL
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url('https://example.com/base/path?oldQuery#oldFragment');
+
+$url = new Uri\WhatWg\UrlBuilder()
+ ->setQuery('?')
+ ->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($url->equals(new Uri\WhatWg\Url('?', $base), Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(30) "https://example.com/base/path?"
+object(Uri\WhatWg\Url)#%d (%d) {
+ ["scheme"]=>
+ string(5) "https"
+ ["username"]=>
+ NULL
+ ["password"]=>
+ NULL
+ ["host"]=>
+ string(11) "example.com"
+ ["port"]=>
+ NULL
+ ["path"]=>
+ string(10) "/base/path"
+ ["query"]=>
+ string(0) ""
+ ["fragment"]=>
+ NULL
+}
+bool(true)
+bool(true)
diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c
index 48ffc767207..079bbb84e7b 100644
--- a/ext/uri/uri_parser_whatwg.c
+++ b/ext/uri/uri_parser_whatwg.c
@@ -1136,27 +1136,17 @@ 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
- );
- php_uri_parser_whatwg_build_errors_and_throw(status, "query", Z_ARRVAL(errors));
- if (status != LXB_STATUS_OK) {
+ zend_result result = php_uri_parser_whatwg_query_write(lexbor_url, query, NULL);
+ php_uri_parser_whatwg_build_errors(Z_ARRVAL(errors));
+ if (result == FAILURE) {
goto failure;
}
}
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
- );
- php_uri_parser_whatwg_build_errors_and_throw(status, "fragment", Z_ARRVAL(errors));
- if (status != LXB_STATUS_OK) {
+ zend_result result = php_uri_parser_whatwg_fragment_write(lexbor_url, fragment, NULL);
+ php_uri_parser_whatwg_build_errors(Z_ARRVAL(errors));
+ if (result == FAILURE) {
goto failure;
}
}
@@ -1202,9 +1192,9 @@ ZEND_ATTRIBUTE_NONNULL static zend_result php_uri_parser_whatwg_build_path(
/* The opaque path parser needs the following delimiter to encode the
* immediately preceding space, while still reporting all space errors. */
- if (Z_TYPE_P(query) != IS_NULL) {
+ if (Z_TYPE_P(query) == IS_STRING && Z_STRLEN_P(query) > 0) {
smart_str_appendc(&opaque_path, '?');
- } else if (Z_TYPE_P(fragment) != IS_NULL) {
+ } else if (Z_TYPE_P(fragment) == IS_STRING && Z_STRLEN_P(fragment) > 0) {
smart_str_appendc(&opaque_path, '#');
}
@@ -1320,30 +1310,16 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
goto failure;
}
- if (Z_TYPE_P(query) == IS_STRING && Z_STRLEN_P(query) == 0) {
- /* The URL API setter treats an empty string as removal. The builder
- * distinguishes an empty component from an absent one. */
- lexbor_str_destroy(&lexbor_url->query, lexbor_url->mraw, false);
- lexbor_str_init(&lexbor_url->query, lexbor_url->mraw, 1);
- } else {
- result = php_uri_parser_whatwg_query_write(lexbor_url, query, NULL);
- php_uri_parser_whatwg_build_errors(Z_ARRVAL(errors));
- if (result == FAILURE) {
- goto failure;
- }
+ result = php_uri_parser_whatwg_query_write(lexbor_url, query, NULL);
+ php_uri_parser_whatwg_build_errors(Z_ARRVAL(errors));
+ if (result == FAILURE) {
+ goto failure;
}
- if (Z_TYPE_P(fragment) == IS_STRING && Z_STRLEN_P(fragment) == 0) {
- /* The URL API setter treats an empty string as removal. The builder
- * distinguishes an empty component from an absent one. */
- lexbor_str_destroy(&lexbor_url->fragment, lexbor_url->mraw, false);
- lexbor_str_init(&lexbor_url->fragment, lexbor_url->mraw, 1);
- } else {
- result = php_uri_parser_whatwg_fragment_write(lexbor_url, fragment, NULL);
- php_uri_parser_whatwg_build_errors(Z_ARRVAL(errors));
- if (result == FAILURE) {
- goto failure;
- }
+ result = php_uri_parser_whatwg_fragment_write(lexbor_url, fragment, NULL);
+ php_uri_parser_whatwg_build_errors(Z_ARRVAL(errors));
+ if (result == FAILURE) {
+ goto failure;
}
if (php_uri_pass_errors_by_ref_and_free(soft_errors_zv, Z_ARRVAL(errors)) == FAILURE) {