Commit eb34f8920db for php.net

commit eb34f8920dbcc2c6bf4a1cbd1a7eb832b09b1425
Author: Máté Kocsis <kocsismate@woohoolabs.com>
Date:   Tue Sep 8 21:33:21 2026 +0200

    ext/uri: Build hostless URLs with the correct path type

    Preserve absent hosts for non-special URLs and initialize opaque paths directly. Add validation to reject path delimiters.

    Closes GH-23636

diff --git a/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt b/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt
index 9921721af17..052584e91cc 100644
--- a/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt
+++ b/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt
@@ -15,7 +15,7 @@

 ?>
 --EXPECTF--
-string(9) "scheme://"
+string(7) "scheme:"
 object(Uri\WhatWg\Url)#%d (%d) {
   ["scheme"]=>
   string(6) "scheme"
@@ -24,7 +24,7 @@
   ["password"]=>
   NULL
   ["host"]=>
-  string(0) ""
+  NULL
   ["port"]=>
   NULL
   ["path"]=>
diff --git a/ext/uri/tests/whatwg/builder/path_error_opaque_hashmark.phpt b/ext/uri/tests/whatwg/builder/path_error_opaque_hashmark.phpt
new file mode 100644
index 00000000000..11176137331
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_error_opaque_hashmark.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - hashmark in an opaque path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("a#b");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified path is malformed
diff --git a/ext/uri/tests/whatwg/builder/path_error_opaque_question_mark.phpt b/ext/uri/tests/whatwg/builder/path_error_opaque_question_mark.phpt
new file mode 100644
index 00000000000..c527d87b13d
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_error_opaque_question_mark.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - question mark in an opaque path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("a?b");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified path is malformed
diff --git a/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters.phpt b/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters.phpt
new file mode 100644
index 00000000000..f33a3c26ee9
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - question mark and hashmark in a hierarchical path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setPath("/a?b#c");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(29) "https://example.com/a%3Fb%23c"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(10) "/a%3Fb%23c"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces.phpt b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces.phpt
new file mode 100644
index 00000000000..c66d10f29f5
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces.phpt
@@ -0,0 +1,67 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - leading and trailing spaces in an opaque path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("  abc ");
+$softErrors = [];
+$url = $builder->build(softErrors: $softErrors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($softErrors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(10) "foo:  abc "
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(3) "foo"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  NULL
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(6) "  abc "
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+array(3) {
+  [0]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(1) " "
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+  [1]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(5) " abc "
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+  [2]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(6) "  abc "
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+}
+bool(false)
diff --git a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt
new file mode 100644
index 00000000000..a5cc306c07f
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt
@@ -0,0 +1,59 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - trailing spaces before a fragment
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("abc  ");
+$builder->setFragment("f");
+$softErrors = [];
+$url = $builder->build(softErrors: $softErrors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($softErrors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()), Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(11) "foo:abc  #f"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(3) "foo"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  NULL
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(5) "abc  "
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  string(1) "f"
+}
+array(2) {
+  [0]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(2) " #"
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+  [1]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(3) "  #"
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt
new file mode 100644
index 00000000000..4ffa9701e10
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt
@@ -0,0 +1,59 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - trailing spaces before a query
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("abc  ");
+$builder->setQuery("q");
+$softErrors = [];
+$url = $builder->build(softErrors: $softErrors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($softErrors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(11) "foo:abc  ?q"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(3) "foo"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  NULL
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(5) "abc  "
+  ["query"]=>
+  string(1) "q"
+  ["fragment"]=>
+  NULL
+}
+array(2) {
+  [0]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(2) " ?"
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+  [1]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(3) "  ?"
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_special_char.phpt b/ext/uri/tests/whatwg/builder/path_success_special_char.phpt
index b47a44b1e13..f10ec866a40 100644
--- a/ext/uri/tests/whatwg/builder/path_success_special_char.phpt
+++ b/ext/uri/tests/whatwg/builder/path_success_special_char.phpt
@@ -1,11 +1,11 @@
 --TEST--
-Test Uri\WhatWg\UrlBuilder::setPath() - success - contains special character
+Test Uri\WhatWg\UrlBuilder::setPath() - success - contains a C0 control character
 --FILE--
 <?php

 $builder = new Uri\WhatWg\UrlBuilder();
 $builder->setScheme("scheme");
-$builder->setPath("#foo");
+$builder->setPath("\x1Ffoo");

 $url = $builder->build();

@@ -15,7 +15,7 @@

 ?>
 --EXPECTF--
-string(16) "scheme:///%23foo"
+string(13) "scheme:%1Ffoo"
 object(Uri\WhatWg\Url)#%d (%d) {
   ["scheme"]=>
   string(6) "scheme"
@@ -24,11 +24,11 @@
   ["password"]=>
   NULL
   ["host"]=>
-  string(0) ""
+  NULL
   ["port"]=>
   NULL
   ["path"]=>
-  string(7) "/%23foo"
+  string(6) "%1Ffoo"
   ["query"]=>
   NULL
   ["fragment"]=>
diff --git a/ext/uri/tests/whatwg/builder/query_success_hashmark.phpt b/ext/uri/tests/whatwg/builder/query_success_hashmark.phpt
new file mode 100644
index 00000000000..3c56e6a131f
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_hashmark.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - hashmark does not introduce a fragment
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setQuery("a#b");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(26) "https://example.com/?a%23b"
+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(5) "a%23b"
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt b/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt
index c805105ee99..26593beda92 100644
--- a/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt
+++ b/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt
@@ -14,7 +14,7 @@

 ?>
 --EXPECTF--
-string(6) "foo://"
+string(4) "foo:"
 object(Uri\WhatWg\Url)#%d (%d) {
   ["scheme"]=>
   string(3) "foo"
@@ -23,7 +23,7 @@
   ["password"]=>
   NULL
   ["host"]=>
-  string(0) ""
+  NULL
   ["port"]=>
   NULL
   ["path"]=>
diff --git a/ext/uri/tests/whatwg/builder/scheme_success_special.phpt b/ext/uri/tests/whatwg/builder/scheme_success_special.phpt
index 60a175a5f20..39fb4b71c13 100644
--- a/ext/uri/tests/whatwg/builder/scheme_success_special.phpt
+++ b/ext/uri/tests/whatwg/builder/scheme_success_special.phpt
@@ -13,7 +13,7 @@

 ?>
 --EXPECTF--
-string(18) "my-12+34.scheme://"
+string(16) "my-12+34.scheme:"
 object(Uri\WhatWg\Url)#%d (%d) {
   ["scheme"]=>
   string(15) "my-12+34.scheme"
@@ -22,7 +22,7 @@
   ["password"]=>
   NULL
   ["host"]=>
-  string(0) ""
+  NULL
   ["port"]=>
   NULL
   ["path"]=>
diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c
index 3ae5a3890e8..acda86877ac 100644
--- a/ext/uri/uri_parser_whatwg.c
+++ b/ext/uri/uri_parser_whatwg.c
@@ -984,6 +984,56 @@ ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors(zval *erro
 	fill_errors_inner(Z_ARRVAL_P(errors));
 }

+ZEND_ATTRIBUTE_NONNULL static zend_result php_uri_parser_whatwg_build_path(
+	lxb_url_t *lexbor_url, const zval *path, const zval *query, const zval *fragment, zval *errors
+) {
+	zend_result result;
+	const char *path_start = Z_STRVAL_P(path);
+	const char *path_end = path_start + Z_STRLEN_P(path);
+	while (path_start < path_end && php_uri_whatwg_is_ascii_tab_or_newline(*path_start)) {
+		path_start++;
+	}
+
+	if (lexbor_url->host.type == LXB_URL_HOST_TYPE__UNDEF && (path_start == path_end || *path_start != '/')) {
+		/* A pathname setter cannot parse an opaque path. Parse it directly,
+		 * rejecting delimiters that would start a query or fragment. */
+		for (const char *p = Z_STRVAL_P(path); p < path_end; p++) {
+			if (*p == '?' || *p == '#') {
+				throw_invalid_url_exception_during_write(NULL, "path");
+
+				return FAILURE;
+			}
+		}
+
+		smart_str opaque_path = {0};
+		smart_str_append(&opaque_path, Z_STR_P(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) {
+			smart_str_appendc(&opaque_path, '?');
+		} else if (Z_TYPE_P(fragment) != IS_NULL) {
+			smart_str_appendc(&opaque_path, '#');
+		}
+
+		zend_string *input = smart_str_extract(&opaque_path);
+		lxb_url_parser_clean(&lexbor_parser);
+		const lxb_status_t status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, NULL,
+			(const lxb_char_t *) ZSTR_VAL(input), ZSTR_LEN(input),
+			LXB_URL_STATE_OPAQUE_PATH_STATE, LXB_ENCODING_UTF_8);
+		result = status == LXB_STATUS_OK ? SUCCESS : FAILURE;
+		if (result == FAILURE) {
+			throw_invalid_url_exception_during_write(NULL, "path");
+		}
+		php_uri_parser_whatwg_build_errors(errors);
+		zend_string_release(input);
+	} else {
+		result = php_uri_parser_whatwg_path_write(lexbor_url, path, NULL);
+	}
+
+	return result;
+}
+
 ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_whatwg_build_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,
@@ -1016,10 +1066,14 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
 		goto failure;
 	}

-	result = php_uri_parser_whatwg_host_write(lexbor_url, host, NULL);
-	php_uri_parser_whatwg_build_errors(&errors);
-	if (result == FAILURE) {
-		goto failure;
+	/* Set the host when provided or required by a special scheme (file allows an empty host).
+	 * Otherwise, preserve the absent host so the path can be opaque. */
+	if (Z_TYPE_P(host) == IS_STRING || lxb_url_is_special(lexbor_url)) {
+		result = php_uri_parser_whatwg_host_write(lexbor_url, host, NULL);
+		php_uri_parser_whatwg_build_errors(&errors);
+		if (result == FAILURE) {
+			goto failure;
+		}
 	}

 	if (lexbor_url->host.type == LXB_URL_HOST_TYPE__UNDEF
@@ -1062,7 +1116,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
 		goto failure;
 	}

-	result = php_uri_parser_whatwg_path_write(lexbor_url, path, NULL);
+	result = php_uri_parser_whatwg_build_path(lexbor_url, path, query, fragment, &errors);
 	php_uri_parser_whatwg_build_errors(&errors);
 	if (result == FAILURE) {
 		goto failure;