etn~:Qm:jp<јssG$W 0Km32Nނ㕰اqY^N tB෎]/+; !*lKkvѽ5|l[bjg47B`IZM zlϨ0nb}p&mWh뺡+ǦS0 Pޓf=H?'p*싄߶g|L{m-btʬN`\ K5ELFPLdgH 0LC0w 6J4A{ZјʚGiX-L94qYjfd鮺b-TAIQ"-&ʞv#z_^școs![FU{1q{ Hs5;^{G])ze~њCIT dpA@ݺ@(}g ܥL?dzn c8'|zٺ9Թ Qm&{)UoWf pE]#![U2G3-( Iy#*flo._)уa?=7zV_ G诟,vG  ʗ <9Wl>iR/_爮7 æ5I\gH:]r0 86{ ^0ARdl8=~芉Gװ.@#XSހ?3#kWME]O~z Q QIDnPI|_ XcL.Zn ={IKډpQc"yvqT!Ӌ̬|64jT|1R¯騖 :5Y5gl9GxJY R˪(Ʌ^Ԡh 6;Lo)8GZV 5s=ړr=m8Yq4:)pQD69@#Qf@`)źZf-DrȌ'$ʥn%_ MG?ޑ'SG)aUb`NK׎v- 5ݡkLUkwN"2’Au|8 NXM7{Z(q|쫺V@m@U.sE砠,QK$Ԕ5|ȡe<$M_B J\1cbXu?nF[FWh:Rჹyt<05ڨk ǭ9ҦR!γLNo@z&10&,&D`:LCJ]JLBm2{fOmc{%򂝒=)Qr1 TmZʹl զRJ,TzgjGVEz^p-s9&ER+w,ȵ3: )&5=#y}I.VbP{ 萹t#^zK=h|Y$Vؑ}|L 5翪`8J; ,tT1q= 4VF;&4ؽm^_X`^R%`m۳G!0V8k&e31#1*Ι)cb hڛ;9ERWlhTf~@BjLk}^@]NJO:,ZS$w蘿؟B396BȐKRF}iA 7ދiN_3pcfVy5vkf2_Sn嫍zr$e;ZP*LD}#X47LK;gv<iF/V*>G"Y6PNֳȄTwJlPx3ƻ R-'`/a;jC>`X=JfڭD} /و4RrEttZ+/r? 5U&D%{[g(\lBDHS"\P6\DU^A|d7 煔s(ֆ P"5Êօ[x^auw쟸J!72X#=Nݴ+ =֩QH氅فEENxBlѨW R9SK0(D7T˖vbsǶtsJx^to}¼8gzBx_: H;g\Ɩx!pUH/Si[QN9hD߾ų搾Էl٣1:\t3 8[vmZwPxGFB}Rԩ3n|ʺes9xஊWg)N&5MΕI蓄2EzXzbh'%ߏ +Ub! b=m=(Z8AQXBQl&?ɢhm`.JAG¶_!Xnee]/YXѲn_s.M?R(%Z<~KDb!r`>v@4x;7?T)Rrq^jpW3]23tؿ.qAAS6)7h «RkM"j.[dѽ]'m)8*\`\ y:(G@u$"tAtĬS/50$RW ! AퟃkxYvm*#]͝/.;;^&)Ǥe/!U%;Ǟm "RD_9c0uT6m|)( mpa]M[zSmD C[O UsJ$g݀ӀЌW\m~4jAj*ʼ=n (Y 8AZ (Vk%XoWPYnp5lwJrn*\j/WG9ܴL=/mTK|tˈTћy\a'oe0/y p ~ `/]AYTON֙蛸A +.۾H&/-)c)W.AOCT4]w*KZ-s:"nxy- ,xlMY^(3sc䅨vs'\θhBN@;[6eteiU( R} σT?ZxE0]c=A*8[bzT(7 {M[= gBۓvĴfȱ{}zu a0w>Ú.[}_p>vnEZ> _iuF ձJ/Uʦnf+,${IĆQ"H>-/TtId/J,+έ\1)ND@=oզ:ߢ(=.$ DC8$ dT$m%2eggnפpS#Hͧ'>tFPqZA7Jb;v?dpqADK\Fjt1)ublic function getPath(): string { if ('' === $this->path) { // No path return $this->path; } if ($this->path[0] !== '/') { // Relative path return $this->path; } // Ensure only one leading slash, to prevent XSS attempts. return '/' . ltrim($this->path, '/'); } /** * {@inheritdoc} */ public function getQuery(): string { return $this->query; } /** * {@inheritdoc} */ public function getFragment(): string { return $this->fragment; } /** * {@inheritdoc} */ public function withScheme($scheme): UriInterface { if (! is_string($scheme)) { throw new Exception\InvalidArgumentException(sprintf( '%s expects a string argument; received %s', __METHOD__, is_object($scheme) ? $scheme::class : gettype($scheme) )); } $scheme = $this->filterScheme($scheme); if ($scheme === $this->scheme) { // Do nothing if no change was made. return $this; } $new = clone $this; $new->scheme = $scheme; return $new; } // The following rule is buggy for parameters attributes // phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHintSpacing.NoSpaceBetweenTypeHintAndParameter /** * Create and return a new instance containing the provided user credentials. * * The value will be percent-encoded in the new instance, but with measures * taken to prevent double-encoding. * * {@inheritdoc} */ public function withUserInfo( $user, #[SensitiveParameter] $password = null ): UriInterface { if (! is_string($user)) { throw new Exception\InvalidArgumentException(sprintf( '%s expects a string user argument; received %s', __METHOD__, is_object($user) ? $user::class : gettype($user) )); } if (null !== $password && ! is_string($password)) { throw new Exception\InvalidArgumentException(sprintf( '%s expects a string or null password argument; received %s', __METHOD__, is_object($password) ? $password::class : gettype($password) )); } $info = $this->filterUserInfoPart($user); if (null !== $password) { $info .= ':' . $this->filterUserInfoPart($password); } if ($info === $this->userInfo) { // Do nothing if no change was made. return $this; } $new = clone $this; $new->userInfo = $info; return $new; } // phpcs:enable SlevomatCodingStandard.TypeHints.ParameterTypeHintSpacing.NoSpaceBetweenTypeHintAndParameter /** * {@inheritdoc} */ public function withHost($host): UriInterface { if (! is_string($host)) { throw new Exception\InvalidArgumentException(sprintf( '%s expects a string argument; received %s', __METHOD__, is_object($host) ? $host::class : gettype($host) )); } if ($host === $this->host) { // Do nothing if no change was made. return $this; } $new = clone $this; $new->host = strtolower($host); return $new; } /** * {@inheritdoc} */ public function withPort($port): UriInterface { if ($port !== null) { if (! is_numeric($port) || is_float($port)) { throw new Exception\InvalidArgumentException(sprintf( 'Invalid port "%s" specified; must be an integer, an integer string, or null', is_object($port) ? $port::class : gettype($port) )); } $port = (int) $port; } if ($port === $this->port) { // Do nothing if no change was made. return $this; } if ($port !== null && ($port < 1 || $port > 65535)) { throw new Exception\InvalidArgumentException(sprintf( 'Invalid port "%d" specified; must be a valid TCP/UDP port', $port )); } $new = clone $this; $new->port = $port; return $new; } /** * {@inheritdoc} */ public function withPath($path): UriInterface { if (! is_string($path)) { throw new Exception\InvalidArgumentException( 'Invalid path provided; must be a string' ); } if (str_contains($path, '?')) { throw new Exception\InvalidArgumentException( 'Invalid path provided; must not contain a query string' ); } if (str_contains($path, '#')) { throw new Exception\InvalidArgumentException( 'Invalid path provided; must not contain a URI fragment' ); } $path = $this->filterPath($path); if ($path === $this->path) { // Do nothing if no change was made. return $this; } $new = clone $this; $new->path = $path; return $new; } /** * {@inheritdoc} */ public function withQuery($query): UriInterface { if (! is_string($query)) { throw new Exception\InvalidArgumentException( 'Query string must be a string' ); } if (str_contains($query, '#')) { throw new Exception\InvalidArgumentException( 'Query string must not include a URI fragment' ); } $query = $this->filterQuery($query); if ($query === $this->query) { // Do nothing if no change was made. return $this; } $new = clone $this; $new->query = $query; return $new; } /** * {@inheritdoc} */ public function withFragment($fragment): UriInterface { if (! is_string($fragment)) { throw new Exception\InvalidArgumentException(sprintf( '%s expects a string argument; received %s', __METHOD__, is_object($fragment) ? $fragment::class : gettype($fragment) )); } $fragment = $this->filterFragment($fragment); if ($fragment === $this->fragment) { // Do nothing if no change was made. return $this; } $new = clone $this; $new->fragment = $fragment; return $new; } /** * Parse a URI into its parts, and set the properties * * @psalm-suppress InaccessibleProperty Method is only called in {@see Uri::__construct} and thus immutability is * still given. */ private function parseUri(string $uri): void { $parts = parse_url($uri); if (false === $parts) { throw new Exception\InvalidArgumentException( 'The source URI string appears to be malformed' ); } $this->scheme = isset($parts['scheme']) ? $this->filterScheme($parts['scheme']) : ''; $this->userInfo = isset($parts['user']) ? $this->filterUserInfoPart($parts['user']) : ''; $this->host = isset($parts['host']) ? strtolower($parts['host']) : ''; $this->port = $parts['port'] ?? null; $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : ''; $this->query = isset($parts['query']) ? $this->filterQuery($parts['query']) : ''; $this->fragment = isset($parts['fragment']) ? $this->filterFragment($parts['fragment']) : ''; if (isset($parts['pass'])) { $this->userInfo .= ':' . $parts['pass']; } } /** * Create a URI string from its various parts */ private static function createUriString( string $scheme, string $authority, string $path, string $query, string $fragment ): string { $uri = ''; if ('' !== $scheme) { $uri .= sprintf('%s:', $scheme); } if ('' !== $authority) { $uri .= '//' . $authority; } if ('' !== $path && ! str_starts_with($path, '/')) { $path = '/' . $path; } $uri .= $path; if ('' !== $query) { $uri .= sprintf('?%s', $query); } if ('' !== $fragment) { $uri .= sprintf('#%s', $fragment); } return $uri; } /** * Is a given port non-standard for the current scheme? */ private function isNonStandardPort(string $scheme, string $host, ?int $port): bool { if ('' === $scheme) { return '' === $host || null !== $port; } if ('' === $host || null === $port) { return false; } return ! isset($this->allowedSchemes[$scheme]) || $port !== $this->allowedSchemes[$scheme]; } /** * Filters the scheme to ensure it is a valid scheme. * * @param string $scheme Scheme name. * @return string Filtered scheme. */ private function filterScheme(string $scheme): string { $scheme = strtolower($scheme); $scheme = preg_replace('#:(//)?$#', '', $scheme); if ('' === $scheme) { return ''; } if (! isset($this->allowedSchemes[$scheme])) { throw new Exception\InvalidArgumentException(sprintf( 'Unsupported scheme "%s"; must be any empty string or in the set (%s)', $scheme, implode(', ', array_keys($this->allowedSchemes)) )); } return $scheme; } /** * Filters a part of user info in a URI to ensure it is properly encoded. */ private function filterUserInfoPart(string $part): string { $part = $this->filterInvalidUtf8($part); /** * @psalm-suppress ImpureFunctionCall Even tho the callback targets this immutable class, * psalm reports an issue here. * Note the addition of `%` to initial charset; this allows `|` portion * to match and thus prevent double-encoding. */ return preg_replace_callback( '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/u', [$this, 'urlEncodeChar'], $part ); } /** * Filters the path of a URI to ensure it is properly encoded. */ private function filterPath(string $path): string { $path = $this->filterInvalidUtf8($path); /** * @psalm-suppress ImpureFunctionCall Even tho the callback targets this immutable class, * psalm reports an issue here. */ return preg_replace_callback( '/(?:[^' . self::CHAR_UNRESERVED . ')(:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u', [$this, 'urlEncodeChar'], $path ); } /** * Encode invalid UTF-8 characters in given string. All other characters are unchanged. */ private function filterInvalidUtf8(string $string): string { // check if given string contains only valid UTF-8 characters if (preg_match('//u', $string)) { return $string; } $letters = str_split($string); foreach ($letters as $i => $letter) { if (! preg_match('//u', $letter)) { $letters[$i] = $this->urlEncodeChar([$letter]); } } return implode('', $letters); } /** * Filter a query string to ensure it is propertly encoded. * * Ensures that the values in the query string are properly urlencoded. */ private function filterQuery(string $query): string { if ('' !== $query && str_starts_with($query, '?')) { $query = substr($query, 1); } $parts = explode('&', $query); foreach ($parts as $index => $part) { [$key, $value] = $this->splitQueryValue($part); if ($value === null) { $parts[$index] = $this->filterQueryOrFragment($key); continue; } $parts[$index] = sprintf( '%s=%s', $this->filterQueryOrFragment($key), $this->filterQueryOrFragment($value) ); } return implode('&', $parts); } /** * Split a query value into a key/value tuple. * * @return array A value with exactly two elements, key and value */ private function splitQueryValue(string $value): array { $data = explode('=', $value, 2); if (! isset($data[1])) { $data[] = null; } return $data; } /** * Filter a fragment value to ensure it is properly encoded. */ private function filterFragment(string $fragment): string { if ('' !== $fragment && str_starts_with($fragment, '#')) { $fragment = '%23' . substr($fragment, 1); } return $this->filterQueryOrFragment($fragment); } /** * Filter a query string key or value, or a fragment. */ private function filterQueryOrFragment(string $value): string { $value = $this->filterInvalidUtf8($value); /** * @psalm-suppress ImpureFunctionCall Even tho the callback targets this immutable class, * psalm reports an issue here. */ return preg_replace_callback( '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u', [$this, 'urlEncodeChar'], $value ); } /** * URL encode a character returned by a regex. */ private function urlEncodeChar(array $matches): string { return rawurlencode($matches[0]); } }