UriParser.php 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. <?php
  2. declare(strict_types=1);
  3. namespace GuzzleHttp\Psr7;
  4. /**
  5. * @internal
  6. */
  7. final class UriParser
  8. {
  9. private function __construct()
  10. {
  11. }
  12. /**
  13. * UTF-8 aware \parse_url() replacement.
  14. *
  15. * The internal function produces broken output for non ASCII domain names
  16. * (IDN) when used with locales other than "C".
  17. *
  18. * On the other hand, cURL understands IDN correctly only when UTF-8 locale
  19. * is configured ("C.UTF-8", "en_US.UTF-8", etc.).
  20. *
  21. * @see https://bugs.php.net/bug.php?id=52923
  22. * @see https://www.php.net/manual/en/function.parse-url.php#114817
  23. * @see https://curl.se/libcurl/c/CURLOPT_URL.html#ENCODING
  24. *
  25. * @return array|false
  26. */
  27. public static function parse(string $url)
  28. {
  29. if (self::isPathNoSchemeReference($url)) {
  30. return self::parsePathNoSchemeReference($url);
  31. }
  32. // Preserve bracketed IP-literals (IPv6 or IPvFuture) in scheme, userinfo,
  33. // and network-path authorities before encoding. Userinfo is encoded
  34. // separately so raw bytes cannot reach parse_url(), which mutates
  35. // control characters instead of failing.
  36. $prefix = '';
  37. $ipv6Prefix = preg_match('%\A((?:[0-9A-Za-z+.-]+:)?//)(?:([^/?#@]*)(@))?(\[[^\]\x00-\x20\x7F/?#@]+\])(.*)\z%s', $url, $matches);
  38. if ($ipv6Prefix === false) {
  39. return false;
  40. }
  41. if ($ipv6Prefix === 1) {
  42. /** @var array{0:string, 1:string, 2:string, 3:string, 4:string, 5:string} $matches */
  43. $suffix = $matches[5];
  44. // After the bracketed host only an optional numeric port and/or a
  45. // path, query, or fragment may follow. Anything else (for example
  46. // `:80@evil` or `:80x`) would let parse_url() reinterpret a
  47. // different host.
  48. if (preg_match('%\A(?::[0-9]*)?(?:[/?#].*)?\z%s', $suffix) !== 1) {
  49. return false;
  50. }
  51. // RFC 3986 IP-literals contain no percent-encoding, so reject any
  52. // "%" in the bracketed host rather than letting the urldecode()
  53. // below turn an encoded octet into a different literal. This keeps
  54. // parsing aligned with withHost()/Rfc3986::isValidHost().
  55. if (str_contains($matches[4], '%')) {
  56. return false;
  57. }
  58. $prefix = $matches[1];
  59. if ($matches[3] === '@') {
  60. /** @var string|null */
  61. $encodedUserInfo = preg_replace_callback(
  62. '%[^:/@?&=#]+%usD',
  63. static function (array $matches): string {
  64. return urlencode($matches[0]);
  65. },
  66. $matches[2]
  67. );
  68. if ($encodedUserInfo === null) {
  69. return false;
  70. }
  71. $prefix .= $encodedUserInfo.'@';
  72. }
  73. $prefix .= $matches[4];
  74. $url = $suffix;
  75. }
  76. /** @var string|null */
  77. $encodedUrl = preg_replace_callback(
  78. '%[^:/@?&=#]+%usD',
  79. static function (array $matches): string {
  80. return urlencode($matches[0]);
  81. },
  82. $url
  83. );
  84. if ($encodedUrl === null) {
  85. return false;
  86. }
  87. $result = parse_url($prefix.$encodedUrl);
  88. if ($result === false) {
  89. return false;
  90. }
  91. return array_map('urldecode', $result);
  92. }
  93. private static function isPathNoSchemeReference(string $url): bool
  94. {
  95. if ($url === '' || str_starts_with($url, '/') || str_starts_with($url, '?') || str_starts_with($url, '#')) {
  96. return false;
  97. }
  98. $firstSegment = substr($url, 0, strcspn($url, '/?#'));
  99. return !str_contains($firstSegment, ':');
  100. }
  101. /**
  102. * @return array{path: string, query?: string, fragment?: string}
  103. */
  104. private static function parsePathNoSchemeReference(string $url): array
  105. {
  106. $parts = [];
  107. if (false !== ($fragmentPosition = strpos($url, '#'))) {
  108. $parts['fragment'] = substr($url, $fragmentPosition + 1);
  109. $url = substr($url, 0, $fragmentPosition);
  110. }
  111. if (false !== ($queryPosition = strpos($url, '?'))) {
  112. $parts['query'] = substr($url, $queryPosition + 1);
  113. $url = substr($url, 0, $queryPosition);
  114. }
  115. $parts['path'] = $url;
  116. return $parts;
  117. }
  118. }