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) {