getPath() === '' && ($uri->getScheme() === 'http' || $uri->getScheme() === 'https') ) { $uri = $uri->withPath('/'); } if ($flags & self::REMOVE_DEFAULT_HOST && $uri->getScheme() === 'file' && $uri->getHost() === 'localhost') { $uri = $uri->withHost(''); } if ($flags & self::REMOVE_DEFAULT_PORT && $uri->getPort() !== null && Uri::isDefaultPort($uri)) { $uri = $uri->withPort(null); } $removeDotSegments = ($flags & self::REMOVE_DOT_SEGMENTS) && !Uri::isRelativePathReference($uri); if ($removeDotSegments || $flags & self::REMOVE_DUPLICATE_SLASHES) { $path = Uri::rawPath($uri); if ($removeDotSegments) { $path = UriResolver::removeDotSegments($path); } if ($flags & self::REMOVE_DUPLICATE_SLASHES) { $path = preg_replace('#//++#', '/', $path); if ($path === null) { throw new \RuntimeException('Unable to remove duplicate slashes from URI path: '.preg_last_error_msg()); } } $uri = $uri->withPath(UriResolver::guardedPath($uri, $path)); } if ($flags & self::SORT_QUERY_PARAMETERS && $uri->getQuery() !== '') { $queryKeyValues = explode('&', $uri->getQuery()); sort($queryKeyValues); $uri = $uri->withQuery(implode('&', $queryKeyValues)); } if ($flags & self::CANONICALIZE_IPV6_HOST) { $uri = self::canonicalizeIpv6Host($uri); } return $uri; } /** * Whether two URIs can be considered equivalent. * * Both URIs are normalized automatically before comparison with the given * `$normalizations` bitmask. The method also accepts relative URI * references and returns true when they are equivalent. This of course * assumes they will be resolved against the same base URI. If this is not * the case, determination of equivalence or difference of relative * references does not mean anything. * * @param UriInterface $uri1 An URI to compare * @param UriInterface $uri2 An URI to compare * @param int $normalizations A bitmask of normalizations to apply, see constants * * @see https://datatracker.ietf.org/doc/html/rfc3986#section-6.1 */ public static function isEquivalent(UriInterface $uri1, UriInterface $uri2, int $normalizations = self::PRESERVING_NORMALIZATIONS): bool { return (string) self::normalize($uri1, $normalizations) === (string) self::normalize($uri2, $normalizations); } private static function capitalizePercentEncoding(UriInterface $uri): UriInterface { $regex = '/(?:%'.Rfc3986::HEX_OCTET.')++/'; $callback = function (array $match): string { return Utils::asciiToUpper($match[0]); }; $uri = self::withNormalizedUserInfo($uri, $regex, $callback); $uri = self::withNormalizedHost($uri, $regex, $callback); return $uri ->withPath(self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback)) ->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback)) ->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback)); } private static function decodeUnreservedCharacters(UriInterface $uri): UriInterface { $regex = '/%(?:2D|2E|5F|7E|3[0-9]|[46][1-9A-F]|[57][0-9A])/i'; $callback = function (array $match): string { return rawurldecode($match[0]); }; // The host is case-insensitive and PSR-7 requires it to be lowercase, // so decoded ALPHA octets (e.g. "%41") must land lowercase even for // implementations whose withHost() does not normalize the case. $hostCallback = function (array $match): string { return Utils::asciiToLower(rawurldecode($match[0])); }; $uri = self::withNormalizedUserInfo($uri, $regex, $callback); $uri = self::withNormalizedHost($uri, $regex, $hostCallback); return $uri ->withPath(self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback)) ->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback)) ->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback)); } /** * @param callable(array): string $callback */ private static function withNormalizedUserInfo(UriInterface $uri, string $regex, callable $callback): UriInterface { $userInfo = $uri->getUserInfo(); if (!str_contains($userInfo, '%')) { return $uri; } $normalized = self::normalizePercentEncodingInComponent($userInfo, $regex, $callback); if ($normalized === $userInfo) { return $uri; } // Normalization cannot create a colon: decoding is confined to // unreserved characters and capitalization keeps octets encoded. So // splitting on the first colon preserves the user/password boundary. $parts = explode(':', $normalized, 2); // PSR-7 defines withUserInfo('') as removing the userinfo, so a // userinfo with an empty user segment (e.g. ":pass") cannot be // expressed through the setter and is preserved as-is instead. if ($parts[0] === '') { return $uri; } $candidate = $uri->withUserInfo($parts[0], $parts[1] ?? null); // Normalization must never lose or corrupt information, so verify the // representation the setter returned and leave the component untouched // when the implementation cannot represent the normalized form. if ($candidate->getUserInfo() !== $normalized) { return $uri; } return $candidate; } /** * @param callable(array): string $callback */ private static function withNormalizedHost(UriInterface $uri, string $regex, callable $callback): UriInterface { $host = $uri->getHost(); // Bracketed IP-literal hosts are skipped as a legacy tolerance for // nonstandard values other implementations may carry, such as a zone // identifier in "[fe80::1%25eth0]"; that text was briefly valid URI // syntax under RFC 6874, which RFC 9844 obsoleted and reverted. if (str_starts_with($host, '[') || !str_contains($host, '%')) { return $uri; } $normalized = self::normalizePercentEncodingInComponent($host, $regex, $callback); if ($normalized === $host) { return $uri; } $candidate = $uri->withHost($normalized); // Normalization must never lose or corrupt information, so verify the // representation the setter returned and leave the component untouched // when the implementation cannot represent the normalized form. if ($candidate->getHost() !== $normalized) { return $uri; } return $candidate; } /** * @param callable(array): string $callback */ private static function normalizePercentEncodingInComponent(string $component, string $regex, callable $callback): string { // Decoding a valid triplet that follows a dangling "%" would complete // the malformed sequence into a new valid triplet ("example%6%31com" // becomes "example%61com"), turning malformed text valid and breaking // idempotence, so a component containing malformed percent syntax is // returned unchanged. $malformed = preg_match('/%(?!'.Rfc3986::HEX_OCTET.')/', $component); if ($malformed === false) { throw new \RuntimeException('Unable to scan URI component percent-encoding: '.preg_last_error_msg()); } if ($malformed === 1) { return $component; } $normalized = preg_replace_callback($regex, $callback, $component); if ($normalized === null) { throw new \RuntimeException('Unable to normalize URI component percent-encoding: '.preg_last_error_msg()); } return $normalized; } private static function canonicalizeIpv6Host(UriInterface $uri): UriInterface { $host = $uri->getHost(); if (!str_starts_with($host, '[') || !str_ends_with($host, ']')) { return $uri; } // Foreign UriInterface implementations may carry IPvFuture literals, // IPv6 zone identifiers, uppercase text, or invalid spellings; // tryCanonicalizeIpv6() canonicalizes only what is unambiguously an // IPv6 address and leaves everything else untouched. $canonical = Rfc3986::tryCanonicalizeIpv6(substr($host, 1, -1)); if ($canonical === null || '['.$canonical.']' === $host) { return $uri; } $candidate = $uri->withHost('['.$canonical.']'); // Normalization must never corrupt a component, so keep the original // host when the implementation does not retain the canonical form. if ($candidate->getHost() !== '['.$canonical.']') { return $uri; } return $candidate; } private function __construct() { // cannot be instantiated } }