Update imapengine dependancies too

This commit is contained in:
johnnyq
2026-08-02 01:26:40 -04:00
parent cf4446f405
commit 3e532fc792
185 changed files with 9018 additions and 4442 deletions

View File

@@ -13,17 +13,16 @@ use Psr\Http\Message\StreamInterface;
*/
final class AppendStream implements StreamInterface
{
use NonSerializableStreamTrait;
/** @var StreamInterface[] Streams being decorated */
private $streams = [];
private array $streams = [];
/** @var bool */
private $seekable = true;
private bool $seekable = true;
/** @var int */
private $current = 0;
private int $current = 0;
/** @var int */
private $pos = 0;
private int $pos = 0;
/**
* @param StreamInterface[] $streams Streams to decorate. Each stream must
@@ -38,18 +37,9 @@ final class AppendStream implements StreamInterface
public function __toString(): string
{
try {
$this->rewind();
$this->rewind();
return $this->getContents();
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
}
return $this->getContents();
}
/**
@@ -132,7 +122,7 @@ final class AppendStream implements StreamInterface
if ($s === null) {
return null;
}
$size += $s;
$size = Integers::add($size, $s);
}
return $size;
@@ -153,26 +143,8 @@ final class AppendStream implements StreamInterface
/**
* Attempts to seek to the given position. Only supports SEEK_SET.
*/
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
if (!$this->seekable) {
throw new \RuntimeException('This AppendStream is not seekable');
} elseif ($whence !== SEEK_SET) {
@@ -203,15 +175,10 @@ final class AppendStream implements StreamInterface
/**
* Reads from all of the appended streams until the length is met or EOF.
*/
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
if ($this->streams === []) {
@@ -233,7 +200,7 @@ final class AppendStream implements StreamInterface
++$this->current;
}
$result = $this->streams[$this->current]->read($remaining);
$result = StreamTimeout::read($this->streams[$this->current], $remaining, 'Unable to read from stream: timed out');
if ($result === '') {
$progressToNext = true;
@@ -244,7 +211,7 @@ final class AppendStream implements StreamInterface
$remaining = $length - strlen($buffer);
}
$this->pos += strlen($buffer);
$this->pos = Integers::add($this->pos, strlen($buffer));
return $buffer;
}
@@ -264,34 +231,13 @@ final class AppendStream implements StreamInterface
return $this->seekable;
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
throw new \RuntimeException('Cannot write to an AppendStream');
}
/**
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null): ?array
{
if ($key !== null && !\is_string($key)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.',
\get_debug_type($key)
);
}
return $key ? null : [];
return $key === null ? [] : null;
}
}

View File

@@ -16,22 +16,22 @@ use Psr\Http\Message\StreamInterface;
*/
final class BufferStream implements StreamInterface
{
/** @var int */
private $hwm;
use NonSerializableStreamTrait;
/** @var string */
private $buffer = '';
private int $hwm;
private string $buffer = '';
/**
* @param int $hwm High water mark, representing the preferred maximum
* buffer size. If the size of the buffer exceeds the high
* water mark, then calls to write will continue to succeed
* but will return 0 to inform writers to slow down
* buffer size. If the size of the buffer reaches or exceeds
* the high water mark, then calls to write will continue to
* succeed but will return 0 to inform writers to slow down
* until the buffer has been drained by reading from it.
*/
public function __construct(int $hwm = 16384)
{
$this->hwm = $hwm;
$this->hwm = Integers::assertNonNegativeInteger($hwm, 'High water mark');
}
public function __toString(): string
@@ -84,26 +84,8 @@ final class BufferStream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
throw new \RuntimeException('Cannot seek a BufferStream');
}
@@ -120,15 +102,10 @@ final class BufferStream implements StreamInterface
/**
* Reads data from the buffer.
*/
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
$currentLength = strlen($this->buffer);
@@ -149,17 +126,8 @@ final class BufferStream implements StreamInterface
/**
* Writes data to the buffer.
*/
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
$this->buffer .= $string;
if (strlen($this->buffer) >= $this->hwm) {
@@ -172,21 +140,12 @@ final class BufferStream implements StreamInterface
/**
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
if ($key !== null && !\is_string($key)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.',
\get_debug_type($key)
);
}
if ($key === 'hwm') {
return $this->hwm;
}
return $key ? null : [];
return $key === null ? [] : null;
}
}

View File

@@ -13,26 +13,30 @@ use Psr\Http\Message\StreamInterface;
final class CachingStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var StreamInterface Stream being wrapped */
private $remoteStream;
private StreamInterface $remoteStream;
/** @var int Number of bytes to skip reading due to a write on the buffer */
private $skipReadBytes = 0;
private int $skipReadBytes = 0;
/**
* @var StreamInterface
*/
private $stream;
private StreamInterface $stream;
/** @var bool */
private $detached = false;
private bool $detached = false;
private bool $closed = false;
/**
* We will treat the buffer object as the body of the stream
*
* @param StreamInterface $stream Stream to cache. The cursor is assumed to be at the beginning of the stream.
* @param StreamInterface $target Optionally specify where data is cached
* @param StreamInterface $target Optionally specify where data is cached. Defaults to a "php://temp"
* stream. A custom target is used as a random-access byte buffer to
* replay the remote stream, so it must be readable, writable, and
* seekable, report an accurate position and size, and store writes
* losslessly. Lossy or non-seekable streams such as BufferStream and
* DroppingStream are not valid targets.
*/
public function __construct(
StreamInterface $stream,
@@ -62,36 +66,31 @@ final class CachingStream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
if ($whence === SEEK_SET) {
$byte = $offset;
} elseif ($whence === SEEK_CUR) {
$byte = $offset + $this->tell();
$byte = Integers::addSigned($this->tell(), $offset);
} elseif ($whence === SEEK_END) {
$size = $this->remoteStream->getSize();
if ($size === null) {
// Discovering the size reads the remote stream to EOF and
// moves the cursor, so restore the cursor if the computed
// target is rejected to keep a failed seek side-effect free.
$position = $this->tell();
$size = $this->cacheEntireStream();
try {
$byte = Integers::addSigned($size, $offset);
} catch (\Throwable $e) {
$this->stream->seek($position);
throw $e;
}
} else {
$byte = Integers::addSigned($size, $offset);
}
$byte = $size + $offset;
} else {
throw new \InvalidArgumentException('Invalid whence');
}
@@ -119,15 +118,10 @@ final class CachingStream implements StreamInterface
}
}
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
// Perform a regular read on any previously read data from the buffer
@@ -140,8 +134,10 @@ final class CachingStream implements StreamInterface
// been filled from the remote stream, then we must skip bytes on
// the remote stream to emulate overwriting bytes from that
// position. This mimics the behavior of other PHP stream wrappers.
$remoteData = $this->remoteStream->read(
$remaining + $this->skipReadBytes
$remoteData = StreamTimeout::read(
$this->remoteStream,
Integers::add($remaining, $this->skipReadBytes),
'Unable to read from stream: timed out'
);
if ($this->skipReadBytes) {
@@ -161,24 +157,15 @@ final class CachingStream implements StreamInterface
return $data;
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
// When appending to the end of the currently read stream, you'll want
// to skip bytes from being read from the remote stream to emulate
// other stream wrappers. Basically replacing bytes of data of a fixed
// length.
$overflow = (strlen($string) + $this->tell()) - $this->remoteStream->tell();
$overflow = Integers::add(strlen($string), $this->tell()) - $this->remoteStream->tell();
if ($overflow > 0) {
$this->skipReadBytes += $overflow;
$this->skipReadBytes = Integers::add($this->skipReadBytes, $overflow);
}
return $this->stream->write($string);
@@ -207,13 +194,39 @@ final class CachingStream implements StreamInterface
}
/**
* Close both the remote stream and buffer stream
* Close the remote stream and any attached cache stream.
*/
public function close(): void
{
$this->remoteStream->close();
$this->stream->close();
if ($this->closed) {
return;
}
$closeCache = !$this->detached;
$this->closed = true;
$this->detached = true;
$exception = null;
try {
$this->remoteStream->close();
} catch (\Throwable $e) {
$exception = $e;
}
if ($closeCache) {
try {
$this->stream->close();
} catch (\Throwable $e) {
if ($exception === null) {
$exception = $e;
}
}
}
if ($exception !== null) {
throw $exception;
}
}
private function cacheEntireStream(): int

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* Escapes control characters and malformed UTF-8 for use in diagnostics.
*/
final class DiagnosticValue
{
private function __construct()
{
}
/**
* Escapes C0, DEL, and C1 controls as uppercase `\xNN` sequences.
*
* ASCII bytes from 0x20 through 0x7E and valid UTF-8 characters outside
* those control ranges remain unchanged. If the input is malformed UTF-8 or
* PCRE cannot process it, every byte outside printable ASCII is escaped.
* Valid C1 characters are rendered as `\xNN` using their Unicode code
* points. During bytewise fallback, each original byte outside printable
* ASCII is rendered in the same form. The result is diagnostic text, not a
* reversible encoding.
*
* This does not encode values for HTML, JSON, shells, terminals, URLs, or
* protocol fields.
*/
public static function escape(string $value): string
{
$escaped = \preg_replace_callback(
'/[\x{0000}-\x{001F}\x{007F}-\x{009F}]/u',
static function (array $matches): string {
$character = $matches[0];
$codePoint = \strlen($character) === 1 ? \ord($character) : \ord($character[1]);
return \sprintf('\\x%02X', $codePoint);
},
$value
);
return $escaped ?? self::escapeBytes($value);
}
private static function escapeBytes(string $value): string
{
$escaped = '';
for ($offset = 0, $length = \strlen($value); $offset < $length; ++$offset) {
$byte = \ord($value[$offset]);
if ($byte >= 0x20 && $byte <= 0x7E) {
$escaped .= $value[$offset];
continue;
}
$escaped .= \sprintf('\\x%02X', $byte);
}
return $escaped;
}
}

View File

@@ -13,12 +13,11 @@ use Psr\Http\Message\StreamInterface;
final class DroppingStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var int */
private $maxLength;
private int $maxLength;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
/**
* @param StreamInterface $stream Underlying stream to decorate.
@@ -27,20 +26,11 @@ final class DroppingStream implements StreamInterface
public function __construct(StreamInterface $stream, int $maxLength)
{
$this->stream = $stream;
$this->maxLength = $maxLength;
$this->maxLength = Integers::assertNonNegativeInteger($maxLength, 'Maximum length');
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
$diff = $this->maxLength - $this->stream->getSize();
// Begin returning 0 when the underlying stream is too large.

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7\Exception;
use RuntimeException;
/**
* Exception thrown when a stream operation times out.
*/
class TimeoutException extends RuntimeException
{
}

View File

@@ -15,6 +15,8 @@ use Psr\Http\Message\StreamInterface;
#[\AllowDynamicProperties]
final class FnStream implements StreamInterface
{
use NonSerializableStreamTrait;
private const SLOTS = [
'__toString', 'close', 'detach', 'rewind',
'getSize', 'tell', 'eof', 'isSeekable', 'seek', 'isWritable', 'write',
@@ -22,7 +24,9 @@ final class FnStream implements StreamInterface
];
/** @var array<string, callable> */
private $methods;
private array $methods;
private bool $detached = false;
/**
* @param array<string, callable> $methods Hash of method name to a callable.
@@ -44,8 +48,7 @@ final class FnStream implements StreamInterface
*/
public function __get(string $name): void
{
throw new \BadMethodCallException(str_replace('_fn_', '', $name)
.'() is not implemented in the FnStream');
throw new \BadMethodCallException(\sprintf('%s() is not implemented in the FnStream', DiagnosticValue::escape(str_replace('_fn_', '', $name))));
}
/**
@@ -53,8 +56,14 @@ final class FnStream implements StreamInterface
*/
public function __destruct()
{
if (isset($this->_fn_close)) {
($this->_fn_close)();
if ($this->detached || !isset($this->_fn_close)) {
return;
}
try {
$this->close();
} catch (\Throwable $e) {
// Destructors must not surface cleanup failures.
}
}
@@ -65,7 +74,18 @@ final class FnStream implements StreamInterface
*/
public function __wakeup(): void
{
throw new \LogicException('FnStream should never be unserialized');
$this->methods = [];
$this->detached = true;
throw new \LogicException(static::class.' should never be unserialized');
}
public function __unserialize(array $data): void
{
$this->methods = [];
$this->detached = true;
throw new \LogicException(static::class.' should never be unserialized');
}
/**
@@ -74,10 +94,8 @@ final class FnStream implements StreamInterface
*
* @param StreamInterface $stream Stream to decorate
* @param array<string, callable> $methods Hash of method name to a callable
*
* @return FnStream
*/
public static function decorate(StreamInterface $stream, array $methods)
public static function decorate(StreamInterface $stream, array $methods): self
{
// If any of the required methods were not provided, then simply
// proxy to the decorated stream.
@@ -92,110 +110,113 @@ final class FnStream implements StreamInterface
public function __toString(): string
{
try {
/** @var string */
return ($this->_fn___toString)();
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
$this->assertAttached();
return '';
}
/** @var string */
return ($this->_fn___toString)();
}
public function close(): void
{
($this->_fn_close)();
if ($this->detached) {
return;
}
$close = $this->_fn_close;
$this->detached = true;
$close();
}
public function detach()
{
return ($this->_fn_detach)();
if ($this->detached) {
return null;
}
$detach = $this->_fn_detach;
$result = $detach();
$this->detached = true;
return $result;
}
public function getSize(): ?int
{
if ($this->detached) {
return null;
}
return ($this->_fn_getSize)();
}
public function tell(): int
{
$this->assertAttached();
return ($this->_fn_tell)();
}
public function eof(): bool
{
$this->assertAttached();
return ($this->_fn_eof)();
}
public function isSeekable(): bool
{
if ($this->detached) {
return false;
}
return ($this->_fn_isSeekable)();
}
public function rewind(): void
{
$this->assertAttached();
($this->_fn_rewind)();
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
$this->assertAttached();
($this->_fn_seek)($offset, $whence);
}
public function isWritable(): bool
{
if ($this->detached) {
return false;
}
return ($this->_fn_isWritable)();
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
$this->assertAttached();
return ($this->_fn_write)($string);
}
public function isReadable(): bool
{
if ($this->detached) {
return false;
}
return ($this->_fn_isReadable)();
}
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
$this->assertAttached();
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
return ($this->_fn_read)($length);
@@ -203,23 +224,27 @@ final class FnStream implements StreamInterface
public function getContents(): string
{
$this->assertAttached();
return ($this->_fn_getContents)();
}
/**
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
if ($key !== null && !\is_string($key)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.',
\get_debug_type($key)
);
if ($this->detached) {
return $key === null ? [] : null;
}
return ($this->_fn_getMetadata)($key);
}
private function assertAttached(): void
{
if ($this->detached) {
throw new \RuntimeException('Stream is detached');
}
}
}

View File

@@ -6,11 +6,14 @@ namespace GuzzleHttp\Psr7;
final class Header
{
private function __construct()
{
}
/**
* Parse an array of header values containing ";" separated data into an
* array of associative arrays representing the header key value pair data
* of the header. When a parameter does not contain a value, but just
* contains a key, this function will inject a key with a '' string value.
* Parses semicolon-separated header parameters into associative arrays, one
* per comma-separated header value. Parameters without a value are appended
* as values under integer keys.
*
* @param string|array $header Header to parse into components.
*/
@@ -23,7 +26,13 @@ final class Header
foreach (self::splitList($value) as $val) {
$part = [];
foreach (self::splitParameters($val) as $kvp) {
if (preg_match_all('/<[^>]+>|[^=]+/', $kvp, $matches)) {
$count = preg_match_all('/<[^>]+>|[^=]+/', $kvp, $matches);
if ($count === false) {
throw new \RuntimeException('Unable to parse header parameters: '.preg_last_error_msg());
}
if ($count !== 0) {
$m = $matches[0];
if (isset($m[1])) {
$part[trim($m[0], $trimmed)] = trim($m[1], $trimmed);
@@ -86,37 +95,16 @@ final class Header
}
/**
* Converts an array of header values that may contain comma separated
* headers into an array of headers with no comma separated values.
* Splits an HTTP header defined to contain a comma-separated list into each
* individual value. Empty values are removed.
*
* @param string|array $header Header to normalize.
* Example headers include `accept`, `cache-control`, and `if-none-match`.
*
* @deprecated Use self::splitList() instead.
*/
public static function normalize($header): array
{
\trigger_deprecation('guzzlehttp/psr7', '2.3', 'Header::normalize() is deprecated and will be removed in guzzlehttp/psr7 3.0. Use Header::splitList() instead.');
$result = [];
foreach ((array) $header as $value) {
foreach (self::splitList($value) as $parsed) {
$result[] = $parsed;
}
}
return $result;
}
/**
* Splits a HTTP header defined to contain a comma-separated list into
* each individual value. Empty values will be removed.
* This method must not be used to parse headers that are not defined as a
* list, such as `user-agent` or `set-cookie`.
*
* Example headers include 'accept', 'cache-control' and 'if-none-match'.
*
* This method must not be used to parse headers that are not defined as
* a list, such as 'user-agent' or 'set-cookie'.
*
* @param string|string[] $values Header value as returned by MessageInterface::getHeader()
* @param string|string[] $values Header value as returned by
* MessageInterface::getHeader()
*
* @return string[]
*/
@@ -144,7 +132,7 @@ final class Header
}
if (!$isQuoted && $value[$i] === ',') {
$v = \trim($v, " \n\r\t\0\x0B");
$v = \trim($v, " \t\n\r");
if ($v !== '') {
$result[] = $v;
}
@@ -169,7 +157,7 @@ final class Header
$v .= $value[$i];
}
$v = \trim($v, " \n\r\t\0\x0B");
$v = \trim($v, " \t\n\r");
if ($v !== '') {
$result[] = $v;
}

View File

@@ -36,7 +36,13 @@ final class HttpFactory implements RequestFactoryInterface, ResponseFactoryInter
$size = $stream->getSize();
}
return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType);
return new UploadedFile(
$stream,
Integers::assertOptionalNonNegativeSize($size, 'Uploaded file size'),
$error,
$clientFilename,
$clientMediaType
);
}
public function createStream(string $content = ''): StreamInterface
@@ -50,7 +56,7 @@ final class HttpFactory implements RequestFactoryInterface, ResponseFactoryInter
$resource = Utils::tryFopen($file, $mode);
} catch (\RuntimeException $e) {
if ('' === $mode || false === \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true)) {
throw new \InvalidArgumentException(sprintf('Invalid file opening mode "%s"', $mode), 0, $e);
throw new \InvalidArgumentException(sprintf('Invalid file opening mode: %s', DiagnosticValue::escape($mode)), 0, $e);
}
throw $e;
@@ -64,8 +70,12 @@ final class HttpFactory implements RequestFactoryInterface, ResponseFactoryInter
return Utils::streamFor($resource);
}
public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface
{
public function createServerRequest(
string $method,
$uri,
#[\SensitiveParameter]
array $serverParams = []
): ServerRequestInterface {
if (empty($method)) {
if (!empty($serverParams['REQUEST_METHOD'])) {
$method = $serverParams['REQUEST_METHOD'];

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\StreamInterface;
/**
@@ -20,12 +21,15 @@ use Psr\Http\Message\StreamInterface;
final class InflateStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
private ?StreamInterface $source;
public function __construct(StreamInterface $stream)
{
$this->source = $stream;
$resource = StreamWrapper::getResource($stream);
// Specify window=15+32, so zlib will use header detection to both gzip (with header) and zlib data
// See https://www.zlib.net/manual.html#Advanced definition of inflateInit2
@@ -34,4 +38,64 @@ final class InflateStream implements StreamInterface
stream_filter_append($resource, 'zlib.inflate', STREAM_FILTER_READ, ['window' => 15 + 32]);
$this->stream = $stream->isSeekable() ? new Stream($resource) : new NoSeekStream(new Stream($resource));
}
public function read(int $length): string
{
if ($length <= 0 || $this->source === null) {
return $this->stream->read($length);
}
try {
$data = $this->stream->read($length);
} catch (TimeoutException $e) {
throw $e;
} catch (\RuntimeException $e) {
if (StreamTimeout::isReadTimedOut($this->source)) {
throw new TimeoutException('Unable to read from stream: timed out', 0, $e);
}
throw $e;
}
if ($data === '' && StreamTimeout::isReadTimedOut($this->source)) {
throw new TimeoutException('Unable to read from stream: timed out');
}
return $data;
}
public function close(): void
{
$source = $this->source;
$this->source = null;
$exception = null;
try {
$this->stream->close();
} catch (\Throwable $e) {
$exception = $e;
}
if ($source !== null) {
try {
$source->close();
} catch (\Throwable $e) {
if ($exception === null) {
$exception = $e;
}
}
}
if ($exception !== null) {
throw $exception;
}
}
public function detach()
{
$this->source = null;
return $this->stream->detach();
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class Integers
{
private function __construct()
{
}
public static function add(int $a, int $b): int
{
if ($a < 0 || $b < 0) {
throw new \InvalidArgumentException('Integer operands must be non-negative');
}
if ($b > \PHP_INT_MAX - $a) {
throw new \OverflowException('Stream byte count exceeds the maximum integer size supported on this platform');
}
return $a + $b;
}
public static function addSigned(int $base, int $delta): int
{
if ($base < 0) {
throw new \InvalidArgumentException('Stream offset must be non-negative');
}
if ($delta > 0 && $delta > \PHP_INT_MAX - $base) {
throw new \OverflowException('Stream offset exceeds the maximum integer size supported on this platform');
}
$value = $base + $delta;
if ($value < 0) {
// A negative computed offset is a seek failure at runtime, so throw
// RuntimeException per PSR-7, unlike the precondition check above.
throw new \RuntimeException('Stream offset must be non-negative');
}
return $value;
}
/**
* @param mixed $value
*/
public static function assertEngineInteger($value, string $what): ?int
{
if ($value === false || $value === null) {
return null;
}
if (!\is_int($value) || $value < 0) {
throw new \OverflowException($what.' exceeds the maximum integer size supported on this platform');
}
return $value;
}
/**
* @param mixed $value
*/
public static function assertOptionalNonNegativeSize($value, string $name): ?int
{
if ($value === null) {
return null;
}
if (!\is_int($value) || $value < 0) {
throw new \InvalidArgumentException($name.' must be a non-negative integer or null');
}
return $value;
}
/**
* @param mixed $value
*/
public static function assertNonNegativeInteger($value, string $name): int
{
if (!\is_int($value) || $value < 0) {
throw new \InvalidArgumentException($name.' must be a non-negative integer');
}
return $value;
}
/**
* @param mixed $value
*/
public static function assertLimitInteger($value, string $name): int
{
if (!\is_int($value) || $value < -1) {
throw new \InvalidArgumentException($name.' must be -1 or a non-negative integer');
}
return $value;
}
}

View File

@@ -13,17 +13,13 @@ use Psr\Http\Message\StreamInterface;
final class LazyOpenStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var string */
private $filename;
private string $filename;
/** @var string */
private $mode;
private string $mode;
/**
* @var StreamInterface
*/
private $stream;
private StreamInterface $stream;
/**
* @param string $filename File to lazily open
@@ -39,6 +35,13 @@ final class LazyOpenStream implements StreamInterface
unset($this->stream);
}
public function __unserialize(array $data): void
{
$this->stream = new BufferStream();
throw new \LogicException(static::class.' should never be unserialized');
}
/**
* Creates the underlying stream lazily when required.
*/

View File

@@ -12,15 +12,15 @@ use Psr\Http\Message\StreamInterface;
final class LimitStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var int Offset to start reading from */
private $offset;
private int $offset;
/** @var int Limit the number of bytes that can be read */
private $limit;
private int $limit;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
/**
* @param StreamInterface $stream Stream to wrap
@@ -51,7 +51,7 @@ final class LimitStream implements StreamInterface
return false;
}
return $this->stream->tell() >= $this->offset + $this->limit;
return $this->stream->tell() >= Integers::add($this->offset, $this->limit);
}
/**
@@ -75,26 +75,8 @@ final class LimitStream implements StreamInterface
/**
* Allow for a bounded seek on the read limited stream
*/
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
if ($whence !== SEEK_SET || $offset < 0) {
throw new \RuntimeException(sprintf(
'Cannot seek to offset %s with whence %s',
@@ -103,11 +85,12 @@ final class LimitStream implements StreamInterface
));
}
$offset += $this->offset;
$offset = Integers::add($this->offset, $offset);
if ($this->limit !== -1) {
if ($offset > $this->offset + $this->limit) {
$offset = $this->offset + $this->limit;
$upperBound = Integers::add($this->offset, $this->limit);
if ($offset > $upperBound) {
$offset = $upperBound;
}
}
@@ -131,17 +114,48 @@ final class LimitStream implements StreamInterface
*/
public function setOffset(int $offset): void
{
$offset = Integers::assertNonNegativeInteger($offset, 'Offset');
$current = $this->stream->tell();
if ($current !== $offset) {
// If the stream cannot seek to the offset position, then read to it
if ($this->stream->isSeekable()) {
$this->stream->seek($offset);
} elseif ($current > $offset) {
throw new \RuntimeException("Could not seek to stream offset $offset");
} else {
$this->stream->read($offset - $current);
if ($current === $offset) {
$this->offset = $offset;
return;
}
// If the stream cannot seek to the offset position, then read to it.
if ($this->stream->isSeekable()) {
$this->stream->seek($offset);
$this->offset = $offset;
return;
}
if ($current > $offset) {
throw new \RuntimeException("Could not seek to stream offset $offset");
}
while ($current < $offset) {
if ($this->stream->eof()) {
$this->offset = $current;
return;
}
$result = $this->stream->read($offset - $current);
if ($result === '') {
if ($this->stream->eof()) {
$this->offset = $current;
return;
}
throw new \RuntimeException("Could not seek to stream offset $offset");
}
$current = Integers::add($current, strlen($result));
}
$this->offset = $offset;
@@ -156,18 +170,13 @@ final class LimitStream implements StreamInterface
*/
public function setLimit(int $limit): void
{
$this->limit = $limit;
$this->limit = Integers::assertLimitInteger($limit, 'Limit');
}
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
if ($this->limit === -1) {
@@ -176,7 +185,7 @@ final class LimitStream implements StreamInterface
// Check if the current position is less than the total allowed
// bytes + original offset
$remaining = ($this->offset + $this->limit) - $this->stream->tell();
$remaining = Integers::add($this->offset, $this->limit) - $this->stream->tell();
if ($remaining > 0) {
// Only return the amount of requested data, ensuring that the byte
// limit is not exceeded

View File

@@ -7,9 +7,16 @@ namespace GuzzleHttp\Psr7;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
final class Message
{
private const DEFAULT_BODY_SUMMARY_TRUNCATE_AT = 120;
private function __construct()
{
}
/**
* Returns the string representation of an HTTP message.
*
@@ -22,7 +29,7 @@ final class Message
.$message->getRequestTarget(), " \n\r\t\0\x0B")
.' HTTP/'.$message->getProtocolVersion();
if (!$message->hasHeader('host')) {
$msg .= "\r\nHost: ".$message->getUri()->getHost();
$msg .= "\r\nHost: ".self::hostHeaderFromUri($message->getUri());
}
} elseif ($message instanceof ResponseInterface) {
$msg = 'HTTP/'.$message->getProtocolVersion().' '
@@ -45,16 +52,39 @@ final class Message
return "{$msg}\r\n\r\n".$message->getBody();
}
private static function hostHeaderFromUri(UriInterface $uri): string
{
$host = $uri->getHost();
if ($host === '') {
return '';
}
Uri::assertValidHost($host);
if (($port = $uri->getPort()) !== null) {
$host .= ':'.$port;
}
return $host;
}
/**
* Get a short summary of the message body.
*
* Will return `null` if the response is not printable.
*
* Reads seekable bodies from the beginning and restores the original cursor
* position before returning. Pass `null` for `$truncateAt` to use the
* default summary length.
*
* @param MessageInterface $message The message to get the body summary
* @param int $truncateAt The maximum allowed size of the summary
* @param int|null $truncateAt Maximum allowed size of the summary
*/
public static function bodySummary(MessageInterface $message, int $truncateAt = 120): ?string
public static function bodySummary(MessageInterface $message, ?int $truncateAt = null): ?string
{
$truncateAt ??= self::DEFAULT_BODY_SUMMARY_TRUNCATE_AT;
$body = $message->getBody();
if (!$body->isSeekable() || !$body->isReadable()) {
@@ -67,19 +97,23 @@ final class Message
return null;
}
$body->rewind();
$summary = $body->read($truncateAt);
$position = $body->tell();
if ($size > $truncateAt) {
if (preg_match('//u', $summary) !== 1) {
$summary = self::trimTrailingIncompleteUtf8Character($summary, $body->read(3));
try {
$body->rewind();
$summary = $body->read($truncateAt);
if ($size > $truncateAt) {
if (preg_match('//u', $summary) !== 1) {
$summary = self::trimTrailingIncompleteUtf8Character($summary, $body->read(3));
}
$summary .= ' (truncated...)';
}
$summary .= ' (truncated...)';
} finally {
$body->seek($position);
}
$body->rewind();
// Matches any printable character, including unicode characters:
// letters, marks, numbers, punctuation, spacing, and separators.
if (preg_match('/[^\pL\pM\pN\pP\pS\pZ\n\r\t]/u', $summary) !== 0) {
@@ -165,184 +199,49 @@ final class Message
/**
* Parses an HTTP message into an associative array.
*
* The array contains the "start-line" key containing the start line of
* the message, "headers" key containing an associative array of header
* array values, and a "body" key containing the body of the message.
* The array contains the `start-line` key containing the start line of the
* message, `headers` key containing an associative array of header array
* values, and a `body` key containing the body of the message.
*
* @param string $message HTTP request or response to parse.
*/
public static function parseMessage(string $message): array
{
if (!$message) {
throw new \InvalidArgumentException('Invalid message');
}
$message = ltrim($message, "\r\n");
$messageParts = preg_split("/\r?\n\r?\n/", $message, 2);
if ($messageParts === false) {
throw new \RuntimeException('Unable to split HTTP message: '.preg_last_error_msg());
}
if (count($messageParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing header delimiter');
}
[$rawHeaders, $body] = $messageParts;
$rawHeaders .= "\r\n"; // Put back the delimiter we split previously
$headerParts = preg_split("/\r?\n/", $rawHeaders, 2);
if ($headerParts === false) {
throw new \RuntimeException('Unable to split HTTP message headers: '.preg_last_error_msg());
}
if (count($headerParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing status line');
}
[$startLine, $rawHeaders] = $headerParts;
$versionMatch = preg_match("/(?:^HTTP\/|^[A-Z]+ \S+ HTTP\/)(\d+(?:\.\d+)?)/i", $startLine, $matches);
if ($versionMatch === false) {
throw new \RuntimeException('Unable to parse HTTP start line: '.preg_last_error_msg());
}
if ($versionMatch === 1 && $matches[1] === '1.0') {
// Header folding is deprecated for HTTP/1.1, but allowed in HTTP/1.0
$rawHeaders = preg_replace(Rfc7230::HEADER_FOLD_REGEX, ' ', $rawHeaders);
if ($rawHeaders === null) {
throw new \RuntimeException('Unable to unfold HTTP headers: '.preg_last_error_msg());
}
}
/** @var array[] $headerLines */
$count = preg_match_all(Rfc7230::HEADER_REGEX, $rawHeaders, $headerLines, PREG_SET_ORDER);
if ($count === false) {
throw new \RuntimeException('Unable to parse HTTP headers: '.preg_last_error_msg());
}
// If these aren't the same, then one line didn't match and there's an invalid header.
if ($count !== substr_count($rawHeaders, "\n")) {
// Folding is deprecated, see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4
$hasFoldedHeader = preg_match(Rfc7230::HEADER_FOLD_REGEX, $rawHeaders);
if ($hasFoldedHeader === false) {
throw new \RuntimeException('Unable to inspect HTTP header folding: '.preg_last_error_msg());
}
if ($hasFoldedHeader === 1) {
throw new \InvalidArgumentException('Invalid header syntax: Obsolete line folding');
}
throw new \InvalidArgumentException('Invalid header syntax');
}
$headers = [];
foreach ($headerLines as $headerLine) {
$headers[$headerLine[1]][] = $headerLine[2];
}
return [
'start-line' => $startLine,
'headers' => $headers,
'body' => $body,
];
return MessageParser::parseMessage($message);
}
/**
* Constructs a URI for an HTTP request message.
*
* The URI is composed from the start-line path and the `Host` header, using
* `https` when the host's port is `443` and `http` otherwise. Without a
* `Host` header, only the path is returned, with extra leading slashes
* collapsed so an origin-form target cannot be parsed as a network-path
* reference with its own authority. An `InvalidArgumentException` is thrown
* when the `Host` header is invalid.
*
* @param string $path Path from the start-line
* @param array $headers Array of headers (each value an array).
*/
public static function parseRequestUri(string $path, array $headers): string
{
$host = self::getHostFromHeaders($headers);
// If no host is found, then a full URI cannot be constructed.
// Collapse leading slashes so an origin-form target cannot be
// parsed as a network-path reference with its own authority.
if ($host === null) {
return self::normalizePathForOriginForm($path);
}
$scheme = substr($host, -4) === ':443' ? 'https' : 'http';
return $scheme.'://'.$host.'/'.ltrim($path, '/');
}
private static function normalizePathForOriginForm(string $path): string
{
if (0 === strpos($path, '//')) {
return '/'.ltrim($path, '/');
}
return $path;
}
/**
* @param array $headers Array of headers (each value an array).
*/
private static function getHostFromHeaders(array $headers): ?string
{
$hostKey = array_filter(array_keys($headers), function ($k) {
// Numeric array keys are converted to int by PHP.
$k = (string) $k;
return Utils::asciiToLower($k) === 'host';
});
if (!$hostKey) {
return null;
}
$host = $headers[reset($hostKey)][0];
if (!is_string($host) || Rfc7230::parseHostHeader($host) === null) {
throw new \InvalidArgumentException('Invalid request string');
}
return $host;
return MessageParser::parseRequestUri($path, $headers);
}
/**
* Parses a request message string into a request object.
*
* The request-target must be in origin form, absolute form (without a
* userinfo component), authority form (`CONNECT`), or asterisk form
* (`OPTIONS`), and any `Host` header must be a single valid value;
* otherwise an `InvalidArgumentException` is thrown. Non-origin-form
* targets are preserved on the returned request via `withRequestTarget()`.
*
* @param string $message Request message string.
*/
public static function parseRequest(string $message): RequestInterface
{
$data = self::parseMessage($message);
if (strpbrk($data['start-line'], "\r\n") !== false) {
throw new \InvalidArgumentException('Invalid request string');
}
$matches = [];
$requestStartLineMatch = preg_match('/^[\S]+\s+([a-zA-Z]+:\/\/|\/).*/', $data['start-line'], $matches);
if ($requestStartLineMatch === false) {
throw new \RuntimeException('Unable to parse request start line: '.preg_last_error_msg());
}
if ($requestStartLineMatch === 0) {
throw new \InvalidArgumentException('Invalid request string');
}
$parts = explode(' ', $data['start-line'], 3);
$version = isset($parts[2]) ? explode('/', $parts[2])[1] : '1.1';
$request = new Request(
$parts[0],
$matches[1] === '/' ? self::parseRequestUri($parts[1], $data['headers']) : $parts[1],
$data['headers'],
$data['body'],
$version
);
return $matches[1] === '/' ? $request : $request->withRequestTarget($parts[1]);
return MessageParser::parseRequest($message);
}
/**
@@ -352,31 +251,6 @@ final class Message
*/
public static function parseResponse(string $message): ResponseInterface
{
$data = self::parseMessage($message);
if (strpbrk($data['start-line'], "\r\n") !== false) {
throw new \InvalidArgumentException('Invalid response string');
}
// According to https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2
// the space between status-code and reason-phrase is required. But
// browsers accept responses without space and reason as well.
$responseStartLineMatch = preg_match('/^HTTP\/.* [0-9]{3}( .*|$)/D', $data['start-line']);
if ($responseStartLineMatch === false) {
throw new \RuntimeException('Unable to parse response start line: '.preg_last_error_msg());
}
if ($responseStartLineMatch === 0) {
throw new \InvalidArgumentException('Invalid response string: '.$data['start-line']);
}
$parts = explode(' ', $data['start-line'], 3);
return new Response(
(int) $parts[1],
$data['headers'],
$data['body'],
explode('/', $parts[0])[1],
$parts[2] ?? null
);
return MessageParser::parseResponse($message);
}
}

View File

@@ -0,0 +1,363 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* @internal
*/
final class MessageParser
{
private function __construct()
{
}
public static function parseMessage(string $message): array
{
if (!$message) {
throw new \InvalidArgumentException('Invalid message');
}
$message = ltrim($message, "\r\n");
$messageParts = preg_split("/\r?\n\r?\n/", $message, 2);
if ($messageParts === false) {
throw new \RuntimeException('Unable to split HTTP message: '.preg_last_error_msg());
}
if (count($messageParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing header delimiter');
}
[$rawHeaders, $body] = $messageParts;
$rawHeaders .= "\r\n"; // Put back the delimiter we split previously
$headerParts = preg_split("/\r?\n/", $rawHeaders, 2);
if ($headerParts === false) {
throw new \RuntimeException('Unable to split HTTP message headers: '.preg_last_error_msg());
}
if (count($headerParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing status line');
}
[$startLine, $rawHeaders] = $headerParts;
$versionMatch = preg_match(
'/(?:^HTTP\/|^'.Rfc9110::TOKEN_PATTERN.' '.Rfc9112::REQUEST_TARGET_PATTERN.' HTTP\/)('.Rfc9112::PROTOCOL_VERSION_PATTERN.')/i',
$startLine,
$matches
);
if ($versionMatch === false) {
throw new \RuntimeException('Unable to parse HTTP start line: '.preg_last_error_msg());
}
if ($versionMatch === 1 && $matches[1] === '1.0') {
// Header folding is deprecated for HTTP/1.1, but allowed in HTTP/1.0
$rawHeaders = preg_replace(Rfc9112::HEADER_FOLD_REGEX, ' ', $rawHeaders);
if ($rawHeaders === null) {
throw new \RuntimeException('Unable to unfold HTTP headers: '.preg_last_error_msg());
}
}
$count = preg_match_all(Rfc9112::HEADER_REGEX, $rawHeaders, $headerLines, PREG_SET_ORDER);
/** @var list<array<int, string>> $headerLines */
if ($count === false) {
throw new \RuntimeException('Unable to parse HTTP headers: '.preg_last_error_msg());
}
// If these aren't the same, then one line didn't match and there's an invalid header.
if ($count !== substr_count($rawHeaders, "\n")) {
// Folding is deprecated, see https://datatracker.ietf.org/doc/html/rfc9112#section-5.2
$hasFoldedHeader = preg_match(Rfc9112::HEADER_FOLD_REGEX, $rawHeaders);
if ($hasFoldedHeader === false) {
throw new \RuntimeException('Unable to inspect HTTP header folding: '.preg_last_error_msg());
}
if ($hasFoldedHeader === 1) {
throw new \InvalidArgumentException('Invalid header syntax: Obsolete line folding');
}
throw new \InvalidArgumentException('Invalid header syntax');
}
$headers = [];
foreach ($headerLines as $headerLine) {
$headers[$headerLine[1]][] = $headerLine[2];
}
return [
'start-line' => $startLine,
'headers' => $headers,
'body' => $body,
];
}
public static function parseRequestUri(string $path, array $headers): string
{
$host = self::getHostFromHeaders($headers);
// If no host is found, then a full URI cannot be constructed.
// Collapse leading slashes so an origin-form target cannot be
// parsed as a network-path reference with its own authority.
if ($host === null) {
return self::normalizePathForOriginForm($path);
}
[$authorityHost, $port] = self::parseHostHeaderAuthority($host);
$scheme = $port === 443 ? 'https' : 'http';
return $scheme.'://'.self::composeAuthority($authorityHost, $port).'/'.ltrim($path, '/');
}
private static function normalizePathForOriginForm(string $path): string
{
if (str_starts_with($path, '//')) {
return '/'.ltrim($path, '/');
}
return $path;
}
/**
* @return array{0: string, 1: int|null}
*/
private static function parseHostHeaderAuthority(string $authority): array
{
$parsed = Rfc9112::parseHostHeader($authority);
if ($parsed === null) {
throw new \InvalidArgumentException('Invalid request string');
}
return $parsed;
}
private static function composeAuthority(string $host, ?int $port): string
{
return $host.($port !== null ? ':'.$port : '');
}
/**
* @param array $headers Array of headers (each value an array).
*/
private static function getHostFromHeaders(array $headers): ?string
{
$host = self::getSingleHostHeader($headers);
if ($host === null) {
return null;
}
self::parseHostHeaderAuthority($host);
return $host;
}
/**
* @param array $headers Array of headers (each value an array).
*/
private static function getSingleHostHeader(array $headers): ?string
{
$host = null;
$found = false;
foreach ($headers as $name => $values) {
if (Utils::asciiToLower((string) $name) !== 'host') {
continue;
}
if ($found || !is_array($values) || count($values) !== 1) {
throw new \InvalidArgumentException('Invalid request string');
}
$found = true;
$host = reset($values);
}
if (!$found) {
return null;
}
if (!is_string($host)) {
throw new \InvalidArgumentException('Invalid request string');
}
return $host;
}
/**
* @param array $headers Array of headers (each value an array).
*/
private static function parseRequestAuthorityUri(array $headers): string
{
$host = self::getHostFromHeaders($headers);
if ($host === null) {
return '';
}
[$authorityHost, $port] = self::parseHostHeaderAuthority($host);
$scheme = $port === 443 ? 'https' : 'http';
return $scheme.'://'.self::composeAuthority($authorityHost, $port);
}
public static function parseRequest(string $message): RequestInterface
{
$data = self::parseMessage($message);
$matches = [];
$matched = preg_match(
'/^(?P<method>'.Rfc9110::TOKEN_PATTERN.') (?P<target>'.Rfc9112::REQUEST_TARGET_PATTERN.') HTTP\/(?P<version>'.Rfc9112::PROTOCOL_VERSION_PATTERN.')$/D',
$data['start-line'],
$matches
);
if ($matched === false) {
throw new \RuntimeException('Unable to parse request start line: '.preg_last_error_msg());
}
if ($matched === 0) {
throw new \InvalidArgumentException('Invalid request string');
}
self::getHostFromHeaders($data['headers']);
if (str_starts_with($matches['target'], '/')) {
return new Request(
$matches['method'],
self::parseRequestUri($matches['target'], $data['headers']),
$data['headers'],
$data['body'],
$matches['version']
);
}
$absoluteFormUri = self::parseAbsoluteFormRequestTarget($matches['target']);
if ($absoluteFormUri !== null) {
return (new Request(
$matches['method'],
$absoluteFormUri,
$data['headers'],
$data['body'],
$matches['version']
))->withRequestTarget($matches['target']);
}
if (Rfc9112::isAsteriskFormRequestTarget($matches['method'], $matches['target'])) {
return (new Request(
$matches['method'],
self::parseRequestAuthorityUri($data['headers']),
$data['headers'],
$data['body'],
$matches['version']
))->withRequestTarget($matches['target']);
}
$connectUri = self::parseConnectAuthorityFormRequestTarget($matches['method'], $matches['target']);
if ($connectUri !== null) {
return (new Request(
$matches['method'],
$connectUri,
$data['headers'],
$data['body'],
$matches['version']
))->withRequestTarget($matches['target']);
}
throw new \InvalidArgumentException('Invalid request string');
}
private static function parseAbsoluteFormRequestTarget(string $target): ?Uri
{
if (!Rfc9112::isAbsoluteFormRequestTarget($target)) {
return null;
}
$authority = substr($target, strpos($target, '//') + 2);
$authority = substr($authority, 0, strcspn($authority, '/?#'));
// RFC 9110 deprecates userinfo in message target URIs and directs
// recipients to treat its presence as an error, since it can obscure
// the authority. Host headers and CONNECT targets already reject it.
if (str_contains($authority, '@')) {
return null;
}
try {
$uri = new Uri($target);
} catch (\InvalidArgumentException $e) {
return null;
}
if ($uri->getHost() === '') {
return null;
}
try {
self::parseHostHeaderAuthority(self::composeAuthority($uri->getHost(), $uri->getPort()));
} catch (\InvalidArgumentException $e) {
return null;
}
return $uri;
}
private static function parseConnectAuthorityFormRequestTarget(string $method, string $target): ?Uri
{
if (!Rfc9112::isConnectAuthorityFormRequestTarget($method, $target)) {
return null;
}
$parsed = Rfc9112::parseHostHeader($target);
if ($parsed === null) {
return null;
}
[$host, $port] = $parsed;
if ($port === null) {
return null;
}
try {
return new Uri('//'.self::composeAuthority($host, $port));
} catch (\InvalidArgumentException $e) {
return null;
}
}
public static function parseResponse(string $message): ResponseInterface
{
$data = self::parseMessage($message);
// According to https://datatracker.ietf.org/doc/html/rfc9112#section-4
// the space between status-code and reason-phrase is required. But
// browsers accept responses without space and reason as well.
$matched = preg_match(
'/^HTTP\/(?P<version>'.Rfc9112::PROTOCOL_VERSION_PATTERN.') (?P<status>[1-5][0-9]{2})(?: (?P<reason>'.Rfc9110::FIELD_VALUE_PATTERN.'))?$/D',
$data['start-line'],
$matches
);
if ($matched === false) {
throw new \RuntimeException('Unable to parse response start line: '.preg_last_error_msg());
}
if ($matched === 0) {
throw new \InvalidArgumentException(\sprintf('Invalid response string: %s', DiagnosticValue::escape($data['start-line'])));
}
return new Response(
(int) $matches['status'],
$data['headers'],
$data['body'],
$matches['version'],
$matches['reason'] ?? null
);
}
}

View File

@@ -13,16 +13,14 @@ use Psr\Http\Message\StreamInterface;
trait MessageTrait
{
/** @var string[][] Map of all registered headers, as original name => array of values */
private $headers = [];
private array $headers = [];
/** @var string[] Map of lowercase header name => original name at registration */
private $headerNames = [];
private array $headerNames = [];
/** @var string */
private $protocol = '1.1';
private string $protocol = '1.1';
/** @var StreamInterface|null */
private $stream;
private ?StreamInterface $stream = null;
public function getProtocolVersion(): string
{
@@ -32,17 +30,8 @@ trait MessageTrait
/**
* @return static
*/
public function withProtocolVersion($version): MessageInterface
public function withProtocolVersion(string $version): MessageInterface
{
if (!\is_string($version)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to MessageInterface::withProtocolVersion() is deprecated; guzzlehttp/psr7 3.0 requires string.',
\get_debug_type($version)
);
}
$this->assertProtocolVersion($version);
if ($this->protocol === $version) {
@@ -60,14 +49,14 @@ trait MessageTrait
return $this->headers;
}
public function hasHeader($header): bool
public function hasHeader(string $name): bool
{
return isset($this->headerNames[Utils::asciiToLower($header)]);
return isset($this->headerNames[Utils::asciiToLower($name)]);
}
public function getHeader($header): array
public function getHeader(string $name): array
{
$header = Utils::asciiToLower($header);
$header = Utils::asciiToLower($name);
if (!isset($this->headerNames[$header])) {
return [];
@@ -78,39 +67,26 @@ trait MessageTrait
return $this->headers[$header];
}
public function getHeaderLine($header): string
public function getHeaderLine(string $name): string
{
return implode(', ', $this->getHeader($header));
return implode(', ', $this->getHeader($name));
}
/**
* @return static
*/
public function withHeader($header, $value): MessageInterface
public function withHeader(string $name, $value): MessageInterface
{
$this->assertHeader($header);
$values = \is_array($value) ? $value : [$value];
foreach ($values as $item) {
if (!\is_string($item) && (\is_scalar($item) || $item === null)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to MessageInterface::withHeader() is deprecated; guzzlehttp/psr7 3.0 requires string|string[].',
\get_debug_type($item)
);
break;
}
}
$this->assertHeader($name);
$value = $this->normalizeHeaderValue($value);
$normalized = Utils::asciiToLower($header);
$normalized = Utils::asciiToLower($name);
$new = clone $this;
if (isset($new->headerNames[$normalized])) {
unset($new->headers[$new->headerNames[$normalized]]);
}
$new->headerNames[$normalized] = $header;
$new->headers[$header] = $value;
$new->headerNames[$normalized] = $name;
$new->headers[$name] = $value;
return $new;
}
@@ -118,32 +94,19 @@ trait MessageTrait
/**
* @return static
*/
public function withAddedHeader($header, $value): MessageInterface
public function withAddedHeader(string $name, $value): MessageInterface
{
$this->assertHeader($header);
$values = \is_array($value) ? $value : [$value];
foreach ($values as $item) {
if (!\is_string($item) && (\is_scalar($item) || $item === null)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to MessageInterface::withAddedHeader() is deprecated; guzzlehttp/psr7 3.0 requires string|string[].',
\get_debug_type($item)
);
break;
}
}
$this->assertHeader($name);
$value = $this->normalizeHeaderValue($value);
$normalized = Utils::asciiToLower($header);
$normalized = Utils::asciiToLower($name);
$new = clone $this;
if (isset($new->headerNames[$normalized])) {
$header = $this->headerNames[$normalized];
$new->headers[$header] = array_merge($this->headers[$header], $value);
$name = $this->headerNames[$normalized];
$new->headers[$name] = array_merge($this->headers[$name], $value);
} else {
$new->headerNames[$normalized] = $header;
$new->headers[$header] = $value;
$new->headerNames[$normalized] = $name;
$new->headers[$name] = $value;
}
return $new;
@@ -152,18 +115,18 @@ trait MessageTrait
/**
* @return static
*/
public function withoutHeader($header): MessageInterface
public function withoutHeader(string $name): MessageInterface
{
$normalized = Utils::asciiToLower($header);
$normalized = Utils::asciiToLower($name);
if (!isset($this->headerNames[$normalized])) {
return $this;
}
$header = $this->headerNames[$normalized];
$name = $this->headerNames[$normalized];
$new = clone $this;
unset($new->headers[$header], $new->headerNames[$normalized]);
unset($new->headers[$name], $new->headerNames[$normalized]);
return $new;
}
@@ -203,20 +166,6 @@ trait MessageTrait
$header = (string) $header;
$this->assertHeader($header);
$values = \is_array($value) ? $value : [$value];
foreach ($values as $item) {
if (!\is_string($item) && (\is_scalar($item) || $item === null)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to %s::__construct() is deprecated; guzzlehttp/psr7 3.0 requires string|string[].',
\get_debug_type($item),
static::class
);
break;
}
}
$value = $this->normalizeHeaderValue($value);
$normalized = Utils::asciiToLower($header);
if (isset($this->headerNames[$normalized])) {
@@ -237,11 +186,7 @@ trait MessageTrait
private function normalizeHeaderValue($value): array
{
if (is_array($value) && $value === []) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing an empty array as a header value is deprecated; guzzlehttp/psr7 3.0 rejects empty header value arrays.'
);
throw new \InvalidArgumentException('Header value must be a non-empty array or string.');
}
if (!is_array($value)) {
@@ -263,25 +208,19 @@ trait MessageTrait
*
* @return string[] Trimmed header values
*
* @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5
*/
private function trimAndValidateHeaderValues(array $values): array
{
return array_map(function ($value) {
if (!is_scalar($value) && null !== $value) {
return array_map(function ($value): string {
if (!is_string($value)) {
throw new \InvalidArgumentException(sprintf(
'Header value must be scalar or null but %s provided.',
is_object($value) ? get_class($value) : gettype($value)
'Header value must be a string or array of strings but %s provided.',
\get_debug_type($value)
));
}
// Convert non-finite floats explicitly, as implicit coercion of
// NAN emits a warning on PHP 8.5.
if (is_float($value) && !is_finite($value)) {
$value = is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
}
$trimmed = trim((string) $value, " \t");
$trimmed = trim($value, " \t");
$this->assertValue($trimmed);
return $trimmed;
@@ -289,45 +228,24 @@ trait MessageTrait
}
/**
* @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
*
* @param mixed $header
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.1
*/
private function assertHeader($header): void
private function assertHeader(string $header): void
{
if (!is_string($header)) {
throw new \InvalidArgumentException(sprintf(
'Header name must be a string but %s provided.',
is_object($header) ? get_class($header) : gettype($header)
));
if (!Rfc9110::isToken($header)) {
throw new \InvalidArgumentException(sprintf('Invalid header name: %s', DiagnosticValue::escape($header)));
}
}
if (!preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $header)) {
throw new \InvalidArgumentException(
sprintf('"%s" is not valid header name.', $header)
);
private function assertProtocolVersion(string $version): void
{
if (!Rfc9112::isValidProtocolVersion($version)) {
throw new \InvalidArgumentException('Protocol version must be a valid HTTP version number.');
}
}
/**
* @param mixed $version
*/
private function assertProtocolVersion($version): void
{
if (is_string($version)) {
$this->assertNoLineSeparators($version, 'Protocol version');
}
}
private function assertNoLineSeparators(string $value, string $field): void
{
if (strpbrk($value, "\r\n") !== false) {
throw new \InvalidArgumentException($field.' must not contain CR or LF characters.');
}
}
/**
* @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5
*
* field-value = *( field-content / obs-fold )
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
@@ -338,21 +256,20 @@ trait MessageTrait
*/
private function assertValue(string $value): void
{
// The regular expression intentionally does not support the obs-fold production, because as
// per RFC 7230#3.2.4:
// The regular expression intentionally does not support the obs-fold
// production, because as per RFC 9112#5.2:
//
// A sender MUST NOT generate a message that includes
// line folding (i.e., that has any field-value that contains a match to
// the obs-fold rule) unless the message is intended for packaging
// within the message/http media type.
// A sender MUST NOT generate a message that includes line folding
// (i.e., that has any field-value that contains a match to the obs-fold
// rule) unless the message is intended for packaging within the
// message/http media type.
//
// Clients must not send a request with line folding and a server sending folded headers is
// likely very rare. Line folding is a fairly obscure feature of HTTP/1.1 and thus not accepting
// folding is not likely to break any legitimate use case.
if (!preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value)) {
throw new \InvalidArgumentException(
sprintf('"%s" is not valid header value.', $value)
);
// Clients must not send a request with line folding and a server
// sending folded headers is likely very rare. Line folding is a fairly
// obscure feature of HTTP/1.1 and thus not accepting folding is not
// likely to break any legitimate use case.
if (!Rfc9110::isFieldValue($value)) {
throw new \InvalidArgumentException(sprintf('Invalid header value: %s', DiagnosticValue::escape($value)));
}
}
}

View File

@@ -6,6 +6,10 @@ namespace GuzzleHttp\Psr7;
final class MimeType
{
private function __construct()
{
}
private const MIME_TYPES = [
'123' => 'application/vnd.lotus-1-2-3',
'1km' => 'application/vnd.1000minds.decision-model+xml',
@@ -1284,7 +1288,7 @@ final class MimeType
];
/**
* Determines the mimetype of a file by looking at its extension.
* Determines the MIME type of a file by looking at its extension.
*
* @see https://raw.githubusercontent.com/jshttp/mime-db/master/db.json
*/
@@ -1294,7 +1298,7 @@ final class MimeType
}
/**
* Maps a file extensions to a mimetype.
* Maps a file extension to a MIME type.
*
* @see https://raw.githubusercontent.com/jshttp/mime-db/master/db.json
*/

View File

@@ -13,12 +13,11 @@ use Psr\Http\Message\StreamInterface;
final class MultipartStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var string */
private $boundary;
private string $boundary;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
private const BOUNDARY_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'()+_,-./:=? ";
@@ -43,15 +42,11 @@ final class MultipartStream implements StreamInterface
*/
public function __construct(array $elements = [], ?string $boundary = null)
{
if ($boundary !== null && !self::isValidBoundary($boundary)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing an invalid multipart boundary to MultipartStream::__construct() is deprecated; guzzlehttp/psr7 3.0 rejects invalid multipart boundaries.'
);
if ($boundary !== null) {
self::validateBoundary($boundary);
}
$this->boundary = $boundary ?: bin2hex(random_bytes(20));
$this->boundary = $boundary ?? bin2hex(random_bytes(20));
$this->stream = $this->createStream($elements);
}
@@ -75,10 +70,14 @@ final class MultipartStream implements StreamInterface
$str = '';
foreach ($headers as $key => $value) {
$key = (string) $key;
self::validatePartHeaderName($key);
self::validatePartHeaderValue($value);
$str .= "{$key}: {$value}\r\n";
}
return "--{$this->boundary}\r\n".trim($str, " \n\r\t\0\x0B")."\r\n\r\n";
return "--{$this->boundary}\r\n".rtrim($str, "\r\n")."\r\n\r\n";
}
/**
@@ -129,18 +128,10 @@ final class MultipartStream implements StreamInterface
if (is_scalar($contents) && !is_string($contents)) {
// Multipart field values are byte strings on the wire, so finite
// numeric and boolean field values are cast to string here rather
// than tripping streamFor()'s non-string-scalar deprecation. Non-finite
// floats are deprecated and normalized here too, so the deprecation is
// reported against MultipartStream instead of transitively through
// streamFor().
// than rejected by streamFor(). Non-finite floats cannot be
// represented and are rejected.
if (is_float($contents) && !is_finite($contents)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.12',
'Passing a non-finite float as multipart contents is deprecated; guzzlehttp/psr7 3.0 rejects non-finite floats.'
);
$contents = is_nan($contents) ? 'NAN' : ($contents > 0 ? 'INF' : '-INF');
throw new \InvalidArgumentException('Cannot create a stream from a non-finite float.');
}
$contents = (string) $contents;
@@ -149,7 +140,7 @@ final class MultipartStream implements StreamInterface
if (empty($element['filename'])) {
$uri = $element['contents']->getMetadata('uri');
if ($uri && \is_string($uri) && \substr($uri, 0, 6) !== 'php://' && \substr($uri, 0, 7) !== 'data://') {
if ($uri && \is_string($uri) && !str_starts_with($uri, 'php://') && !str_starts_with($uri, 'data://')) {
$element['filename'] = $uri;
}
}
@@ -196,21 +187,14 @@ final class MultipartStream implements StreamInterface
// Set a default content-disposition header if one was no provided
$disposition = self::getHeader($headers, 'content-disposition');
if (!$disposition) {
$escapedName = self::escapeContentDispositionParameter($name);
$headers['Content-Disposition'] = ($filename === '0' || $filename)
? sprintf(
'form-data; name="%s"; filename="%s"',
$name,
basename($filename)
$escapedName,
self::escapeContentDispositionParameter(basename($filename))
)
: "form-data; name=\"{$name}\"";
}
// Set a default content-length header if one was no provided
$length = self::getHeader($headers, 'content-length');
if (!$length) {
if ($length = $stream->getSize()) {
$headers['Content-Length'] = (string) $length;
}
: sprintf('form-data; name="%s"', $escapedName);
}
// Set a default Content-Type if one was not supplied
@@ -237,15 +221,17 @@ final class MultipartStream implements StreamInterface
return null;
}
private static function isValidBoundary(string $boundary): bool
private static function validateBoundary(string $boundary): void
{
$length = strlen($boundary);
if ($length < 1 || $length > 70 || $boundary[$length - 1] === ' ') {
return false;
throw new \InvalidArgumentException('Invalid multipart boundary.');
}
return strspn($boundary, self::BOUNDARY_CHARS) === $length;
if (strspn($boundary, self::BOUNDARY_CHARS) !== $length) {
throw new \InvalidArgumentException('Invalid multipart boundary.');
}
}
/**
@@ -258,27 +244,15 @@ final class MultipartStream implements StreamInterface
$normalized = [];
foreach ($headers as $key => $value) {
self::deprecateInvalidPartHeaderName((string) $key);
$key = (string) $key;
self::validatePartHeaderName($key);
if (!is_string($value)) {
if (!is_scalar($value) && $value !== null && !(is_object($value) && method_exists($value, '__toString'))) {
throw new \InvalidArgumentException(sprintf(
'Multipart part header value must be a string or stringable value but %s provided.',
\get_debug_type($value)
));
}
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s as a multipart part header value is deprecated; guzzlehttp/psr7 3.0 requires string multipart part header values.',
\get_debug_type($value)
);
throw new \InvalidArgumentException('Multipart part header value must be a string.');
}
$value = (string) $value;
self::deprecateInvalidPartHeaderValue($value);
self::validatePartHeaderValue($value);
$normalized[$key] = $value;
}
@@ -286,25 +260,23 @@ final class MultipartStream implements StreamInterface
return $normalized;
}
private static function deprecateInvalidPartHeaderName(string $name): void
private static function validatePartHeaderName(string $name): void
{
if (!preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $name)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing an invalid multipart part header name to MultipartStream is deprecated; guzzlehttp/psr7 3.0 rejects invalid multipart part header names.'
);
if (!Rfc9110::isToken($name)) {
throw new \InvalidArgumentException(sprintf('Invalid multipart part header name: %s', DiagnosticValue::escape($name)));
}
}
private static function deprecateInvalidPartHeaderValue(string $value): void
private static function validatePartHeaderValue(string $value): void
{
if (!preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing an invalid multipart part header value to MultipartStream is deprecated; guzzlehttp/psr7 3.0 rejects invalid multipart part header values.'
);
if (!Rfc9110::isFieldValue($value)) {
throw new \InvalidArgumentException(sprintf('Invalid multipart part header value: %s', DiagnosticValue::escape($value)));
}
}
private static function escapeContentDispositionParameter(string $value): string
{
// Match WHATWG browser multipart/form-data behavior: escape CR, LF, and DQUOTE only.
return str_replace(["\r", "\n", '"'], ['%0D', '%0A', '%22'], $value);
}
}

View File

@@ -12,30 +12,12 @@ use Psr\Http\Message\StreamInterface;
final class NoSeekStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
throw new \RuntimeException('Cannot seek a NoSeekStream');
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
trait NonSerializableStreamTrait
{
public function __serialize(): array
{
throw new \LogicException(static::class.' should never be serialized');
}
public function __unserialize(array $data): void
{
throw new \LogicException(static::class.' should never be unserialized');
}
}

View File

@@ -13,38 +13,37 @@ use Psr\Http\Message\StreamInterface;
* number of bytes to read to the callable. The callable can choose to ignore
* this value and return fewer or more bytes than requested. Any extra data
* returned by the callable is buffered internally until drained using the
* read() function of the PumpStream. The callable MUST return false or null
* when there is no more data to read.
* read() function of the PumpStream. The callable MUST return a non-empty
* string when data is available, or false or null when there is no more data
* to read.
*
* Userland callables that declare no parameters are tolerated by PHP, but
* length-aware callables remain the recommended formal shape.
*/
final class PumpStream implements StreamInterface
{
use NonSerializableStreamTrait;
/** @var callable|null */
private $source;
/** @var int|null */
private $size;
private ?int $size;
/** @var int */
private $tellPos = 0;
private int $tellPos = 0;
/** @var array */
private $metadata;
private array $metadata;
/** @var BufferStream */
private $buffer;
private BufferStream $buffer;
/**
* @param (callable(): (string|false|null))|(callable(int): (string|false|null)) $source Source of the stream data. The callable receives
* the suggested number of bytes to read, may ignore
* that value, and may return fewer or more bytes.
* Extra bytes are buffered. The callable MUST return
* a string when called, or false|null on error or EOF.
* Userland callables that declare no parameters are
* tolerated by PHP, but length-aware callables remain
* the recommended formal shape.
* a non-empty string when producing data, or false|null
* on error or EOF. Userland callables that declare no
* parameters are tolerated by PHP, but length-aware
* callables remain the recommended formal shape.
* @param array{size?: int, metadata?: array} $options Stream options:
* - metadata: Hash of metadata to use with stream.
* - size: Size of the stream, if known.
@@ -52,23 +51,25 @@ final class PumpStream implements StreamInterface
public function __construct(callable $source, array $options = [])
{
$this->source = $source;
$this->size = $options['size'] ?? null;
$this->size = Integers::assertOptionalNonNegativeSize($options['size'] ?? null, 'Stream size');
$this->metadata = $options['metadata'] ?? [];
$this->buffer = new BufferStream();
}
public function __unserialize(array $data): void
{
$this->source = null;
$this->size = null;
$this->tellPos = 0;
$this->metadata = [];
$this->buffer = new BufferStream();
throw new \LogicException(static::class.' should never be unserialized');
}
public function __toString(): string
{
try {
return Utils::copyToString($this);
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
}
return Utils::copyToString($this);
}
public function close(): void
@@ -80,6 +81,7 @@ final class PumpStream implements StreamInterface
{
$this->tellPos = 0;
$this->source = null;
$this->buffer->close();
return null;
}
@@ -109,26 +111,8 @@ final class PumpStream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
throw new \RuntimeException('Cannot seek a PumpStream');
}
@@ -137,17 +121,8 @@ final class PumpStream implements StreamInterface
return false;
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
throw new \RuntimeException('Cannot write to a PumpStream');
}
@@ -156,56 +131,35 @@ final class PumpStream implements StreamInterface
return true;
}
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
$bufferLength = $this->buffer->getSize() ?? 0;
if ($length > $bufferLength) {
$this->pump($length - $bufferLength);
}
$data = $this->buffer->read($length);
$readLen = strlen($data);
$this->tellPos += $readLen;
$remaining = $length - $readLen;
if ($remaining) {
$this->pump($remaining);
$data .= $this->buffer->read($remaining);
$this->tellPos += strlen($data) - $readLen;
}
$this->tellPos = Integers::add($this->tellPos, strlen($data));
return $data;
}
public function getContents(): string
{
$result = '';
while (!$this->eof()) {
$result .= $this->read(1000000);
}
return $result;
return Utils::copyToString($this);
}
/**
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
if ($key !== null && !\is_string($key)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.',
\get_debug_type($key)
);
}
if (!$key) {
if ($key === null) {
return $this->metadata;
}
@@ -216,12 +170,18 @@ final class PumpStream implements StreamInterface
{
if ($this->source !== null) {
do {
/** @var string|false|null $data */
$data = ($this->source)($length);
if ($data === false || $data === null) {
$this->source = null;
return;
}
if ($data === '') {
throw new \RuntimeException('PumpStream source returned an empty string');
}
$this->buffer->write($data);
$length -= strlen($data);
} while ($length > 0);

View File

@@ -6,13 +6,17 @@ namespace GuzzleHttp\Psr7;
final class Query
{
private function __construct()
{
}
/**
* Parse a query string into an associative array.
*
* If multiple values are found for the same key, the value of that key
* value pair will become an array. This function does not parse nested
* PHP style arrays into an associative array (e.g., `foo[a]=1&foo[b]=2`
* will be parsed into `['foo[a]' => '1', 'foo[b]' => '2'])`.
* If multiple values are found for the same key, the value of that
* key-value pair becomes an array. This function does not parse nested PHP
* style arrays into an associative array. For example, `foo[a]=1&foo[b]=2`
* will be parsed into `['foo[a]' => '1', 'foo[b]' => '2']`.
*
* @param string $str Query string to parse
* @param int|bool $urlEncoding How the query string is encoded
@@ -26,15 +30,19 @@ final class Query
}
if ($urlEncoding === true) {
$decoder = function ($value) {
return rawurldecode(str_replace('+', ' ', (string) $value));
$decoder = function (string $value): string {
return \rawurldecode(str_replace('+', ' ', $value));
};
} elseif ($urlEncoding === PHP_QUERY_RFC3986) {
$decoder = 'rawurldecode';
$decoder = static function (string $value): string {
return \rawurldecode($value);
};
} elseif ($urlEncoding === PHP_QUERY_RFC1738) {
$decoder = 'urldecode';
$decoder = static function (string $value): string {
return \urldecode($value);
};
} else {
$decoder = function ($str) {
$decoder = function (string $str): string {
return $str;
};
}
@@ -57,11 +65,11 @@ final class Query
}
/**
* Build a query string from an array of key value pairs.
* Build a query string from an array of key-value pairs.
*
* This function can use the return value of `parse()` to build a query
* string. This function does not modify the provided keys when an array is
* encountered (like `http_build_query()` would).
* encountered, unlike `http_build_query()`.
*
* @param array $params Query string parameters.
* @param int|false $encoding Set to false to not encode,
@@ -82,31 +90,33 @@ final class Query
return $str;
};
} elseif ($encoding === PHP_QUERY_RFC3986) {
$encoder = 'rawurlencode';
$encoder = static function (string $value): string {
return \rawurlencode($value);
};
} elseif ($encoding === PHP_QUERY_RFC1738) {
$encoder = 'urlencode';
$encoder = static function (string $value): string {
return \urlencode($value);
};
} else {
throw new \InvalidArgumentException('Invalid type');
}
$castBool = $treatBoolsAsInts ? static function ($v) { return (int) $v; } : static function ($v) { return $v ? 'true' : 'false'; };
$qs = '';
foreach ($params as $k => $v) {
$k = $encoder((string) $k);
if (!is_array($v)) {
$qs .= $k;
$v = is_bool($v) ? $castBool($v) : self::normalizeNonFiniteFloat($v);
$v = self::normalizeValue($v, $treatBoolsAsInts);
if ($v !== null) {
$qs .= '='.$encoder((string) $v);
$qs .= '='.$encoder($v);
}
$qs .= '&';
} else {
foreach ($v as $vv) {
$qs .= $k;
$vv = is_bool($vv) ? $castBool($vv) : self::normalizeNonFiniteFloat($vv);
$vv = self::normalizeValue($vv, $treatBoolsAsInts);
if ($vv !== null) {
$qs .= '='.$encoder((string) $vv);
$qs .= '='.$encoder($vv);
}
$qs .= '&';
}
@@ -117,25 +127,30 @@ final class Query
}
/**
* Converts non-finite floats to the strings PHP coerces them to, as
* implicit coercion of NAN emits a warning on PHP 8.5.
*
* @param mixed $value
*
* @return mixed
*/
private static function normalizeNonFiniteFloat($value)
private static function normalizeValue($value, bool $treatBoolsAsInts): ?string
{
if (is_float($value) && !is_finite($value)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.12',
'Passing a non-finite float to Query::build() is deprecated; guzzlehttp/psr7 3.0 rejects non-finite floats.'
);
return is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
if ($value === null) {
return null;
}
return $value;
if (is_bool($value)) {
return $treatBoolsAsInts ? (string) (int) $value : ($value ? 'true' : 'false');
}
if (is_float($value) && !is_finite($value)) {
throw new \InvalidArgumentException('Query string values must be finite; non-finite floats are not supported.');
}
if (is_scalar($value)) {
return (string) $value;
}
if (is_object($value) && method_exists($value, '__toString')) {
return $value->__toString();
}
throw new \InvalidArgumentException('Query string values must be scalar, null, or stringable objects');
}
}

View File

@@ -16,14 +16,11 @@ class Request implements RequestInterface
{
use MessageTrait;
/** @var string */
private $method;
private string $method;
/** @var string|null */
private $requestTarget;
private ?string $requestTarget = null;
/** @var UriInterface */
private $uri;
private UriInterface $uri;
/**
* @param string $method HTTP method
@@ -45,9 +42,9 @@ class Request implements RequestInterface
if (!$uri instanceof UriInterface) {
$uri = new Uri($uri);
}
self::getRequestTargetFromUri($uri);
self::warnOnMethodCasingChange($method);
$this->method = Utils::asciiToUpper($method);
$this->method = $method;
$this->uri = $uri;
$this->setHeaders($headers);
$this->protocol = $version;
@@ -67,30 +64,12 @@ class Request implements RequestInterface
return $this->requestTarget;
}
$target = $this->uri->getPath();
if ($target === '') {
$target = '/';
}
if ($this->uri->getQuery() != '') {
$target .= '?'.$this->uri->getQuery();
}
return $target;
return self::getRequestTargetFromUri($this->uri);
}
public function withRequestTarget($requestTarget): RequestInterface
public function withRequestTarget(string $requestTarget): RequestInterface
{
$hasWhitespace = preg_match('#\s#', $requestTarget);
if ($hasWhitespace === false) {
throw new \RuntimeException('Unable to validate request target: '.preg_last_error_msg());
}
if ($hasWhitespace === 1) {
throw new InvalidArgumentException(
'Invalid request target provided; cannot contain whitespace'
);
}
self::assertRequestTarget($requestTarget);
$new = clone $this;
$new->requestTarget = $requestTarget;
@@ -103,12 +82,11 @@ class Request implements RequestInterface
return $this->method;
}
public function withMethod($method): RequestInterface
public function withMethod(string $method): RequestInterface
{
$this->assertMethod($method);
self::warnOnMethodCasingChange($method);
$new = clone $this;
$new->method = Utils::asciiToUpper($method);
$new->method = $method;
return $new;
}
@@ -118,26 +96,30 @@ class Request implements RequestInterface
return $this->uri;
}
public function withUri(UriInterface $uri, $preserveHost = false): RequestInterface
public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface
{
if (!\is_bool($preserveHost)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to RequestInterface::withUri() is deprecated; guzzlehttp/psr7 3.0 requires bool for $preserveHost.',
\get_debug_type($preserveHost)
);
$sameUri = $uri === $this->uri;
if (!$sameUri && $this->requestTarget === null) {
self::getRequestTargetFromUri($uri);
}
if ($uri === $this->uri) {
$currentHost = $this->getHeaderLine('Host');
$host = null;
if (!$preserveHost || $currentHost === '') {
$host = $this->getHostFromUri($uri);
}
if ($sameUri && ($host === null || $currentHost === $host)) {
return $this;
}
$new = clone $this;
$new->uri = $uri;
if (!$preserveHost || !isset($this->headerNames['host'])) {
$new->updateHostFromUri();
if ($host !== null) {
$new->setHostHeader($host);
}
return $new;
@@ -145,20 +127,36 @@ class Request implements RequestInterface
private function updateHostFromUri(): void
{
$host = $this->uri->getHost();
$host = $this->getHostFromUri($this->uri);
if ($host == '') {
if ($host === null) {
return;
}
$this->setHostHeader($host);
}
private function getHostFromUri(UriInterface $uri): ?string
{
$host = $uri->getHost();
if ($host === '') {
return null;
}
Uri::assertValidHost($host);
if (($port = $this->uri->getPort()) !== null) {
if (($port = $uri->getPort()) !== null) {
$host .= ':'.$port;
}
$this->assertValue($host);
return $host;
}
private function setHostHeader(string $host): void
{
if (isset($this->headerNames['host'])) {
$header = $this->headerNames['host'];
} else {
@@ -166,30 +164,47 @@ class Request implements RequestInterface
$this->headerNames['host'] = 'Host';
}
// Ensure Host is the first header.
// See: https://datatracker.ietf.org/doc/html/rfc7230#section-5.4
// See: https://datatracker.ietf.org/doc/html/rfc9110#section-7.2
$this->headers = [$header => [$host]] + $this->headers;
}
/**
* @param mixed $method
*/
private function assertMethod($method): void
private function assertMethod(string $method): void
{
if (!is_string($method) || $method === '') {
throw new InvalidArgumentException('Method must be a non-empty string.');
if (!Rfc9110::isToken($method)) {
throw new InvalidArgumentException('Method must be a valid HTTP token.');
}
$this->assertNoLineSeparators($method, 'Method');
}
private static function warnOnMethodCasingChange(string $method): void
private static function getRequestTargetFromUri(UriInterface $uri): string
{
if ($method !== Utils::asciiToUpper($method)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing a non-uppercase HTTP method is deprecated; guzzlehttp/psr7 3.0 preserves method casing and will no longer uppercase it. Normalize the method before constructing or modifying requests if uppercase is required.'
$target = self::normalizePathForOriginForm($uri->getPath());
if ($target === '') {
$target = '/';
}
if ($uri->getQuery() != '') {
$target .= '?'.$uri->getQuery();
}
self::assertRequestTarget($target);
return $target;
}
private static function assertRequestTarget(string $requestTarget): void
{
if (!Rfc9112::isValidRequestTarget($requestTarget)) {
throw new InvalidArgumentException(
'Invalid request target provided; cannot be empty or contain whitespace or control characters'
);
}
}
private static function normalizePathForOriginForm(string $path): string
{
if (str_starts_with($path, '//')) {
return '/'.ltrim($path, '/');
}
return $path;
}
}

View File

@@ -78,11 +78,9 @@ class Response implements ResponseInterface
511 => 'Network Authentication Required',
];
/** @var string */
private $reasonPhrase;
private string $reasonPhrase;
/** @var int */
private $statusCode;
private int $statusCode;
/**
* @param int $status Status code
@@ -114,7 +112,7 @@ class Response implements ResponseInterface
$reasonPhrase = (string) $reason;
}
$this->assertNoLineSeparators($reasonPhrase, 'Reason phrase');
$this->assertReasonPhrase($reasonPhrase);
$this->reasonPhrase = $reasonPhrase;
$this->protocol = $version;
@@ -130,56 +128,32 @@ class Response implements ResponseInterface
return $this->reasonPhrase;
}
public function withStatus($code, $reasonPhrase = ''): ResponseInterface
public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface
{
if (!\is_int($code) && \filter_var($code, \FILTER_VALIDATE_INT) !== false) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to ResponseInterface::withStatus() is deprecated; guzzlehttp/psr7 3.0 requires int for $code.',
\get_debug_type($code)
);
}
if (!\is_string($reasonPhrase)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to ResponseInterface::withStatus() is deprecated; guzzlehttp/psr7 3.0 requires string for $reasonPhrase.',
\get_debug_type($reasonPhrase)
);
}
$this->assertStatusCodeIsInteger($code);
$code = (int) $code;
$this->assertStatusCodeRange($code);
$new = clone $this;
$new->statusCode = $code;
if ($reasonPhrase == '' && isset(self::PHRASES[$new->statusCode])) {
if ($reasonPhrase === '' && isset(self::PHRASES[$new->statusCode])) {
$reasonPhrase = self::PHRASES[$new->statusCode];
}
$reasonPhrase = (string) $reasonPhrase;
$this->assertNoLineSeparators($reasonPhrase, 'Reason phrase');
$this->assertReasonPhrase($reasonPhrase);
$new->reasonPhrase = $reasonPhrase;
return $new;
}
/**
* @param mixed $statusCode
*/
private function assertStatusCodeIsInteger($statusCode): void
{
if (filter_var($statusCode, FILTER_VALIDATE_INT) === false) {
throw new \InvalidArgumentException('Status code must be an integer value.');
}
}
private function assertStatusCodeRange(int $statusCode): void
{
if ($statusCode < 100 || $statusCode >= 600) {
throw new \InvalidArgumentException('Status code must be an integer value between 1xx and 5xx.');
}
}
private function assertReasonPhrase(string $reasonPhrase): void
{
if (!Rfc9112::isValidReasonPhrase($reasonPhrase)) {
throw new \InvalidArgumentException('Reason phrase must not contain invalid control characters.');
}
}
}

View File

@@ -5,14 +5,21 @@ declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
* Syntax predicates and canonicalization helpers for the URI grammar defined
* by RFC 3986.
*/
final class Rfc3986
{
private function __construct()
{
}
/**
* Sub-delims for use in a regex.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
*
* @internal
*/
public const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
@@ -20,6 +27,335 @@ final class Rfc3986
* Unreserved characters for use in a regex.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
*
* @internal
*/
public const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
/**
* The two hex digits of a percent-encoded octet (the "3A" in "%3A"), for use in a regex.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.1
*
* @internal
*/
public const HEX_OCTET = '[A-Fa-f0-9]{2}';
/**
* Whether the string is a valid URI scheme.
*
* Per RFC 3986 a scheme must start with a letter, followed by letters,
* digits, `+`, `-`, or `.`. The empty string is also accepted, since a URI
* reference may omit the scheme.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-3.1
*/
public static function isValidScheme(string $scheme): bool
{
return $scheme === '' || preg_match('/^[A-Za-z][A-Za-z0-9.+-]*$/D', $scheme) === 1;
}
/**
* Whether the string is a valid URI host.
*
* Per RFC 3986 the host is `IP-literal / IPv4address / reg-name`. An empty
* host is accepted, since the authority (and thus the host) may be empty.
* Bracketed values are validated as IPv6 / IPvFuture literals; any other
* value is rejected if it contains control characters, whitespace, an
* authority or path delimiter (`/ ? # @ \`), an embedded colon denoting
* a port, a malformed percent-sequence, or a percent-encoded octet that
* decodes to one of those rejected bytes, to a bracket, or to `%` itself.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2
*/
public static function isValidHost(string $host): bool
{
if ($host === '') {
return true;
}
$invalidHost = preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host);
if ($invalidHost === false) {
return false;
}
if ($invalidHost === 1) {
return false;
}
if (str_contains($host, '[') || str_contains($host, ']')) {
return self::isValidIpLiteralHost($host);
}
if (str_contains($host, ':')) {
return false;
}
return !str_contains($host, '%') || self::hasValidHostPercentEncoding($host);
}
/**
* Whether the string is a valid port number (0-65535).
*
* RFC 3986 defines the port as `*DIGIT`, which also permits an empty port
* and has no upper bound. This applies the stricter policy used throughout
* the library instead: the value must be a non-empty run of digits (leading
* zeros are accepted and normalized) that resolves to 0-65535.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.3
*/
public static function isValidPort(string $port): bool
{
if ($port === '' || !ctype_digit($port)) {
return false;
}
$normalized = ltrim($port, '0');
if ($normalized === '') {
return true;
}
return strlen($normalized) <= 5 && (int) $normalized <= 0xFFFF;
}
/**
* Returns the RFC 5952 canonical form of a valid IPv6 address.
*
* The address must be a valid textual IPv6 address without brackets and
* without a zone identifier, such as the inside of an IP-literal accepted
* by `isValidHost()`. Canonicalization lowercases the hexadecimal fields,
* suppresses leading zeros, and collapses the longest run of two or more
* zero fields (the leftmost on a tie) with `::`. Embedded dotted-decimal
* notation follows the rendering policy of BIND-derived `inet_ntop()`
* implementations and curl: exactly the IPv4-mapped (`::ffff:0:0/96`) and
* deprecated IPv4-compatible (`::/96`) layouts use it, while other
* embedded-IPv4 forms, including translated (NAT64) well-known prefixes
* such as `64:ff9b::/96` (RFC 6052), serialize in pure hexadecimal fields.
*
* Validation is strict and platform-independent: the address is checked
* against the RFC 3986 `IPv6address` grammar with PHP's
* `FILTER_VALIDATE_IP` filter and parsed in pure PHP, so spellings that
* only some platform parsers accept, such as the zero-padded dotted octets
* in `::ffff:192.168.001.001`, are rejected everywhere.
*
* @throws \InvalidArgumentException If the address cannot be parsed.
*
* @see https://datatracker.ietf.org/doc/html/rfc5952#section-4
*/
public static function canonicalizeIpv6(string $address): string
{
$canonical = self::tryCanonicalizeIpv6($address);
if ($canonical === null) {
throw new \InvalidArgumentException('Invalid IPv6 address');
}
return $canonical;
}
/**
* Returns the RFC 5952 canonical form of a valid IPv6 address, or null
* when the address cannot be parsed.
*
* @internal
*/
public static function tryCanonicalizeIpv6(string $address): ?string
{
// Platform parsers disagree on which spellings are valid: Apple libc
// and OpenBSD inet_pton() accept zero-padded dotted octets such as
// "::ffff:192.168.001.001", and macOS additionally accepts and silently
// strips zone IDs ("fe80::1%eth0"), while glibc, musl, and PHP's own
// filter reject both. Origin classification built on this helper must
// fail closed and must not vary by operating system, so the address is
// validated with the platform-independent FILTER_VALIDATE_IP filter and
// parsed in pure PHP; no OS parser is consulted.
if (\filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) === false) {
return null;
}
$words = self::parseIpv6Words($address);
if ($words === null) {
return null;
}
// Find the longest run of two or more zero fields; ties keep the
// leftmost run per RFC 5952 section 4.2.3.
$bestStart = 0;
$bestLen = 0;
$start = -1;
foreach ($words as $i => $word) {
if ($word !== 0) {
$start = -1;
continue;
}
if ($start === -1) {
$start = $i;
}
if ($i - $start + 1 > $bestLen) {
$bestStart = $start;
$bestLen = $i - $start + 1;
}
}
if ($bestLen < 2) {
$bestLen = 0;
}
// RFC 5952 section 5: embedded IPv4 notation for IPv4-mapped
// (::ffff:0:0/96) and IPv4-compatible (::/96) addresses, the same
// condition BIND-derived inet_ntop() and curl use. bestStart must be
// zero: a five or six field zero run elsewhere is not an IPv4 prefix.
$mixed = $bestStart === 0
&& ($bestLen === 6 || ($bestLen === 5 && $words[5] === 0xFFFF));
$groups = [];
for ($i = 0, $n = $mixed ? 6 : 8; $i < $n; ++$i) {
$groups[] = dechex($words[$i]);
}
if ($mixed) {
$groups[] = sprintf(
'%d.%d.%d.%d',
$words[6] >> 8,
$words[6] & 0xFF,
$words[7] >> 8,
$words[7] & 0xFF
);
}
if ($bestLen === 0) {
return implode(':', $groups);
}
return implode(':', array_slice($groups, 0, $bestStart)).'::'.implode(':', array_slice($groups, $bestStart + $bestLen));
}
private static function hasValidHostPercentEncoding(string $host): bool
{
// Mirror of the raw reg-name policy above for percent-encoded octets:
// reject malformed sequences (RFC 3986 requires "%" HEXDIG HEXDIG) and
// octets that decode to bytes the raw grammar rejects - C0 controls,
// SP, DEL, the delimiters / ? # @ \ [ ], the port colon, and % itself.
// Octets decoding to any other byte (unreserved, sub-delims, and
// non-ASCII UTF-8 data) remain accepted.
$invalidEncoding = preg_match(
'/%(?!'.self::HEX_OCTET.')|%(?:[01][0-9A-Fa-f]|2[035F]|3[AF]|40|5[BCD]|7F)/i',
$host
);
return $invalidEncoding === 0;
}
private static function isValidIpLiteralHost(string $host): bool
{
if (!str_starts_with($host, '[') || !str_ends_with($host, ']')) {
return false;
}
$address = substr($host, 1, -1);
if (\filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) !== false) {
return true;
}
// RFC 6874 IPv6 zone identifiers are intentionally not supported here.
// Bracketed hosts are validated as IPv6 or IPvFuture only.
return preg_match('/^v[0-9a-f]+\.['.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.':]+$/iD', $address) === 1;
}
/**
* Parses a textual IPv6 address into its eight 16-bit words, or null
* when the text is not a structurally valid address.
*
* The grammar enforced here is the RFC 3986 `IPv6address` rule: one to four
* hexadecimal digits per field, at most one `::` eliding one or more zero
* fields, and an optional dotted-decimal tail of four octets (0-255, no
* leading zeros) as the final 32 bits. FILTER_VALIDATE_IP accepts exactly
* this grammar, so the filter guard in tryCanonicalizeIpv6() and this
* parser always agree and the null paths here can only fail closed.
*
* @return list<int>|null
*/
private static function parseIpv6Words(string $address): ?array
{
// A dotted-decimal tail is only valid as the final 32 bits, after the
// final colon. Rewrite it into its two hexadecimal fields so the
// remainder of the parse handles hexadecimal fields only.
$dot = strpos($address, '.');
if ($dot !== false) {
$colon = strrpos($address, ':');
if ($colon === false || $colon > $dot) {
return null;
}
$octets = explode('.', substr($address, $colon + 1));
if (count($octets) !== 4) {
return null;
}
$bytes = [];
foreach ($octets as $octet) {
if ($octet === '' || strlen($octet) > 3 || !ctype_digit($octet)) {
return null;
}
if ($octet[0] === '0' && $octet !== '0') {
return null;
}
$byte = (int) $octet;
if ($byte > 255) {
return null;
}
$bytes[] = $byte;
}
$address = substr($address, 0, $colon + 1)
.dechex(($bytes[0] << 8) | $bytes[1])
.':'
.dechex(($bytes[2] << 8) | $bytes[3]);
}
$halves = explode('::', $address);
if (count($halves) > 2) {
return null;
}
$head = self::parseHexFields($halves[0]);
if ($head === null) {
return null;
}
if (count($halves) === 1) {
return count($head) === 8 ? $head : null;
}
$tail = self::parseHexFields($halves[1]);
if ($tail === null) {
return null;
}
// The "::" must elide at least one zero field.
$elided = 8 - count($head) - count($tail);
if ($elided < 1) {
return null;
}
return array_merge($head, array_fill(0, $elided, 0), $tail);
}
/**
* Parses a colon-separated run of 16-bit hexadecimal fields, or null
* when a field is empty, longer than four digits, or not hexadecimal.
*
* @return list<int>|null
*/
private static function parseHexFields(string $fields): ?array
{
if ($fields === '') {
return [];
}
$words = [];
foreach (explode(':', $fields) as $field) {
if ($field === '' || strlen($field) > 4 || !ctype_xdigit($field)) {
return null;
}
$words[] = intval($field, 16);
}
return $words;
}
}

View File

@@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class Rfc7230
{
/**
* Header related regular expressions (based on amphp/http package)
*
* Note: header delimiter (\r\n) is modified to \r?\n to accept line feed only delimiters for BC reasons.
*
* @see https://github.com/amphp/http/blob/v1.0.1/src/Rfc7230.php#L12-L15
*
* @license https://github.com/amphp/http/blob/v1.0.1/LICENSE
*/
public const HEADER_REGEX = "(^([^()<>@,;:\\\"/[\]?={}\x01-\x20\x7F]++):[ \t]*+((?:[ \t]*+[\x21-\x7E\x80-\xFF]++)*+)[ \t]*+\r?\n)m";
public const HEADER_FOLD_REGEX = "(\r?\n[ \t]++)";
/**
* @return array{0: string, 1: int|null}|null
*/
public static function parseHostHeader(string $authority): ?array
{
if ($authority === '') {
return null;
}
$host = $authority;
$port = null;
if ($authority[0] === '[') {
$closingBracket = strpos($authority, ']');
if ($closingBracket === false) {
return null;
}
$host = substr($authority, 0, $closingBracket + 1);
$remainder = substr($authority, $closingBracket + 1);
if ($remainder !== '') {
if ($remainder[0] !== ':') {
return null;
}
$port = self::parseAuthorityPort(substr($remainder, 1));
if ($port === null) {
return null;
}
}
} elseif (false !== ($colon = strpos($authority, ':'))) {
$host = substr($authority, 0, $colon);
$port = self::parseAuthorityPort(substr($authority, $colon + 1));
if ($port === null) {
return null;
}
}
if ($host === '' || !self::isValidHostHeaderHost($host)) {
return null;
}
return [$host, $port];
}
private static function isValidHostHeaderHost(string $host): bool
{
$invalidHost = preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host);
if ($invalidHost === false) {
return false;
}
if ($invalidHost === 1) {
return false;
}
if (strpos($host, '[') !== false || strpos($host, ']') !== false) {
if ($host[0] !== '[' || substr($host, -1) !== ']') {
return false;
}
$address = substr($host, 1, -1);
return filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) !== false
|| preg_match('/^v[0-9a-f]+\.['.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.':]+$/iD', $address) === 1;
}
return strpos($host, ':') === false;
}
private static function parseAuthorityPort(string $port): ?int
{
if ($port === '' || !ctype_digit($port)) {
return null;
}
$normalized = ltrim($port, '0');
if ($normalized === '') {
return 0;
}
if (strlen($normalized) > 5 || (int) $normalized > 0xFFFF) {
return null;
}
return (int) $normalized;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class Rfc9110
{
/**
* A token for use in a regular expression.
*
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
*/
public const TOKEN_PATTERN = '[!#$%&\'*+.^_`|~0-9A-Za-z-]+';
/**
* A field value for use in a regular expression.
*
* Obsolete line folding is intentionally excluded.
*
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5
*/
public const FIELD_VALUE_PATTERN = '[\x09\x20-\x7E\x80-\xFF]*';
private function __construct()
{
}
public static function isToken(string $value): bool
{
return preg_match('/^'.self::TOKEN_PATTERN.'$/D', $value) === 1;
}
public static function isFieldValue(string $value): bool
{
return preg_match('/^'.self::FIELD_VALUE_PATTERN.'$/D', $value) === 1;
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class Rfc9112
{
/**
* An HTTP protocol version for use in a regular expression.
*/
public const PROTOCOL_VERSION_PATTERN = '\d+(?:\.\d+)?';
/**
* The request-target bytes accepted by the HTTP/1 start-line grammar for
* use in a regular expression.
*/
public const REQUEST_TARGET_PATTERN = '[^\x00-\x20\x7F]+';
private function __construct()
{
}
/**
* Header related regular expressions (based on amphp/http package)
*
* Note: header delimiter (\r\n) is modified to \r?\n to accept line feed only delimiters for BC reasons.
*
* @see https://github.com/amphp/http/blob/v1.0.1/src/Rfc7230.php#L12-L15
*
* @license https://github.com/amphp/http/blob/v1.0.1/LICENSE
*/
public const HEADER_REGEX = "(^([^()<>@,;:\\\"/[\]?={}\x01-\x20\x7F]++):[ \t]*+((?:[ \t]*+[\x21-\x7E\x80-\xFF]++)*+)[ \t]*+\r?\n)m";
public const HEADER_FOLD_REGEX = "(\r?\n[ \t]++)";
public static function isValidProtocolVersion(string $version): bool
{
return preg_match('/^'.self::PROTOCOL_VERSION_PATTERN.'$/D', $version) === 1;
}
public static function isValidRequestTarget(string $target): bool
{
return preg_match('/^'.self::REQUEST_TARGET_PATTERN.'$/D', $target) === 1;
}
public static function isValidReasonPhrase(string $reasonPhrase): bool
{
return Rfc9110::isFieldValue($reasonPhrase);
}
/**
* @return array{0: string, 1: int|null}|null
*/
public static function parseHostHeader(string $authority): ?array
{
if ($authority === '') {
return null;
}
$host = $authority;
$port = null;
if (str_starts_with($authority, '[')) {
$closingBracket = strpos($authority, ']');
if ($closingBracket === false) {
return null;
}
$host = substr($authority, 0, $closingBracket + 1);
$remainder = substr($authority, $closingBracket + 1);
if ($remainder !== '') {
if (!str_starts_with($remainder, ':')) {
return null;
}
$port = self::parsePort(substr($remainder, 1));
if ($port === null) {
return null;
}
}
} elseif (false !== ($colon = strpos($authority, ':'))) {
$host = substr($authority, 0, $colon);
$port = self::parsePort(substr($authority, $colon + 1));
if ($port === null) {
return null;
}
}
if ($host === '' || !Rfc3986::isValidHost($host)) {
return null;
}
return [$host, $port];
}
public static function isAbsoluteFormRequestTarget(string $target): bool
{
return preg_match('/^[A-Za-z][A-Za-z0-9+.-]*:\/\//D', $target) === 1;
}
public static function isAsteriskFormRequestTarget(string $method, string $target): bool
{
return $method === 'OPTIONS' && $target === '*';
}
public static function isConnectAuthorityFormRequestTarget(string $method, string $target): bool
{
return $method === 'CONNECT' && strpbrk($target, '/?#') === false;
}
public static function parsePort(string $port): ?int
{
if (!Rfc3986::isValidPort($port)) {
return null;
}
// A zero port is valid per RFC 3986 but meaningless for an HTTP
// authority, so reject it on top of the generic syntax check.
$parsed = (int) ltrim($port, '0');
return $parsed === 0 ? null : $parsed;
}
}

View File

@@ -26,35 +26,20 @@ use Psr\Http\Message\UriInterface;
*/
class ServerRequest extends Request implements ServerRequestInterface
{
/**
* @var array
*/
private $attributes = [];
private array $attributes = [];
/**
* @var array
*/
private $cookieParams = [];
private array $cookieParams = [];
/**
* @var array|object|null
*/
private $parsedBody;
/**
* @var array
*/
private $queryParams = [];
private array $queryParams = [];
/**
* @var array
*/
private $serverParams;
private array $serverParams;
/**
* @var array
*/
private $uploadedFiles = [];
private array $uploadedFiles = [];
/**
* @param string $method HTTP method
@@ -70,6 +55,7 @@ class ServerRequest extends Request implements ServerRequestInterface
array $headers = [],
$body = null,
string $version = '1.1',
#[\SensitiveParameter]
array $serverParams = []
) {
$this->serverParams = $serverParams;
@@ -86,73 +72,7 @@ class ServerRequest extends Request implements ServerRequestInterface
*/
public static function normalizeFiles(array $files): array
{
$normalized = [];
foreach ($files as $key => $value) {
if ($value instanceof UploadedFileInterface) {
$normalized[$key] = $value;
} elseif (is_array($value) && isset($value['tmp_name'])) {
$normalized[$key] = self::createUploadedFileFromSpec($value);
} elseif (is_array($value)) {
$normalized[$key] = self::normalizeFiles($value);
continue;
} else {
throw new InvalidArgumentException('Invalid value in files specification');
}
}
return $normalized;
}
/**
* Create and return an UploadedFile instance from a $_FILES specification.
*
* If the specification represents an array of values, this method will
* delegate to normalizeNestedFileSpec() and return that return value.
*
* @param array $value $_FILES struct
*
* @return UploadedFileInterface|UploadedFileInterface[]
*/
private static function createUploadedFileFromSpec(array $value)
{
if (is_array($value['tmp_name'])) {
return self::normalizeNestedFileSpec($value);
}
return new UploadedFile(
$value['tmp_name'],
(int) $value['size'],
(int) $value['error'],
$value['name'],
$value['type']
);
}
/**
* Normalize an array of file specifications.
*
* Loops through all nested files and returns a normalized array of
* UploadedFileInterface instances.
*
* @return UploadedFileInterface[]
*/
private static function normalizeNestedFileSpec(array $files = []): array
{
$normalizedFiles = [];
foreach (array_keys($files['tmp_name']) as $key) {
$spec = [
'tmp_name' => $files['tmp_name'][$key],
'size' => $files['size'][$key] ?? null,
'error' => $files['error'][$key] ?? null,
'name' => $files['name'][$key] ?? null,
'type' => $files['type'][$key] ?? null,
];
$normalizedFiles[$key] = self::createUploadedFileFromSpec($spec);
}
return $normalizedFiles;
return UploadedFileNormalizer::normalize($files);
}
/**
@@ -165,79 +85,20 @@ class ServerRequest extends Request implements ServerRequestInterface
*/
public static function fromGlobals(): ServerRequestInterface
{
$method = Utils::asciiToUpper(self::getServerParam('REQUEST_METHOD') ?? 'GET');
$headers = self::removeInvalidHostHeader(self::getAllHeaders());
$uri = self::getUriFromGlobals();
$body = new CachingStream(new LazyOpenStream('php://input', 'r+'));
$serverProtocol = self::getServerParam('SERVER_PROTOCOL');
$protocol = $serverProtocol !== null ? str_replace('HTTP/', '', $serverProtocol) : '1.1';
return ServerRequestGlobalsFactory::fromArrays(
$_SERVER,
$_GET,
$_POST,
$_COOKIE,
$_FILES,
static function () {
if (!\function_exists('apache_request_headers')) {
return false;
}
$serverRequest = new ServerRequest($method, $uri, $headers, $body, $protocol, $_SERVER);
return $serverRequest
->withCookieParams($_COOKIE)
->withQueryParams($_GET)
->withParsedBody($_POST)
->withUploadedFiles(self::normalizeFiles($_FILES));
}
/**
* @return array<array-key, string>
*/
private static function getAllHeaders(): array
{
return self::normalizeHeaderValues(getallheaders());
}
/**
* @param array<array-key, mixed> $headers
*
* @return array<array-key, string>
*/
private static function normalizeHeaderValues(array $headers): array
{
$normalized = [];
foreach ($headers as $name => $value) {
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
$normalized[$name] = (string) $value;
return \apache_request_headers();
}
}
return $normalized;
}
private static function getServerParam(string $key): ?string
{
return isset($_SERVER[$key]) && is_string($_SERVER[$key]) ? $_SERVER[$key] : null;
}
/**
* @param array<array-key, string> $headers
*
* @return array<array-key, string>
*/
private static function removeInvalidHostHeader(array $headers): array
{
foreach ($headers as $name => $value) {
if (Utils::asciiToLower((string) $name) !== 'host') {
continue;
}
if (Rfc7230::parseHostHeader($value) === null) {
unset($headers[$name]);
}
}
return $headers;
}
/**
* @return array{0: string|null, 1: int|null}
*/
private static function extractHostAndPortFromAuthority(string $authority): array
{
return Rfc7230::parseHostHeader($authority) ?? [null, null];
);
}
/**
@@ -245,51 +106,7 @@ class ServerRequest extends Request implements ServerRequestInterface
*/
public static function getUriFromGlobals(): UriInterface
{
$uri = new Uri('');
$https = self::getServerParam('HTTPS');
$uri = $uri->withScheme(!empty($https) && $https !== 'off' ? 'https' : 'http');
$hasPort = false;
$authority = self::getServerParam('HTTP_HOST');
if ($authority !== null) {
[$host, $port] = self::extractHostAndPortFromAuthority($authority);
if ($host !== null) {
$uri = $uri->withHost($host);
}
if ($port !== null) {
$hasPort = true;
$uri = $uri->withPort($port);
}
} elseif (($serverName = self::getServerParam('SERVER_NAME')) !== null) {
$uri = $uri->withHost($serverName);
} elseif (($serverAddr = self::getServerParam('SERVER_ADDR')) !== null) {
$uri = $uri->withHost($serverAddr);
}
$serverPort = self::getServerParam('SERVER_PORT');
if (!$hasPort && $serverPort !== null && preg_match('/^[+-]?\d+$/D', $serverPort) === 1) {
$uri = $uri->withPort((int) $serverPort);
}
$hasQuery = false;
$requestUri = self::getServerParam('REQUEST_URI');
if ($requestUri !== null) {
$requestUriParts = explode('?', $requestUri, 2);
$uri = $uri->withPath($requestUriParts[0]);
if (isset($requestUriParts[1])) {
$hasQuery = true;
$uri = $uri->withQuery($requestUriParts[1]);
}
}
$queryString = self::getServerParam('QUERY_STRING');
if (!$hasQuery && $queryString !== null) {
$uri = $uri->withQuery($queryString);
}
return $uri;
return ServerRequestGlobalsFactory::getUriFromServerParams($_SERVER);
}
public function getServerParams(): array
@@ -304,12 +121,10 @@ class ServerRequest extends Request implements ServerRequestInterface
public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface
{
$invalidUploadedFileFound = false;
$invalidUploadedFile = null;
$stack = [$uploadedFiles];
while ($stack !== []) {
foreach (\array_pop($stack) as $uploadedFile) {
for ($i = 0; $i < \count($stack); ++$i) {
foreach ($stack[$i] as $uploadedFile) {
if ($uploadedFile instanceof UploadedFileInterface) {
continue;
}
@@ -319,22 +134,13 @@ class ServerRequest extends Request implements ServerRequestInterface
continue;
}
$invalidUploadedFileFound = true;
$invalidUploadedFile = $uploadedFile;
break 2;
throw new InvalidArgumentException(sprintf(
'Invalid uploaded file tree; expected UploadedFileInterface instances but %s provided.',
\get_debug_type($uploadedFile)
));
}
}
if ($invalidUploadedFileFound) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s inside ServerRequestInterface::withUploadedFiles() is deprecated; guzzlehttp/psr7 3.0 requires an UploadedFileInterface[] tree.',
\get_debug_type($invalidUploadedFile)
);
}
$new = clone $this;
$new->uploadedFiles = $uploadedFiles;
@@ -346,8 +152,10 @@ class ServerRequest extends Request implements ServerRequestInterface
return $this->cookieParams;
}
public function withCookieParams(array $cookies): ServerRequestInterface
{
public function withCookieParams(
#[\SensitiveParameter]
array $cookies
): ServerRequestInterface {
$new = clone $this;
$new->cookieParams = $cookies;
@@ -378,12 +186,7 @@ class ServerRequest extends Request implements ServerRequestInterface
public function withParsedBody($data): ServerRequestInterface
{
if ($data !== null && !\is_array($data) && !\is_object($data)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to ServerRequestInterface::withParsedBody() is deprecated; guzzlehttp/psr7 3.0 requires array|object|null.',
\get_debug_type($data)
);
throw new InvalidArgumentException('Parsed body must be an array, object, or null.');
}
$new = clone $this;
@@ -400,58 +203,31 @@ class ServerRequest extends Request implements ServerRequestInterface
/**
* @return mixed
*/
public function getAttribute($attribute, $default = null)
public function getAttribute(string $name, $default = null)
{
if (!\is_string($attribute)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to ServerRequestInterface::getAttribute() is deprecated; guzzlehttp/psr7 3.0 requires string for $attribute.',
\get_debug_type($attribute)
);
}
if (false === array_key_exists($attribute, $this->attributes)) {
if (false === array_key_exists($name, $this->attributes)) {
return $default;
}
return $this->attributes[$attribute];
return $this->attributes[$name];
}
public function withAttribute($attribute, $value): ServerRequestInterface
public function withAttribute(string $name, $value): ServerRequestInterface
{
if (!\is_string($attribute)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to ServerRequestInterface::withAttribute() is deprecated; guzzlehttp/psr7 3.0 requires string for $attribute.',
\get_debug_type($attribute)
);
}
$new = clone $this;
$new->attributes[$attribute] = $value;
$new->attributes[$name] = $value;
return $new;
}
public function withoutAttribute($attribute): ServerRequestInterface
public function withoutAttribute(string $name): ServerRequestInterface
{
if (!\is_string($attribute)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to ServerRequestInterface::withoutAttribute() is deprecated; guzzlehttp/psr7 3.0 requires string for $attribute.',
\get_debug_type($attribute)
);
}
if (false === array_key_exists($attribute, $this->attributes)) {
if (false === array_key_exists($name, $this->attributes)) {
return $this;
}
$new = clone $this;
unset($new->attributes[$attribute]);
unset($new->attributes[$name]);
return $new;
}

View File

@@ -0,0 +1,504 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use InvalidArgumentException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
final class ServerRequestGlobalsFactory
{
private function __construct()
{
}
/**
* @param array<array-key, mixed> $server Typically the $_SERVER superglobal
* @param array<array-key, mixed> $query Typically the $_GET superglobal
* @param array<array-key, mixed> $post Typically the $_POST superglobal
* @param array<array-key, mixed> $cookies Typically the $_COOKIE superglobal
* @param array<array-key, mixed> $files Typically the $_FILES superglobal
* @param (callable(): mixed)|null $headerProvider
*/
public static function fromArrays(
#[\SensitiveParameter]
array $server,
array $query,
array $post,
#[\SensitiveParameter]
array $cookies,
array $files,
?callable $headerProvider = null
): ServerRequestInterface {
$method = self::getRequestMethodFromServer($server);
$headers = self::removeInvalidHostHeader(self::getAllHeaders($server, $headerProvider));
[$uri, $requestTarget] = self::getUriAndRequestTargetFromServer($server, $method);
$body = new CachingStream(new LazyOpenStream('php://input', 'r+'));
$serverRequest = new ServerRequest($method, $uri, $headers, $body, self::getProtocolFromServer($server), $server);
if ($requestTarget !== null) {
/** @var ServerRequestInterface $serverRequest */
$serverRequest = $serverRequest->withRequestTarget($requestTarget);
}
return $serverRequest
->withCookieParams($cookies)
->withQueryParams($query)
->withParsedBody($post)
->withUploadedFiles(UploadedFileNormalizer::normalize($files));
}
/**
* @param array<array-key, mixed> $server Typically the $_SERVER superglobal
*/
public static function getUriFromServerParams(
#[\SensitiveParameter]
array $server
): UriInterface {
$method = self::getRequestMethodFromServer($server);
return self::getUriAndRequestTargetFromServer($server, $method)[0];
}
/**
* @param array<array-key, mixed> $server
* @param (callable(): mixed)|null $headerProvider
*
* @return array<array-key, string>
*/
private static function getAllHeaders(
#[\SensitiveParameter]
array $server,
?callable $headerProvider
): array {
$headers = $headerProvider !== null ? $headerProvider() : false;
if (!is_array($headers)) {
$headers = self::getHeadersFromServer($server);
}
return self::normalizeHeaderValues($headers);
}
/**
* @param array<array-key, mixed> $headers
*
* @return array<array-key, string>
*/
private static function normalizeHeaderValues(
#[\SensitiveParameter]
array $headers
): array {
$normalized = [];
foreach ($headers as $name => $value) {
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
$normalized[$name] = (string) $value;
}
}
return $normalized;
}
/**
* @param array<array-key, mixed> $server Typically the $_SERVER superglobal
*
* @return array<array-key, string>
*/
private static function getHeadersFromServer(array $server): array
{
$headers = [];
$copyServer = [
'CONTENT_TYPE' => 'Content-Type',
'CONTENT_LENGTH' => 'Content-Length',
'CONTENT_MD5' => 'Content-Md5',
];
foreach ($server as $key => $value) {
if (!is_string($key) || !is_string($value)) {
continue;
}
if (str_starts_with($key, 'HTTP_')) {
$header = substr($key, 5);
if (isset($copyServer[$header], $server[$header]) && is_string($server[$header])) {
continue;
}
$parts = explode(' ', Utils::asciiToLower(str_replace('_', ' ', $header)));
foreach ($parts as $i => $part) {
$parts[$i] = Utils::asciiUcFirst($part);
}
$header = implode('-', $parts);
$headers[$header] = $value;
continue;
}
if (isset($copyServer[$key])) {
$headers[$copyServer[$key]] = $value;
}
}
if (!isset($headers['Authorization'])) {
if (isset($server['REDIRECT_HTTP_AUTHORIZATION']) && is_string($server['REDIRECT_HTTP_AUTHORIZATION'])) {
$headers['Authorization'] = $server['REDIRECT_HTTP_AUTHORIZATION'];
} elseif (isset($server['PHP_AUTH_USER']) && is_string($server['PHP_AUTH_USER'])) {
$password = isset($server['PHP_AUTH_PW']) && is_string($server['PHP_AUTH_PW'])
? $server['PHP_AUTH_PW']
: '';
$headers['Authorization'] = 'Basic '.base64_encode($server['PHP_AUTH_USER'].':'.$password);
} elseif (isset($server['PHP_AUTH_DIGEST']) && is_string($server['PHP_AUTH_DIGEST'])) {
$headers['Authorization'] = $server['PHP_AUTH_DIGEST'];
}
}
return $headers;
}
/**
* @param array<array-key, string> $headers
*
* @return array<array-key, string>
*/
private static function removeInvalidHostHeader(array $headers): array
{
foreach ($headers as $name => $value) {
if (Utils::asciiToLower((string) $name) !== 'host') {
continue;
}
[$host] = self::extractHostAndPortFromAuthority($value);
if ($host === null) {
unset($headers[$name]);
}
}
return $headers;
}
/**
* @param array<array-key, mixed> $server
*/
private static function getServerParam(array $server, string $key): ?string
{
return isset($server[$key]) && is_string($server[$key]) ? $server[$key] : null;
}
/**
* @param array<array-key, mixed> $server
*/
private static function getRequestMethodFromServer(array $server): string
{
return Utils::asciiToUpper(self::getServerParam($server, 'REQUEST_METHOD') ?? 'GET');
}
/**
* @param array<array-key, mixed> $server
*/
private static function getProtocolFromServer(array $server): string
{
$serverProtocol = self::getServerParam($server, 'SERVER_PROTOCOL');
if ($serverProtocol === null) {
return '1.1';
}
return str_starts_with($serverProtocol, 'HTTP/') ? substr($serverProtocol, 5) : $serverProtocol;
}
/**
* @return array{0: string|null, 1: int|null}
*/
private static function extractHostAndPortFromAuthority(string $authority): array
{
return Rfc9112::parseHostHeader($authority) ?? [null, null];
}
private static function parseServerPort(string $port): int
{
$parsed = Rfc9112::parsePort($port);
if ($parsed === null) {
throw new InvalidArgumentException('Invalid SERVER_PORT; expected an integer between 1 and 65535.');
}
return $parsed;
}
private static function withHostFromServer(UriInterface $uri, ?string $host): ?UriInterface
{
if ($host === null) {
return null;
}
try {
return $uri->withHost($host);
} catch (InvalidArgumentException $e) {
return null;
}
}
/**
* @param array<array-key, mixed> $server
*/
private static function getUriWithSchemeFromServer(array $server): UriInterface
{
$uri = new Uri('');
$https = self::getServerParam($server, 'HTTPS');
return $uri->withScheme(!empty($https) && $https !== 'off' ? 'https' : 'http');
}
/**
* @param array<array-key, mixed> $server
*/
private static function getAuthorityUriFromServer(
#[\SensitiveParameter]
array $server
): UriInterface {
$uri = self::getUriWithSchemeFromServer($server);
$hasPort = false;
$hasHost = false;
$authority = self::getServerParam($server, 'HTTP_HOST');
if ($authority !== null) {
[$host, $port] = self::extractHostAndPortFromAuthority($authority);
if ($host !== null) {
$hostUri = self::withHostFromServer($uri, $host);
if ($hostUri !== null) {
$uri = $hostUri;
$hasHost = true;
if ($port !== null) {
$hasPort = true;
$uri = $uri->withPort($port);
}
}
}
}
foreach (['SERVER_NAME', 'SERVER_ADDR'] as $serverParam) {
if ($hasHost) {
continue;
}
$hostUri = self::withHostFromServer($uri, self::getServerParam($server, $serverParam));
if ($hostUri !== null) {
$uri = $hostUri;
$hasHost = true;
}
}
$serverPort = self::getServerParam($server, 'SERVER_PORT');
if (!$hasPort && $serverPort !== null) {
$uri = $uri->withPort(self::parseServerPort($serverPort));
}
return $uri;
}
/**
* @param array<array-key, mixed> $server
*
* @return array{0: UriInterface, 1: string|null}
*/
private static function getUriAndRequestTargetFromServer(
#[\SensitiveParameter]
array $server,
string $method
): array {
$requestUri = self::getServerParam($server, 'REQUEST_URI');
$queryString = self::getServerParam($server, 'QUERY_STRING');
if ($requestUri !== null) {
$connectAuthority = self::parseConnectAuthorityFormRequestTarget($method, $requestUri);
if ($connectAuthority !== null) {
[$host, $port] = $connectAuthority;
$uri = self::getUriWithSchemeFromServer($server);
return [
$uri->withHost($host)->withPort($port)->withPath('')->withQuery(''),
$requestUri,
];
}
$absoluteForm = self::getAbsoluteFormUriAndRequestTarget($requestUri, $queryString);
if ($absoluteForm !== null) {
return $absoluteForm;
}
}
$uri = self::getAuthorityUriFromServer($server);
if ($requestUri === null) {
if ($queryString !== null) {
$uri = $uri->withQuery($queryString);
}
return [$uri, null];
}
if (Rfc9112::isAsteriskFormRequestTarget($method, $requestUri)) {
return [$uri->withPath('')->withQuery(''), '*'];
}
[$path, $query, $hasQuery] = self::splitRequestTargetQuery($requestUri);
$uri = $uri->withPath(self::normalizeOriginFormPathFromServer($path));
if ($hasQuery) {
$uri = $uri->withQuery($query);
} elseif ($queryString !== null) {
$uri = $uri->withQuery($queryString);
}
return [$uri, null];
}
/**
* @return array{0: UriInterface, 1: string}|null
*/
private static function getAbsoluteFormUriAndRequestTarget(
#[\SensitiveParameter]
string $requestUri,
?string $queryString
): ?array {
if (!Rfc9112::isAbsoluteFormRequestTarget($requestUri)) {
return null;
}
try {
$targetUri = (new Uri($requestUri))->withFragment('');
} catch (InvalidArgumentException $e) {
return null;
}
if ($targetUri->getHost() === '') {
return null;
}
$requestTarget = self::removeRequestTargetFragment($requestUri);
$requestTargetWithoutUserInfo = self::removeUserInfoFromAbsoluteFormRequestTarget($requestTarget);
if ($requestTargetWithoutUserInfo !== $requestTarget) {
$targetUri = $targetUri->withUserInfo('');
$requestTarget = $requestTargetWithoutUserInfo;
}
if (!str_contains($requestTarget, '?') && $queryString !== null && $queryString !== '') {
$targetUri = $targetUri->withQuery($queryString);
$requestTarget .= '?'.$queryString;
}
// Preserve the received absolute-form target unless it cannot be used as
// a PSR-7 request target without normalization.
$normalizeRequestTarget = !Rfc9112::isValidRequestTarget($requestTarget)
|| self::hasEmptyPortInAbsoluteFormRequestTarget($requestTarget);
return [$targetUri, $normalizeRequestTarget ? (string) $targetUri : $requestTarget];
}
private static function removeUserInfoFromAbsoluteFormRequestTarget(string $target): string
{
$authorityStart = strpos($target, '://');
if ($authorityStart === false) {
return $target;
}
$authorityStart += 3;
$authorityLength = strcspn($target, '/?#', $authorityStart);
$authority = substr($target, $authorityStart, $authorityLength);
if ($authority === '') {
return $target;
}
$lastAt = strrpos($authority, '@');
if ($lastAt === false) {
return $target;
}
$authorityEnd = $authorityStart + $authorityLength;
return substr($target, 0, $authorityStart)
.substr($authority, $lastAt + 1)
.substr($target, $authorityEnd);
}
private static function hasEmptyPortInAbsoluteFormRequestTarget(string $target): bool
{
$authorityStart = strpos($target, '://');
if ($authorityStart === false) {
return false;
}
$authorityStart += 3;
$authority = substr($target, $authorityStart, strcspn($target, '/?#', $authorityStart));
if ($authority === '') {
return false;
}
$lastAt = strrpos($authority, '@');
if ($lastAt !== false) {
$authority = substr($authority, $lastAt + 1);
}
if ($authority === '') {
return false;
}
if (str_starts_with($authority, '[')) {
$closingBracket = strpos($authority, ']');
return $closingBracket !== false && substr($authority, $closingBracket + 1) === ':';
}
return str_ends_with($authority, ':');
}
/**
* @return array{0: string, 1: int}|null
*/
private static function parseConnectAuthorityFormRequestTarget(string $method, string $target): ?array
{
if (!Rfc9112::isConnectAuthorityFormRequestTarget($method, $target)) {
return null;
}
[$host, $port] = self::extractHostAndPortFromAuthority($target);
if ($host === null || $port === null) {
return null;
}
return [$host, $port];
}
private static function removeRequestTargetFragment(string $target): string
{
return explode('#', $target, 2)[0];
}
/**
* @return array{0: string, 1: string, 2: bool}
*/
private static function splitRequestTargetQuery(string $target): array
{
$parts = explode('?', $target, 2);
return [$parts[0], $parts[1] ?? '', isset($parts[1])];
}
private static function normalizeOriginFormPathFromServer(string $path): string
{
if ($path === '' || str_starts_with($path, '/')) {
return $path;
}
return '/'.$path;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\StreamInterface;
/**
@@ -11,27 +12,17 @@ use Psr\Http\Message\StreamInterface;
*/
class Stream implements StreamInterface
{
/**
* @see https://www.php.net/manual/en/function.fopen.php
* @see https://www.php.net/manual/en/function.gzopen.php
*/
private const READABLE_MODES = '/r|a\+|ab\+|w\+|wb\+|x\+|xb\+|c\+|cb\+/';
private const WRITABLE_MODES = '/a|w|r\+|rb\+|rw|x|c/';
use NonSerializableStreamTrait;
/** @var resource */
private $stream;
/** @var int|null */
private $size;
/** @var bool */
private $seekable;
/** @var bool */
private $readable;
/** @var bool */
private $writable;
/** @var string|null */
private $uri;
private ?int $size = null;
private bool $seekable;
private bool $readable;
private bool $writable;
private ?string $uri = null;
/** @var mixed[] */
private $customMetadata;
private array $customMetadata;
/**
* This constructor accepts an associative array of options.
@@ -53,16 +44,14 @@ class Stream implements StreamInterface
throw new \InvalidArgumentException('Stream must be a resource');
}
if (isset($options['size'])) {
$this->size = $options['size'];
}
$this->size = Integers::assertOptionalNonNegativeSize($options['size'] ?? null, 'Stream size');
$this->customMetadata = $options['metadata'] ?? [];
$this->stream = $stream;
$meta = stream_get_meta_data($this->stream);
$this->seekable = $meta['seekable'];
$this->readable = (bool) preg_match(self::READABLE_MODES, $meta['mode']);
$this->writable = (bool) preg_match(self::WRITABLE_MODES, $meta['mode']);
$this->readable = self::isReadableMode($meta['mode']);
$this->writable = self::isWritableMode($meta['mode']);
$this->uri = $meta['uri'] ?? null;
}
@@ -76,20 +65,11 @@ class Stream implements StreamInterface
public function __toString(): string
{
try {
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
}
public function getContents(): string
@@ -145,13 +125,13 @@ class Stream implements StreamInterface
}
$stats = fstat($this->stream);
if (is_array($stats) && isset($stats['size'])) {
$this->size = $stats['size'];
return $this->size;
if ($stats === false) {
return null;
}
return null;
$this->size = Integers::assertEngineInteger($stats['size'], 'Stream size');
return $this->size;
}
public function isReadable(): bool
@@ -185,12 +165,16 @@ class Stream implements StreamInterface
}
$result = ftell($this->stream);
if ($result === false) {
throw new \RuntimeException('Unable to determine stream position');
}
return $result;
$position = Integers::assertEngineInteger($result, 'Stream position');
if ($position === null) {
throw new \RuntimeException('Unable to determine stream position');
}
return $position;
}
public function rewind(): void
@@ -198,28 +182,8 @@ class Stream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
$whence = (int) $whence;
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
}
@@ -232,17 +196,8 @@ class Stream implements StreamInterface
}
}
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
}
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
}
@@ -259,28 +214,33 @@ class Stream implements StreamInterface
try {
$string = fread($this->stream, $length);
} catch (TimeoutException $e) {
throw $e;
} catch (\Exception $e) {
if ($this->timedOut()) {
throw new TimeoutException('Unable to read from stream: timed out', 0, $e);
}
throw new \RuntimeException('Unable to read from stream', 0, $e);
}
if (false === $string) {
if ($this->timedOut()) {
throw new TimeoutException('Unable to read from stream: timed out');
}
throw new \RuntimeException('Unable to read from stream');
}
if ($string === '' && $this->timedOut()) {
throw new TimeoutException('Unable to read from stream: timed out');
}
return $string;
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
}
@@ -288,34 +248,48 @@ class Stream implements StreamInterface
throw new \RuntimeException('Cannot write to a non-writable stream');
}
if ($string === '') {
return 0;
}
// We can't know the size after writing anything
$this->size = null;
$result = fwrite($this->stream, $string);
try {
$result = fwrite($this->stream, $string);
} catch (TimeoutException $e) {
throw $e;
} catch (\Exception $e) {
if ($this->writeTimedOut()) {
throw new TimeoutException('Unable to write to stream: timed out', 0, $e);
}
throw new \RuntimeException('Unable to write to stream', 0, $e);
}
if ($result === false) {
if ($this->writeTimedOut()) {
throw new TimeoutException('Unable to write to stream: timed out');
}
throw new \RuntimeException('Unable to write to stream');
}
if ($result === 0 && $this->writeTimedOut()) {
throw new TimeoutException('Unable to write to stream: timed out');
}
return $result;
}
/**
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
if ($key !== null && !\is_string($key)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.',
\get_debug_type($key)
);
}
if (!isset($this->stream)) {
return $key ? null : [];
} elseif (!$key) {
return $key === null ? [] : null;
} elseif ($key === null) {
return $this->customMetadata + stream_get_meta_data($this->stream);
} elseif (isset($this->customMetadata[$key])) {
return $this->customMetadata[$key];
@@ -325,4 +299,36 @@ class Stream implements StreamInterface
return $meta[$key] ?? null;
}
/**
* @see https://www.php.net/manual/en/function.fopen.php
* @see https://www.php.net/manual/en/function.gzopen.php
*/
private static function isReadableMode(string $mode): bool
{
return str_starts_with($mode, 'r') || str_contains($mode, '+');
}
/**
* @see https://www.php.net/manual/en/function.fopen.php
* @see https://www.php.net/manual/en/function.gzopen.php
*/
private static function isWritableMode(string $mode): bool
{
return str_starts_with($mode, 'a')
|| str_starts_with($mode, 'w')
|| str_starts_with($mode, 'x')
|| str_starts_with($mode, 'c')
|| str_contains($mode, '+');
}
private function timedOut(): bool
{
return StreamTimeout::isResourceReadTimedOut($this->stream);
}
private function writeTimedOut(): bool
{
return StreamTimeout::isResourceWriteTimedOut($this->stream);
}
}

View File

@@ -24,10 +24,8 @@ trait StreamDecoratorTrait
/**
* Magic method used to create a new stream if streams are not added in
* the constructor of a decorator (e.g., LazyOpenStream).
*
* @return StreamInterface
*/
public function __get(string $name)
public function __get(string $name): StreamInterface
{
if ($name === 'stream') {
$this->stream = $this->createStream();
@@ -35,25 +33,16 @@ trait StreamDecoratorTrait
return $this->stream;
}
throw new \UnexpectedValueException("$name not found on class");
throw new \UnexpectedValueException(\sprintf('%s not found on class', DiagnosticValue::escape($name)));
}
public function __toString(): string
{
try {
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
}
public function getContents(): string
@@ -84,17 +73,8 @@ trait StreamDecoratorTrait
/**
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
if ($key !== null && !\is_string($key)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.',
\get_debug_type($key)
);
}
return $this->stream->getMetadata($key);
}
@@ -138,54 +118,22 @@ trait StreamDecoratorTrait
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
$this->stream->seek($offset, $whence);
}
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
return $this->stream->read($length);
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
return $this->stream->write($string);
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\StreamInterface;
/**
* @internal
*/
final class StreamTimeout
{
private function __construct()
{
}
public static function read(StreamInterface $stream, int $length, string $timeoutMessage): string
{
try {
$buffer = $stream->read($length);
} catch (TimeoutException $e) {
throw $e;
} catch (\RuntimeException $e) {
self::throwIfReadTimedOut($stream, $timeoutMessage, $e);
throw $e;
}
if ($buffer === '') {
self::throwIfReadTimedOut($stream, $timeoutMessage);
}
return $buffer;
}
public static function throwIfReadTimedOut(
StreamInterface $stream,
string $message,
?\Throwable $previous = null
): void {
if (self::isReadTimedOut($stream)) {
throw new TimeoutException($message, 0, $previous);
}
}
public static function throwIfWriteTimedOut(StreamInterface $stream, ?\Throwable $previous = null): void
{
if (self::isWriteTimedOut($stream)) {
throw new TimeoutException('Unable to write to stream: timed out', 0, $previous);
}
}
public static function isReadTimedOut(StreamInterface $stream): bool
{
try {
if ($stream->getMetadata('timed_out') !== true) {
return false;
}
return !$stream->eof();
} catch (\Throwable $e) {
return false;
}
}
public static function isWriteTimedOut(StreamInterface $stream): bool
{
try {
return $stream->getMetadata('timed_out') === true;
} catch (\Throwable $e) {
return false;
}
}
/**
* @param resource $resource
*/
public static function isResourceReadTimedOut($resource): bool
{
try {
/** @var array<string, mixed> $metadata */
$metadata = stream_get_meta_data($resource);
return ($metadata['timed_out'] ?? false) === true && !feof($resource);
} catch (\Throwable $e) {
return false;
}
}
/**
* @param resource $resource
*/
public static function isResourceWriteTimedOut($resource): bool
{
try {
/** @var array<string, mixed> $metadata */
$metadata = stream_get_meta_data($resource);
return ($metadata['timed_out'] ?? false) === true;
} catch (\Throwable $e) {
return false;
}
}
}

View File

@@ -16,11 +16,10 @@ final class StreamWrapper
/** @var resource */
public $context;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
/** @var string r, r+, or w */
private $mode;
private string $mode;
/**
* Returns a resource representing the stream.
@@ -78,40 +77,67 @@ final class StreamWrapper
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path = null): bool
{
$options = stream_context_get_options($this->context);
$stream = $options['guzzle']['stream'] ?? null;
if (!isset($options['guzzle']['stream'])) {
if (!$stream instanceof StreamInterface) {
return false;
}
$this->mode = $mode;
$this->stream = $options['guzzle']['stream'];
$this->stream = $stream;
return true;
}
public function stream_read(int $count): string
/**
* @return string|false
*/
public function stream_read(int $count)
{
return $this->stream->read($count);
try {
return $this->stream->read($count);
} catch (\RuntimeException $e) {
return false;
}
}
public function stream_write(string $data): int
{
return $this->stream->write($data);
try {
return $this->stream->write($data);
} catch (\RuntimeException $e) {
return -1;
}
}
public function stream_tell(): int
/**
* @return int|false
*/
public function stream_tell()
{
return $this->stream->tell();
try {
return $this->stream->tell();
} catch (\RuntimeException $e) {
return false;
}
}
public function stream_eof(): bool
{
return $this->stream->eof();
try {
return $this->stream->eof();
} catch (\RuntimeException $e) {
return true;
}
}
public function stream_seek(int $offset, int $whence): bool
{
$this->stream->seek($offset, $whence);
try {
$this->stream->seek($offset, $whence);
} catch (\RuntimeException $e) {
return false;
}
return true;
}
@@ -121,8 +147,12 @@ final class StreamWrapper
*/
public function stream_cast(int $cast_as)
{
$stream = clone $this->stream;
$resource = $stream->detach();
try {
$stream = clone $this->stream;
$resource = $stream->detach();
} catch (\RuntimeException $e) {
return false;
}
return $resource ?? false;
}
@@ -146,7 +176,13 @@ final class StreamWrapper
*/
public function stream_stat()
{
if ($this->stream->getSize() === null) {
try {
$size = $this->stream->getSize();
} catch (\RuntimeException $e) {
return false;
}
if ($size === null) {
return false;
}
@@ -161,12 +197,12 @@ final class StreamWrapper
return [
'dev' => 0,
'ino' => 0,
'mode' => $modeMap[$this->mode],
'mode' => $modeMap[$this->mode] ?? 0,
'nlink' => 0,
'uid' => 0,
'gid' => 0,
'rdev' => 0,
'size' => $this->stream->getSize() ?: 0,
'size' => $size,
'atime' => 0,
'mtime' => 0,
'ctime' => 0,

View File

@@ -22,40 +22,19 @@ class UploadedFile implements UploadedFileInterface
UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION',
];
/**
* @var string|null
*/
private $clientFilename;
private ?string $clientFilename;
/**
* @var string|null
*/
private $clientMediaType;
private ?string $clientMediaType;
/**
* @var int
*/
private $error;
private int $error;
/**
* @var string|null
*/
private $file;
private ?string $file = null;
/**
* @var bool
*/
private $moved = false;
private bool $moved = false;
/**
* @var int|null
*/
private $size;
private ?int $size;
/**
* @var StreamInterface|null
*/
private $stream;
private ?StreamInterface $stream = null;
/**
* @param StreamInterface|string|resource $streamOrFile
@@ -68,7 +47,7 @@ class UploadedFile implements UploadedFileInterface
?string $clientMediaType = null
) {
$this->setError($errorStatus);
$this->size = $size;
$this->size = Integers::assertOptionalNonNegativeSize($size, 'Uploaded file size');
$this->clientFilename = $clientFilename;
$this->clientMediaType = $clientMediaType;
@@ -113,11 +92,6 @@ class UploadedFile implements UploadedFileInterface
$this->error = $error;
}
private static function isStringNotEmpty($param): bool
{
return is_string($param) && false === empty($param);
}
/**
* Return true if there is no upload error
*/
@@ -159,11 +133,11 @@ class UploadedFile implements UploadedFileInterface
return new LazyOpenStream($file, 'r+');
}
public function moveTo($targetPath): void
public function moveTo(string $targetPath): void
{
$this->validateActive();
if (false === self::isStringNotEmpty($targetPath)) {
if ($targetPath === '') {
throw new InvalidArgumentException(
'Invalid path provided for move operation; must be a non-empty string'
);
@@ -174,8 +148,13 @@ class UploadedFile implements UploadedFileInterface
? rename($this->file, $targetPath)
: move_uploaded_file($this->file, $targetPath);
} else {
$stream = $this->getStream();
if ($stream->isSeekable()) {
$stream->rewind();
}
Utils::copyToStream(
$this->getStream(),
$stream,
new LazyOpenStream($targetPath, 'w')
);
@@ -183,9 +162,7 @@ class UploadedFile implements UploadedFileInterface
}
if (false === $this->moved) {
throw new RuntimeException(
sprintf('Uploaded file could not be moved to %s', $targetPath)
);
throw new RuntimeException(sprintf('Uploaded file could not be moved to %s', DiagnosticValue::escape($targetPath)));
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use InvalidArgumentException;
use Psr\Http\Message\UploadedFileInterface;
/**
* @internal
*
* @phpstan-type UploadedFileTree array<array-key, UploadedFileInterface|array>
*/
final class UploadedFileNormalizer
{
private function __construct()
{
}
/**
* Return an UploadedFile instance array.
*
* @param array $files An array which respect $_FILES structure
*
* @return UploadedFileTree
*
* @throws InvalidArgumentException for unrecognized values
*/
public static function normalize(array $files): array
{
$normalized = [];
foreach ($files as $key => $value) {
if ($value instanceof UploadedFileInterface) {
$normalized[$key] = $value;
} elseif (is_array($value) && array_key_exists('tmp_name', $value)) {
$normalized[$key] = self::createUploadedFileFromSpec($value);
} elseif (is_array($value)) {
$normalized[$key] = self::normalize($value);
continue;
} else {
throw new InvalidArgumentException('Invalid value in files specification');
}
}
return $normalized;
}
/**
* Create and return an UploadedFile instance from a $_FILES specification.
*
* If the specification represents an array of values, this method will
* delegate to normalizeNestedFileSpec() and return that return value.
*
* @param array $value $_FILES struct
*
* @return UploadedFileInterface|UploadedFileTree
*/
private static function createUploadedFileFromSpec(array $value)
{
self::assertFileSpec($value);
if (is_array($value['tmp_name'])) {
return self::normalizeNestedFileSpec($value);
}
return new UploadedFile(
$value['tmp_name'],
Integers::assertNonNegativeInteger($value['size'], 'Uploaded file size'),
Integers::assertNonNegativeInteger($value['error'], 'Uploaded file error'),
$value['name'] ?? null,
$value['type'] ?? null
);
}
private static function assertFileSpec(array $value): void
{
if (!isset($value['tmp_name'], $value['size'], $value['error'])) {
throw new InvalidArgumentException('Invalid file specification; expected keys "tmp_name", "size", and "error".');
}
}
/**
* Normalize an array of file specifications.
*
* Loops through all nested files and returns a normalized array of
* UploadedFileInterface instances.
*
* @return UploadedFileTree
*/
private static function normalizeNestedFileSpec(array $files = []): array
{
self::assertNestedFileSpec($files);
$normalizedFiles = [];
foreach (array_keys($files['tmp_name']) as $key) {
if (!array_key_exists($key, $files['size']) || !array_key_exists($key, $files['error'])) {
throw new InvalidArgumentException('Invalid nested file specification; expected "tmp_name", "size", and "error" arrays to have matching keys.');
}
$spec = [
'tmp_name' => $files['tmp_name'][$key],
'size' => $files['size'][$key],
'error' => $files['error'][$key],
'name' => $files['name'][$key] ?? null,
'type' => $files['type'][$key] ?? null,
];
$normalizedFiles[$key] = self::createUploadedFileFromSpec($spec);
}
return $normalizedFiles;
}
private static function assertNestedFileSpec(array $files): void
{
foreach (['tmp_name', 'size', 'error'] as $key) {
if (!isset($files[$key]) || !is_array($files[$key])) {
throw new InvalidArgumentException('Invalid nested file specification; expected keys "tmp_name", "size", and "error" to be arrays.');
}
}
foreach (['name', 'type'] as $key) {
if (isset($files[$key]) && !is_array($files[$key])) {
throw new InvalidArgumentException(sprintf('Invalid nested file specification; expected key "%s" to be an array when present.', $key));
}
}
}
}

View File

@@ -17,10 +17,9 @@ use Psr\Http\Message\UriInterface;
class Uri implements UriInterface, \JsonSerializable
{
/**
* Absolute http and https URIs require a host per RFC 7230 Section 2.7
* but in generic URIs the host can be empty. So for http(s) URIs
* we apply this default host when no host is given yet to form a
* valid URI.
* Absolute http and https URIs require a host per RFC 9110 Section 4.2.1
* but in generic URIs the host can be empty. So for http(s) URIs we apply
* this default host when no host is given yet to form a valid URI.
*/
private const HTTP_DEFAULT_HOST = 'localhost';
@@ -36,37 +35,41 @@ class Uri implements UriInterface, \JsonSerializable
'imap' => 143,
'pop' => 110,
'ldap' => 389,
'ws' => 80,
'wss' => 443,
];
private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26', '+' => '%2B'];
/** @var string Uri scheme. */
private $scheme = '';
private string $scheme = '';
/** @var string Uri user info. */
private $userInfo = '';
private string $userInfo = '';
/** @var string Uri host. */
private $host = '';
private string $host = '';
/** @var int|null Uri port. */
private $port;
private ?int $port = null;
/** @var string Uri path. */
private $path = '';
private string $path = '';
/** @var string Uri query string. */
private $query = '';
private string $query = '';
/** @var string Uri fragment. */
private $fragment = '';
private string $fragment = '';
public function __construct(string $uri = '')
{
public function __construct(
#[\SensitiveParameter]
string $uri = ''
) {
if ($uri !== '') {
$parts = self::parse($uri);
$parts = UriParser::parse($uri);
if ($parts === false) {
throw new MalformedUriException("Unable to parse URI: $uri");
throw new MalformedUriException(\sprintf('Unable to parse URI: %s', DiagnosticValue::escape($uri)));
}
try {
$this->applyParts($parts);
@@ -78,108 +81,6 @@ class Uri implements UriInterface, \JsonSerializable
}
}
/**
* UTF-8 aware \parse_url() replacement.
*
* The internal function produces broken output for non ASCII domain names
* (IDN) when used with locales other than "C".
*
* On the other hand, cURL understands IDN correctly only when UTF-8 locale
* is configured ("C.UTF-8", "en_US.UTF-8", etc.).
*
* @see https://bugs.php.net/bug.php?id=52923
* @see https://www.php.net/manual/en/function.parse-url.php#114817
* @see https://curl.haxx.se/libcurl/c/CURLOPT_URL.html#ENCODING
*
* @return array|false
*/
private static function parse(string $url)
{
if (self::isPathNoSchemeReference($url)) {
return self::parsePathNoSchemeReference($url);
}
// Preserve bracketed IPv6 literals before encoding, including dotted IPv4
// tails. DEL (\x7F) is excluded so a raw-DEL host falls through to the
// general path and is rejected rather than silently mutated by parse_url().
$prefix = '';
$ipv6Prefix = preg_match('%\A([0-9A-Za-z+.-]+://\[[^\]\x00-\x20\x7F/?#@]+\])(.*)\z%s', $url, $matches);
if ($ipv6Prefix === false) {
return false;
}
if ($ipv6Prefix === 1) {
/** @var array{0:string, 1:string, 2:string} $matches */
$suffix = $matches[2];
// After the bracketed host only an optional numeric port and/or a
// path, query, or fragment may follow. Anything else (for example
// `:80@evil` or `:80x`) would let parse_url() reinterpret a
// different host.
if (preg_match('%\A(?::[0-9]*)?(?:[/?#].*)?\z%s', $suffix) !== 1) {
return false;
}
$prefix = $matches[1];
$url = $suffix;
}
/** @var string|null */
$encodedUrl = preg_replace_callback(
'%[^:/@?&=#]+%usD',
static function ($matches) {
return urlencode($matches[0]);
},
$url
);
if ($encodedUrl === null) {
return false;
}
$result = parse_url($prefix.$encodedUrl);
if ($result === false) {
return false;
}
return array_map('urldecode', $result);
}
private static function isPathNoSchemeReference(string $url): bool
{
if ($url === '' || $url[0] === '/' || $url[0] === '?' || $url[0] === '#') {
return false;
}
$firstSegment = substr($url, 0, strcspn($url, '/?#'));
return strpos($firstSegment, ':') === false;
}
/**
* @return array{path: string, query?: string, fragment?: string}
*/
private static function parsePathNoSchemeReference(string $url): array
{
$parts = [];
if (false !== ($fragmentPosition = strpos($url, '#'))) {
$parts['fragment'] = substr($url, $fragmentPosition + 1);
$url = substr($url, 0, $fragmentPosition);
}
if (false !== ($queryPosition = strpos($url, '?'))) {
$parts['query'] = substr($url, $queryPosition + 1);
$url = substr($url, 0, $queryPosition);
}
$parts['path'] = $url;
return $parts;
}
public function __toString(): string
{
return self::composeComponents(
@@ -192,20 +93,25 @@ class Uri implements UriInterface, \JsonSerializable
}
/**
* Composes a URI reference string from its various components.
* Composes a URI reference string from its various components according to
* RFC 3986 Section 5.3.
*
* Usually this method does not need to be called manually but instead is used indirectly via
* `Psr\Http\Message\UriInterface::__toString`.
* Usually this method does not need to be called manually but instead is
* used indirectly via `Psr\Http\Message\UriInterface::__toString`.
*
* PSR-7 UriInterface treats an empty component the same as a missing component as
* getQuery(), getFragment() etc. always return a string. This explains the slight
* difference to RFC 3986 Section 5.3.
* PSR-7 UriInterface treats an empty component the same as a missing
* component as `getQuery()`, `getFragment()` etc. always return a string.
* This explains the slight difference to RFC 3986 Section 5.3.
*
* Another adjustment is that the authority separator is added even when the authority is missing/empty
* for the "file" scheme. This is because PHP stream functions like `file_get_contents` only work with
* `file:///myfile` but not with `file:/myfile` although they are equivalent according to RFC 3986. But
* `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to
* that format).
* Another adjustment is that the authority separator is added even when the
* authority is missing/empty for the "file" scheme. This is because PHP
* stream functions like `file_get_contents` only work with `file:///myfile`
* but not with `file:/myfile` although they are equivalent according to RFC
* 3986. But `file:///` is the more common syntax for the file scheme anyway
* (Chrome for example redirects to that format). The separator is omitted
* when such a URI has a rootless or empty path: adding it would turn the
* first path segment into the authority of the composed URI, or compose the
* string `file://`, which cannot be parsed back into a URI.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.3
*/
@@ -218,11 +124,11 @@ class Uri implements UriInterface, \JsonSerializable
$uri .= $scheme.':';
}
if ($authority != '' || $scheme === 'file') {
if ($authority != '' || ($scheme === 'file' && str_starts_with($path, '/'))) {
$uri .= '//'.$authority;
}
if ($authority != '' && $path != '' && $path[0] != '/') {
if ($authority != '' && $path != '' && !str_starts_with($path, '/')) {
$path = '/'.$path;
}
@@ -242,8 +148,8 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI has the default port of the current scheme.
*
* `Psr\Http\Message\UriInterface::getPort` may return null or the standard port. This method can be used
* independently of the implementation.
* `Psr\Http\Message\UriInterface::getPort` may return null or the standard
* port. This method can be used independently of the implementation.
*/
public static function isDefaultPort(UriInterface $uri): bool
{
@@ -254,17 +160,18 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI is absolute, i.e. it has a scheme.
*
* An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true
* if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative
* to another URI, the base URI. Relative references can be divided into several forms:
* - network-path references, e.g. '//example.com/path'
* - absolute-path references, e.g. '/path'
* - relative-path references, e.g. 'subpath'
* An instance of UriInterface can either be an absolute URI or a relative
* reference. An absolute URI has a scheme. A relative reference is used to
* express a URI relative to another URI, the base URI. Relative references
* can be divided into several forms according to RFC 3986 Section 4.2:
* - network-path references, e.g. `//example.com/path`
* - absolute-path references, e.g. `/path`
* - relative-path references, e.g. `subpath`
*
* @see Uri::isNetworkPathReference
* @see Uri::isAbsolutePathReference
* @see Uri::isRelativePathReference
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
public static function isAbsolute(UriInterface $uri): bool
{
@@ -274,7 +181,8 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI is a network-path reference.
*
* A relative reference that begins with two slash characters is termed an network-path reference.
* A relative reference that begins with two slash characters is termed a
* network-path reference.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
@@ -284,9 +192,10 @@ class Uri implements UriInterface, \JsonSerializable
}
/**
* Whether the URI is a absolute-path reference.
* Whether the URI is an absolute-path reference.
*
* A relative reference that begins with a single slash character is termed an absolute-path reference.
* A relative reference that begins with a single slash character is termed
* an absolute-path reference.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
@@ -301,7 +210,8 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI is a relative-path reference.
*
* A relative reference that does not begin with a slash character is termed a relative-path reference.
* A relative reference that does not begin with a slash character is termed
* a relative-path reference.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
@@ -315,9 +225,10 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI is a same-document reference.
*
* A same-document reference refers to a URI that is, aside from its fragment
* component, identical to the base URI. When no base URI is given, only an empty
* URI reference (apart from its fragment) is considered a same-document reference.
* A same-document reference refers to a URI that is, aside from its
* fragment component, identical to the base URI. When no base URI is given,
* only an empty URI reference (apart from its fragment) is considered a
* same-document reference.
*
* @param UriInterface $uri The URI to check
* @param UriInterface|null $base An optional base URI to compare against
@@ -331,7 +242,7 @@ class Uri implements UriInterface, \JsonSerializable
return ($uri->getScheme() === $base->getScheme())
&& ($uri->getAuthority() === $base->getAuthority())
&& ($uri->getPath() === $base->getPath())
&& (self::rawPath($uri) === self::rawPath($base))
&& ($uri->getQuery() === $base->getQuery());
}
@@ -358,10 +269,9 @@ class Uri implements UriInterface, \JsonSerializable
* Creates a new URI with a specific query string value.
*
* Any existing query string values that exactly match the provided key are
* removed and replaced with the given key value pair.
*
* A value of null will set the query string key without a value, e.g. "key"
* instead of "key=value".
* removed and replaced with the given key value pair. A value of null will
* set the query string key without a value, e.g. "key" instead of
* "key=value".
*
* @param UriInterface $uri URI to use as a base.
* @param string $key Key to set.
@@ -377,9 +287,10 @@ class Uri implements UriInterface, \JsonSerializable
}
/**
* Creates a new URI with multiple specific query string values.
* Creates a new URI with multiple query string values.
*
* It has the same behavior as withQueryValue() but for an associative array of key => value.
* It has the same behavior as `withQueryValue()` but for an associative
* array of key => value.
*
* @param UriInterface $uri URI to use as a base.
* @param (string|null)[] $keyValueArray Associative array of key and values
@@ -389,36 +300,24 @@ class Uri implements UriInterface, \JsonSerializable
$result = self::getFilteredQueryString($uri, array_keys($keyValueArray));
foreach ($keyValueArray as $key => $value) {
$result[] = self::generateQueryString((string) $key, $value !== null ? self::stringifyQueryValue($value) : null);
self::assertStringOrNullQueryValue($value);
$result[] = self::generateQueryString((string) $key, $value !== null ? (string) $value : null);
}
return $uri->withQuery(implode('&', $result));
}
/**
* Stringifies a non-null query value, deprecating non-string values that
* guzzlehttp/psr7 3.0 will reject. Non-finite floats are normalized to the
* strings PHP coerces them to, as implicit coercion of NAN emits a warning
* on PHP 8.5.
*
* @param mixed $value
*/
private static function stringifyQueryValue($value): string
private static function assertStringOrNullQueryValue($value): void
{
if (!is_string($value)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.12',
'Passing %s to Uri::withQueryValues() is deprecated; cast it to a string. guzzlehttp/psr7 3.0 will only accept string or null query values.',
\gettype($value)
);
if (is_float($value) && !is_finite($value)) {
return is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
}
if ($value !== null && !is_string($value)) {
throw new \InvalidArgumentException(\sprintf(
'Query string values must be a string or null, %s given.',
\get_debug_type($value)
));
}
return (string) $value;
}
/**
@@ -428,8 +327,10 @@ class Uri implements UriInterface, \JsonSerializable
*
* @throws MalformedUriException If the components do not form a valid URI.
*/
public static function fromParts(array $parts): UriInterface
{
public static function fromParts(
#[\SensitiveParameter]
array $parts
): UriInterface {
$uri = new self();
try {
$uri->applyParts($parts);
@@ -450,32 +351,8 @@ class Uri implements UriInterface, \JsonSerializable
*/
public static function assertValidHost(string $host): void
{
if ($host === '') {
return;
}
// Reject control characters and URI authority delimiters so getHost()
// cannot disagree with the on-wire authority.
$invalidHost = preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host);
if ($invalidHost === false) {
throw new \RuntimeException('Unable to validate URI host: '.preg_last_error_msg());
}
if ($invalidHost === 1) {
throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host));
}
if (strpos($host, '[') !== false || strpos($host, ']') !== false) {
if ($host[0] !== '[' || substr($host, -1) !== ']') {
throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host));
}
return;
}
if (strpos($host, ':') !== false) {
throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host));
if (!Rfc3986::isValidHost($host)) {
throw new \InvalidArgumentException(sprintf('Invalid host: %s', DiagnosticValue::escape($host)));
}
}
@@ -515,9 +392,52 @@ class Uri implements UriInterface, \JsonSerializable
public function getPath(): string
{
if (str_starts_with($this->path, '//')) {
return '/'.ltrim($this->path, '/');
}
return $this->path;
}
/**
* Returns the path as it appears within a URI's string form.
*
* getPath() collapses multiple leading slashes so that a path used in
* isolation cannot be mistaken for a protocol-relative URL. Whole-URI
* operations like reference resolution and normalization (RFC 3986
* Sections 5 and 6) are defined on the URI string form, where the path
* stays verbatim, so they must read the path through this method instead.
* For direct instances of this class the path is derived from the stored
* components, including the leading slash the string form adds to a
* rootless path when an authority is present; for subclasses and other
* implementations the path is split from the string form per RFC 3986
* Appendix B, without validating or decoding any other component.
*
* @throws \RuntimeException If the path cannot be split from the string form.
*
* @internal
*/
public static function rawPath(UriInterface $uri): string
{
if (get_class($uri) === self::class) {
if ($uri->path !== '' && !str_starts_with($uri->path, '/') && $uri->getAuthority() !== '') {
// composeComponents() prepends a slash to a rootless path when
// an authority is present, so the string form uses this path.
return '/'.$uri->path;
}
return $uri->path;
}
$count = preg_match('%^(?:[^:/?#]+:)?(?://[^/?#]*)?([^?#]*)%', (string) $uri, $matches);
if ($count === false) {
throw new \RuntimeException('Unable to read the URI path: '.preg_last_error_msg());
}
return $matches[1] ?? '';
}
public function getQuery(): string
{
return $this->query;
@@ -528,7 +448,7 @@ class Uri implements UriInterface, \JsonSerializable
return $this->fragment;
}
public function withScheme($scheme): UriInterface
public function withScheme(string $scheme): UriInterface
{
$scheme = $this->filterScheme($scheme);
@@ -544,8 +464,11 @@ class Uri implements UriInterface, \JsonSerializable
return $new;
}
public function withUserInfo($user, $password = null): UriInterface
{
public function withUserInfo(
string $user,
#[\SensitiveParameter]
?string $password = null
): UriInterface {
$info = $this->filterUserInfoComponent($user);
if ($password !== null) {
$info .= ':'.$this->filterUserInfoComponent($password);
@@ -562,7 +485,7 @@ class Uri implements UriInterface, \JsonSerializable
return $new;
}
public function withHost($host): UriInterface
public function withHost(string $host): UriInterface
{
$host = $this->filterHost($host);
@@ -577,17 +500,8 @@ class Uri implements UriInterface, \JsonSerializable
return $new;
}
public function withPort($port): UriInterface
public function withPort(?int $port): UriInterface
{
if ($port !== null && !\is_int($port)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to UriInterface::withPort() is deprecated; guzzlehttp/psr7 3.0 requires int|null.',
\get_debug_type($port)
);
}
$port = $this->filterPort($port);
if ($this->port === $port) {
@@ -602,7 +516,7 @@ class Uri implements UriInterface, \JsonSerializable
return $new;
}
public function withPath($path): UriInterface
public function withPath(string $path): UriInterface
{
$path = $this->filterPath($path);
@@ -617,7 +531,7 @@ class Uri implements UriInterface, \JsonSerializable
return $new;
}
public function withQuery($query): UriInterface
public function withQuery(string $query): UriInterface
{
$query = $this->filterQueryAndFragment($query);
@@ -631,7 +545,7 @@ class Uri implements UriInterface, \JsonSerializable
return $new;
}
public function withFragment($fragment): UriInterface
public function withFragment(string $fragment): UriInterface
{
$fragment = $this->filterQueryAndFragment($fragment);
@@ -655,8 +569,10 @@ class Uri implements UriInterface, \JsonSerializable
*
* @param array $parts Array of parse_url parts to apply.
*/
private function applyParts(array $parts): void
{
private function applyParts(
#[\SensitiveParameter]
array $parts
): void {
$this->scheme = isset($parts['scheme'])
? $this->filterScheme($parts['scheme'])
: '';
@@ -667,7 +583,7 @@ class Uri implements UriInterface, \JsonSerializable
? $this->filterHost($parts['host'])
: '';
$this->port = isset($parts['port'])
? $this->filterPort($parts['port'])
? $this->filterPortPart($parts['port'])
: null;
$this->path = isset($parts['path'])
? $this->filterPath($parts['path'])
@@ -686,77 +602,70 @@ class Uri implements UriInterface, \JsonSerializable
}
/**
* @param mixed $scheme
*
* @throws \InvalidArgumentException If the scheme is invalid.
*/
private function filterScheme($scheme): string
private function filterScheme(string $scheme): string
{
if (!is_string($scheme)) {
throw new \InvalidArgumentException('Scheme must be a string');
}
$scheme = Utils::asciiToLower($scheme);
if ($scheme !== '' && !preg_match('/^[a-z][a-z0-9.+-]*$/D', $scheme)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing "%s" as a URI scheme is deprecated; guzzlehttp/psr7 3.0 requires URI schemes to match RFC 3986 syntax and begin with a letter.',
$scheme
);
if (!Rfc3986::isValidScheme($scheme)) {
throw new \InvalidArgumentException(sprintf('Invalid scheme: %s', DiagnosticValue::escape($scheme)));
}
return $scheme;
}
/**
* @param mixed $component
*
* @throws \InvalidArgumentException If the user info is invalid.
*/
private function filterUserInfoComponent($component): string
{
if (!is_string($component)) {
throw new \InvalidArgumentException('User info must be a string');
}
private function filterUserInfoComponent(
#[\SensitiveParameter]
string $component
): string {
return $this->filterComponent(
'/(?:[^%'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.']+|%(?![A-Fa-f0-9]{2}))/',
'/(?:[^%'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.']++|%(?!'.Rfc3986::HEX_OCTET.'))/',
$component,
'Unable to filter URI user info'
);
}
/**
* @param mixed $host
*
* @throws \InvalidArgumentException If the host is invalid.
*/
private function filterHost($host): string
private function filterHost(string $host): string
{
if (!is_string($host)) {
throw new \InvalidArgumentException('Host must be a string');
$host = Utils::asciiToLower($host);
$filtered = \preg_replace_callback('/%'.Rfc3986::HEX_OCTET.'/', static function (array $m): string {
return Utils::asciiToUpper($m[0]);
}, $host);
if ($filtered === null) {
throw new \RuntimeException('Unable to normalize URI host percent-encoding: '.\preg_last_error_msg());
}
self::assertValidHost($filtered);
if (str_starts_with($filtered, '[') && !str_starts_with($filtered, '[v')) {
// assertValidHost() accepted this bracketed value with the same
// filter_var() predicate tryCanonicalizeIpv6() validates with, and
// its pure-PHP parse cannot fail on filter-accepted text, so the
// null guard is defense in depth only.
$canonical = Rfc3986::tryCanonicalizeIpv6(substr($filtered, 1, -1));
if ($canonical !== null) {
$filtered = '['.$canonical.']';
}
}
$host = Utils::asciiToLower($host);
self::assertValidHost($host);
return $host;
return $filtered;
}
/**
* @param mixed $port
*
* @throws \InvalidArgumentException If the port is invalid.
*/
private function filterPort($port): ?int
private function filterPort(?int $port): ?int
{
if ($port === null) {
return null;
}
$port = (int) $port;
if (0 > $port || 0xFFFF < $port) {
throw new \InvalidArgumentException(
sprintf('Invalid port: %d. Must be between 0 and 65535', $port)
@@ -766,6 +675,72 @@ class Uri implements UriInterface, \JsonSerializable
return $port;
}
/**
* @param mixed $port
*
* @throws \InvalidArgumentException If the port is invalid.
*/
private function filterPortPart($port): ?int
{
if (\is_int($port)) {
return $this->filterPort($port);
}
if (\is_string($port) && \ctype_digit($port)) {
// A zero port is accepted here; only Rfc9112::parsePort() rejects
// it for HTTP Host/authority parsing.
if (Rfc3986::isValidPort($port)) {
return (int) \ltrim($port, '0');
}
throw new \InvalidArgumentException(sprintf(
'Invalid port: %s. Must be between 0 and 65535',
\ltrim($port, '0')
));
}
throw new \InvalidArgumentException(sprintf(
'Invalid port: %s. Must be between 0 and 65535',
self::describeInvalidPort($port)
));
}
/**
* @param mixed $port
*/
private static function describeInvalidPort($port): string
{
if (\is_string($port)) {
return DiagnosticValue::escape($port);
}
if (\is_int($port)) {
return (string) $port;
}
if (\is_bool($port)) {
return $port ? 'true' : 'false';
}
if ($port === null) {
return 'null';
}
if (\is_float($port)) {
if (\is_nan($port)) {
return 'NAN';
}
if (\is_infinite($port)) {
return $port > 0 ? 'INF' : '-INF';
}
return \sprintf('%.14G', $port);
}
return \get_debug_type($port);
}
/**
* @param (string|int)[] $keys
*
@@ -783,7 +758,7 @@ class Uri implements UriInterface, \JsonSerializable
return rawurldecode((string) $k);
}, $keys);
return array_filter(explode('&', $current), function ($part) use ($decodedKeys) {
return array_filter(explode('&', $current), static function (string $part) use ($decodedKeys): bool {
return !in_array(rawurldecode(explode('=', $part)[0]), $decodedKeys, true);
});
}
@@ -813,18 +788,12 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Filters the path of a URI
*
* @param mixed $path
*
* @throws \InvalidArgumentException If the path is invalid.
*/
private function filterPath($path): string
private function filterPath(string $path): string
{
if (!is_string($path)) {
throw new \InvalidArgumentException('Path must be a string');
}
return $this->filterComponent(
'/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/]++|%(?![A-Fa-f0-9]{2}))/',
'/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/]++|%(?!'.Rfc3986::HEX_OCTET.'))/',
$path,
'Unable to filter URI path'
);
@@ -833,25 +802,23 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Filters the query string or fragment of a URI.
*
* @param mixed $str
*
* @throws \InvalidArgumentException If the query or fragment is invalid.
*/
private function filterQueryAndFragment($str): string
private function filterQueryAndFragment(string $str): string
{
if (!is_string($str)) {
throw new \InvalidArgumentException('Query and fragment must be a string');
}
return $this->filterComponent(
'/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/',
'/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?!'.Rfc3986::HEX_OCTET.'))/',
$str,
'Unable to filter URI query or fragment'
);
}
private function filterComponent(string $pattern, string $component, string $context): string
{
private function filterComponent(
string $pattern,
#[\SensitiveParameter]
string $component,
string $context
): string {
$filtered = preg_replace_callback($pattern, [$this, 'rawurlencodeMatchZero'], $component);
if ($filtered === null) {
@@ -873,10 +840,10 @@ class Uri implements UriInterface, \JsonSerializable
}
if ($this->getAuthority() === '') {
if (0 === strpos($this->path, '//')) {
if (str_starts_with($this->path, '//')) {
throw new MalformedUriException('The path of a URI without an authority must not start with two slashes "//"');
}
if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) {
if ($this->scheme === '' && str_contains(explode('/', $this->path, 2)[0], ':')) {
throw new MalformedUriException('A relative URI must not have a path beginning with a segment containing a colon');
}
}

View File

@@ -7,19 +7,33 @@ namespace GuzzleHttp\Psr7;
use Psr\Http\Message\UriInterface;
/**
* Provides methods to determine if a modified URL should be considered cross-origin.
* Provides methods to determine if a modified URI should be considered
* cross-origin.
*
* @author Graham Campbell
*/
final class UriComparator
{
/**
* Determines if a modified URL should be considered cross-origin with
* respect to an original URL.
* Determines if a modified URI should be considered cross-origin with
* respect to an original URI.
*
* Two URIs are cross-origin when their scheme, host, or effective port
* differ. Host comparison is case-insensitive, and bracketed IPv6 literals
* are canonicalized to their RFC 5952 form from any PSR-7 implementation
* before comparison, so equivalent spellings of the same address are
* same-origin. IPvFuture literals and bracketed values that cannot be
* parsed as an IPv6 address, such as those carrying zone identifiers,
* still compare as case-insensitive text. Missing ports use the default
* port for `http`, `https`, `ws`, or `wss`. Other schemes do not receive
* implicit default ports.
*
* This helper only compares URI origins. It does not implement redirect
* handling or credential policy.
*/
public static function isCrossOrigin(UriInterface $original, UriInterface $modified): bool
{
if (!Utils::caselessEquals($original->getHost(), $modified->getHost())) {
if (!Utils::caselessEquals(self::normalizeHost($original), self::normalizeHost($modified))) {
return true;
}
@@ -34,6 +48,28 @@ final class UriComparator
return false;
}
private static function normalizeHost(UriInterface $uri): string
{
$host = $uri->getHost();
if (!str_starts_with($host, '[') || !str_ends_with($host, ']')) {
return $host;
}
// Foreign UriInterface implementations may carry non-canonical IPv6
// spellings; canonicalize what is unambiguously an IPv6 address so
// equivalent literals compare as same-origin, and leave IPvFuture,
// zone-identifier, and invalid text to the caseless textual
// comparison. Validation is platform-independent, so a spelling only
// some OS parsers accept, such as zero-padded dotted octets, is
// cross-origin everywhere instead of same-origin on some systems.
$canonical = Rfc3986::tryCanonicalizeIpv6(substr($host, 1, -1));
if ($canonical === null) {
return $host;
}
return '['.$canonical.']';
}
private static function computePort(UriInterface $uri): ?int
{
$port = $uri->getPort();
@@ -42,11 +78,11 @@ final class UriComparator
return $port;
}
if ('http' === $uri->getScheme()) {
if (\in_array($uri->getScheme(), ['http', 'ws'], true)) {
return 80;
}
if ('https' === $uri->getScheme()) {
if (\in_array($uri->getScheme(), ['https', 'wss'], true)) {
return 443;
}

View File

@@ -16,7 +16,8 @@ use Psr\Http\Message\UriInterface;
final class UriNormalizer
{
/**
* Default normalizations which only include the ones that preserve semantics.
* Default normalizations which only include the ones that preserve
* semantics.
*/
public const PRESERVING_NORMALIZATIONS =
self::CAPITALIZE_PERCENT_ENCODING |
@@ -24,10 +25,21 @@ final class UriNormalizer
self::CONVERT_EMPTY_PATH |
self::REMOVE_DEFAULT_HOST |
self::REMOVE_DEFAULT_PORT |
self::REMOVE_DOT_SEGMENTS;
self::REMOVE_DOT_SEGMENTS |
self::CANONICALIZE_IPV6_HOST;
/**
* All letters within a percent-encoding triplet (e.g., "%3A") are case-insensitive, and should be capitalized.
* All letters within a percent-encoding triplet (e.g., "%3A") are
* case-insensitive, and should be capitalized. This applies to the
* userinfo, host, path, query, and fragment components. Bracketed
* IP-literal hosts are skipped as a legacy tolerance for nonstandard values
* other implementations may carry; zone-identifier text was briefly valid
* URI syntax under RFC 6874, which RFC 9844 obsoleted and reverted. The
* userinfo and host are only rewritten when the value returned by the
* implementation matches the normalized form, and a userinfo with an empty
* user segment is never rewritten. No percent-encoding normalization is
* applied to a component that contains malformed percent syntax, such as a
* `%` not followed by two hexadecimal digits.
*
* Example: http://example.org/a%c2%b1b → http://example.org/a%C2%B1b
*/
@@ -36,9 +48,22 @@ final class UriNormalizer
/**
* Decodes percent-encoded octets of unreserved characters.
*
* For consistency, percent-encoded octets in the ranges of ALPHA (%41%5A and %61%7A), DIGIT (%30%39),
* hyphen (%2D), period (%2E), underscore (%5F), or tilde (%7E) should not be created by URI producers and,
* when found in a URI, should be decoded to their corresponding unreserved characters by URI normalizers.
* For consistency, percent-encoded octets in the ranges of ALPHA (%41%5A
* and %61%7A), DIGIT (%30%39), hyphen (%2D), period (%2E), underscore
* (%5F), or tilde (%7E) should not be created by URI producers and, when
* found in a URI, should be decoded to their corresponding unreserved
* characters by URI normalizers. This applies to the userinfo, host, path,
* query, and fragment components. Since the host is case-insensitive and
* PSR-7 requires it to be lowercase, octets decoded in the host are
* lowercased (e.g., "%41" becomes "a"). Bracketed IP-literal hosts are
* skipped as a legacy tolerance for nonstandard values other
* implementations may carry; zone-identifier text was briefly valid URI
* syntax under RFC 6874, which RFC 9844 obsoleted and reverted. The
* userinfo and host are only rewritten when the value returned by the
* implementation matches the normalized form, and a userinfo with an empty
* user segment is never rewritten. No percent-encoding normalization is
* applied to a component that contains malformed percent syntax, such as a
* `%` not followed by two hexadecimal digits.
*
* Example: http://example.org/%7Eusern%61me/ → http://example.org/~username/
*/
@@ -54,11 +79,12 @@ final class UriNormalizer
/**
* Removes the default host of the given URI scheme from the URI.
*
* Only the "file" scheme defines the default host "localhost".
* All of `file:/myfile`, `file:///myfile`, and `file://localhost/myfile`
* are equivalent according to RFC 3986. The first format is not accepted
* by PHPs stream functions and thus already normalized implicitly to the
* second format in the Uri class. See `GuzzleHttp\Psr7\Uri::composeComponents`.
* Only the "file" scheme defines the default host "localhost". All of
* `file:/myfile`, `file:///myfile`, and `file://localhost/myfile` are
* equivalent according to RFC 3986. The first format is not accepted by
* PHPs stream functions and thus already normalized implicitly to the
* second format in the Uri class. See
* `GuzzleHttp\Psr7\Uri::composeComponents`.
*
* Example: file://localhost/myfile → file:///myfile
*/
@@ -84,9 +110,10 @@ final class UriNormalizer
/**
* Paths which include two or more adjacent slashes are converted to one.
*
* Webservers usually ignore duplicate slashes and treat those URIs equivalent.
* But in theory those URIs do not need to be equivalent. So this normalization
* may change the semantics. Encoded slashes (%2F) are not removed.
* Webservers usually ignore duplicate slashes and treat those URIs
* equivalent. But in theory those URIs do not need to be equivalent. So
* this normalization may change the semantics. Encoded slashes (%2F) are
* not removed.
*
* Example: http://example.org//foo///bar.html → http://example.org/foo/bar.html
*/
@@ -95,26 +122,49 @@ final class UriNormalizer
/**
* Sort query parameters with their values in alphabetical order.
*
* However, the order of parameters in a URI may be significant (this is not defined by the standard).
* So this normalization is not safe and may change the semantics of the URI.
* However, the order of parameters in a URI may be significant (this is not
* defined by the standard). So this normalization is not safe and may
* change the semantics of the URI.
*
* Example: ?lang=en&article=fred → ?article=fred&lang=en
*
* Note: The sorting is neither locale nor Unicode aware (the URI query does not get decoded at all) as the
* purpose is to be able to compare URIs in a reproducible way, not to have the params sorted perfectly.
* Note: The sorting is neither locale nor Unicode aware (the URI query does
* not get decoded at all) as the purpose is to be able to compare URIs in a
* reproducible way, not to have the params sorted perfectly.
*/
public const SORT_QUERY_PARAMETERS = 128;
/**
* Canonicalizes IPv6 hosts to their RFC 5952 form.
*
* IPv6 addresses allow leading zeros and multiple placements of the `::`
* elision, so the same address has many textual spellings. The canonical
* form is required for IPv6 literals in URIs by RFC 5952 Section 6 and
* never changes what the URI refers to. Native `Uri` instances already
* guarantee canonical output; for other implementations, the canonical
* host is requested through `withHost()` and the result is kept only when
* the returned `getHost()` exactly matches the requested spelling,
* otherwise this step leaves the URI unchanged while other selected
* normalizations still apply, and setter exceptions propagate.
*
* Example: http://[::0:0a]/ → http://[::a]/
*/
public const CANONICALIZE_IPV6_HOST = 256;
/**
* Returns a normalized URI.
*
* The scheme and host component are already normalized to lowercase per PSR-7 UriInterface.
* This methods adds additional normalizations that can be configured with the $flags parameter.
* The scheme and host component are already normalized to lowercase per
* PSR-7 UriInterface. This method adds additional normalizations that can
* be configured with the `$flags` parameter, which is a bitmask of
* normalizations to apply.
*
* PSR-7 UriInterface cannot distinguish between an empty component and a missing component as
* getQuery(), getFragment() etc. always return a string. This means the URIs "/?#" and "/" are
* treated equivalent which is not necessarily true according to RFC 3986. But that difference
* is highly uncommon in reality. So this potential normalization is implied in PSR-7 as well.
* PSR-7 UriInterface cannot distinguish between an empty component and a
* missing component as `getQuery()`, `getFragment()` etc. always return a
* string. This means the URIs `/?#` and `/` are treated equivalent which is
* not necessarily true according to RFC 3986. But that difference is highly
* uncommon in reality. So this potential normalization is implied in PSR-7
* as well.
*
* @param UriInterface $uri The URI to normalize
* @param int $flags A bitmask of normalizations to apply, see constants
@@ -145,18 +195,24 @@ final class UriNormalizer
$uri = $uri->withPort(null);
}
if ($flags & self::REMOVE_DOT_SEGMENTS && !Uri::isRelativePathReference($uri)) {
$uri = $uri->withPath(UriResolver::removeDotSegments($uri->getPath()));
}
$removeDotSegments = ($flags & self::REMOVE_DOT_SEGMENTS) && !Uri::isRelativePathReference($uri);
if ($flags & self::REMOVE_DUPLICATE_SLASHES) {
$path = preg_replace('#//++#', '/', $uri->getPath());
if ($removeDotSegments || $flags & self::REMOVE_DUPLICATE_SLASHES) {
$path = Uri::rawPath($uri);
if ($path === null) {
throw new \RuntimeException('Unable to remove duplicate slashes from URI path: '.preg_last_error_msg());
if ($removeDotSegments) {
$path = UriResolver::removeDotSegments($path);
}
$uri = $uri->withPath($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() !== '') {
@@ -165,16 +221,22 @@ final class UriNormalizer
$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.
* 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
@@ -189,14 +251,17 @@ final class UriNormalizer
private static function capitalizePercentEncoding(UriInterface $uri): UriInterface
{
$regex = '/(?:%[A-Fa-f0-9]{2})++/';
$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->getPath(), $regex, $callback))
->withPath(self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback))
->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback));
}
@@ -209,17 +274,116 @@ final class UriNormalizer
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->getPath(), $regex, $callback))
->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) {
@@ -229,6 +393,32 @@ final class UriNormalizer
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

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class UriParser
{
private function __construct()
{
}
/**
* UTF-8 aware \parse_url() replacement.
*
* The internal function produces broken output for non ASCII domain names
* (IDN) when used with locales other than "C".
*
* On the other hand, cURL understands IDN correctly only when UTF-8 locale
* is configured ("C.UTF-8", "en_US.UTF-8", etc.).
*
* @see https://bugs.php.net/bug.php?id=52923
* @see https://www.php.net/manual/en/function.parse-url.php#114817
* @see https://curl.se/libcurl/c/CURLOPT_URL.html#ENCODING
*
* @return array|false
*/
public static function parse(string $url)
{
if (self::isPathNoSchemeReference($url)) {
return self::parsePathNoSchemeReference($url);
}
// Preserve bracketed IP-literals (IPv6 or IPvFuture) in scheme, userinfo,
// and network-path authorities before encoding. Userinfo is encoded
// separately so raw bytes cannot reach parse_url(), which mutates
// control characters instead of failing.
$prefix = '';
$ipv6Prefix = preg_match('%\A((?:[0-9A-Za-z+.-]+:)?//)(?:([^/?#@]*)(@))?(\[[^\]\x00-\x20\x7F/?#@]+\])(.*)\z%s', $url, $matches);
if ($ipv6Prefix === false) {
return false;
}
if ($ipv6Prefix === 1) {
/** @var array{0:string, 1:string, 2:string, 3:string, 4:string, 5:string} $matches */
$suffix = $matches[5];
// After the bracketed host only an optional numeric port and/or a
// path, query, or fragment may follow. Anything else (for example
// `:80@evil` or `:80x`) would let parse_url() reinterpret a
// different host.
if (preg_match('%\A(?::[0-9]*)?(?:[/?#].*)?\z%s', $suffix) !== 1) {
return false;
}
// RFC 3986 IP-literals contain no percent-encoding, so reject any
// "%" in the bracketed host rather than letting the urldecode()
// below turn an encoded octet into a different literal. This keeps
// parsing aligned with withHost()/Rfc3986::isValidHost().
if (str_contains($matches[4], '%')) {
return false;
}
$prefix = $matches[1];
if ($matches[3] === '@') {
/** @var string|null */
$encodedUserInfo = preg_replace_callback(
'%[^:/@?&=#]+%usD',
static function (array $matches): string {
return urlencode($matches[0]);
},
$matches[2]
);
if ($encodedUserInfo === null) {
return false;
}
$prefix .= $encodedUserInfo.'@';
}
$prefix .= $matches[4];
$url = $suffix;
}
/** @var string|null */
$encodedUrl = preg_replace_callback(
'%[^:/@?&=#]+%usD',
static function (array $matches): string {
return urlencode($matches[0]);
},
$url
);
if ($encodedUrl === null) {
return false;
}
$result = parse_url($prefix.$encodedUrl);
if ($result === false) {
return false;
}
return array_map('urldecode', $result);
}
private static function isPathNoSchemeReference(string $url): bool
{
if ($url === '' || str_starts_with($url, '/') || str_starts_with($url, '?') || str_starts_with($url, '#')) {
return false;
}
$firstSegment = substr($url, 0, strcspn($url, '/?#'));
return !str_contains($firstSegment, ':');
}
/**
* @return array{path: string, query?: string, fragment?: string}
*/
private static function parsePathNoSchemeReference(string $url): array
{
$parts = [];
if (false !== ($fragmentPosition = strpos($url, '#'))) {
$parts['fragment'] = substr($url, $fragmentPosition + 1);
$url = substr($url, 0, $fragmentPosition);
}
if (false !== ($queryPosition = strpos($url, '?'))) {
$parts['query'] = substr($url, $queryPosition + 1);
$url = substr($url, 0, $queryPosition);
}
$parts['path'] = $url;
return $parts;
}
}

View File

@@ -16,7 +16,15 @@ use Psr\Http\Message\UriInterface;
final class UriResolver
{
/**
* Removes dot segments from a path and returns the new path.
* Removes dot segments from a path and returns the new path according to
* RFC 3986 Section 5.2.4.
*
* Excess `..` segments above the root of an absolute path are dropped
* without consuming the root, so the result can begin with `//` (e.g.
* `/..//a` becomes `//a`). Such a path is not valid for a URI without an
* authority (RFC 3986 Section 3.3); `resolve()` and
* `UriNormalizer::normalize()` serialize it with a `/.` prefix in that
* case, like the WHATWG URL Standard.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
*/
@@ -28,9 +36,15 @@ final class UriResolver
$results = [];
$segments = explode('/', $path);
// The first segment of an absolute path is the empty root marker producing the
// leading slash. RFC 3986 Section 5.2.4 (2C) drops ".." segments in excess of
// the path hierarchy without consuming the root, so it must never be popped.
$floor = $segments[0] === '' ? 1 : 0;
foreach ($segments as $segment) {
if ($segment === '..') {
array_pop($results);
if (count($results) > $floor) {
array_pop($results);
}
} elseif ($segment !== '.') {
$results[] = $segment;
}
@@ -38,7 +52,7 @@ final class UriResolver
$newPath = implode('/', $results);
if ($path[0] === '/' && (!isset($newPath[0]) || $newPath[0] !== '/')) {
if (str_starts_with($path, '/') && !str_starts_with($newPath, '/')) {
// Re-add the leading slash if necessary for cases like "/.."
$newPath = '/'.$newPath;
} elseif ($newPath !== '' && ($segment === '.' || $segment === '..')) {
@@ -51,7 +65,37 @@ final class UriResolver
}
/**
* Converts the relative URI into a new URI that is resolved against the base URI.
* Returns the path, prefixed with "/." when it would otherwise start the
* URI's string form with an authority-like "//".
*
* A URI without an authority cannot hold a path beginning with "//" (RFC
* 3986 Section 3.3), but removeDotSegments() can produce one. The "/."
* prefix serializes such a path unambiguously, the same way the WHATWG URL
* Standard does, and resolves back to the same path. Hostless http and
* https Uri instances gain the default localhost host when the path is
* written, so the path cannot be mistaken for an authority and the prefix
* is not added.
*
* @see https://url.spec.whatwg.org/#url-serializing
*
* @internal
*/
public static function guardedPath(UriInterface $uri, string $path): string
{
if (!str_starts_with($path, '//') || $uri->getAuthority() !== '') {
return $path;
}
if ($uri instanceof Uri && ($uri->getScheme() === 'http' || $uri->getScheme() === 'https')) {
return $path;
}
return '/.'.$path;
}
/**
* Converts the relative URI into a new URI that is resolved against the
* base URI.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2
*/
@@ -63,61 +107,69 @@ final class UriResolver
}
if ($rel->getScheme() != '') {
return $rel->withPath(self::removeDotSegments($rel->getPath()));
return $rel->withPath(self::guardedPath($rel, self::removeDotSegments(Uri::rawPath($rel))));
}
if ($rel->getAuthority() != '') {
return $rel
->withScheme($base->getScheme())
->withPath(self::removeDotSegments($rel->getPath()));
->withPath(self::removeDotSegments(Uri::rawPath($rel)));
}
if ($rel->getPath() === '') {
$targetPath = $base->getPath();
$targetQuery = $rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery();
$relPath = Uri::rawPath($rel);
if ($relPath === '') {
// the base path is used as-is per RFC 3986 Section 5.2.2, so it must not be
// rewritten through a getPath()/withPath() round-trip
return $base
->withQuery($rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery())
->withFragment($rel->getFragment());
}
if (str_starts_with($relPath, '/')) {
$targetPath = $relPath;
} else {
if ($rel->getPath()[0] === '/') {
$targetPath = $rel->getPath();
$basePath = Uri::rawPath($base);
if ($base->getAuthority() != '' && $basePath === '') {
$targetPath = '/'.$relPath;
} else {
if ($base->getAuthority() != '' && $base->getPath() === '') {
$targetPath = '/'.$rel->getPath();
$lastSlashPos = strrpos($basePath, '/');
if ($lastSlashPos === false) {
$targetPath = $relPath;
} else {
$lastSlashPos = strrpos($base->getPath(), '/');
if ($lastSlashPos === false) {
$targetPath = $rel->getPath();
} else {
$targetPath = substr($base->getPath(), 0, $lastSlashPos + 1).$rel->getPath();
}
$targetPath = substr($basePath, 0, $lastSlashPos + 1).$relPath;
}
}
$targetPath = self::removeDotSegments($targetPath);
$targetQuery = $rel->getQuery();
}
$targetPath = self::removeDotSegments($targetPath);
return $base
->withPath($targetPath)
->withQuery($targetQuery)
->withPath(self::guardedPath($base, $targetPath))
->withQuery($rel->getQuery())
->withFragment($rel->getFragment());
}
/**
* Returns the target URI as a relative reference from the base URI.
*
* This method is the counterpart to resolve():
* This method is the counterpart to `resolve()`:
*
* (string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target))
*
* One use-case is to use the current request URI as base URI and then generate relative links in your documents
* to reduce the document size or offer self-contained downloadable document archives.
* One use case is to use the current request URI as the base URI and then
* generate relative links in your documents to reduce the document size or
* offer self-contained downloadable document archives.
*
* $base = new Uri('http://example.com/a/b/');
* echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'.
* echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'.
* echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'.
* echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'.
* echo UriResolver::relativize($base, new Uri('http://example.com')); // prints '//example.com'.
*
* This method also accepts a target that is already relative and will try to relativize it further. Only a
* relative-path reference will be returned as-is.
* This method also accepts a target that is already relative and will try
* to relativize it further. Only a relative-path reference will be returned
* as-is.
*
* echo UriResolver::relativize($base, new Uri('/a/b/c')); // prints 'c' as well
*/
@@ -140,37 +192,68 @@ final class UriResolver
return $target->withScheme('');
}
// A same-authority target with an empty path can only be expressed by a
// network-path reference (RFC 3986 Section 5.2.2).
if (self::needsNetworkPathReference($base, $target)) {
return $target->withScheme('');
}
// We must remove the path before removing the authority because if the path starts with two slashes, the URI
// would turn invalid. And we also cannot set a relative path before removing the authority, as that is also
// invalid.
$emptyPathUri = $target->withScheme('')->withPath('')->withUserInfo('')->withPort(null)->withHost('');
if ($base->getPath() !== $target->getPath()) {
if (Uri::rawPath($base) !== Uri::rawPath($target)) {
return $emptyPathUri->withPath(self::getRelativePath($base, $target));
}
if ($base->getQuery() === $target->getQuery()) {
if ($base->getQuery() === $target->getQuery() && ($target->getFragment() !== '' || $base->getFragment() === '')) {
// Only the target fragment is left. And it must be returned even if base and target fragment are the same.
return $emptyPathUri->withQuery('');
}
// If the base URI has a query but the target has none, we cannot return an empty path reference as it would
// inherit the base query component when resolving.
// If the base URI has a query or fragment that the target lacks, we cannot return an empty path
// reference as it would inherit that base component when resolving.
if ($target->getQuery() === '') {
$segments = explode('/', $target->getPath());
$segments = explode('/', Uri::rawPath($target));
/** @var string $lastSegment */
$lastSegment = end($segments);
return $emptyPathUri->withPath($lastSegment === '' ? './' : $lastSegment);
// A reference to an empty last segment must be prefixed with "./". The same applies
// to a segment with a colon character, which would be mistaken for a scheme name.
if ($lastSegment === '' || str_contains($lastSegment, ':')) {
$lastSegment = "./$lastSegment";
}
return $emptyPathUri->withPath($lastSegment);
}
return $emptyPathUri;
}
/**
* Whether relativizing to $target requires a network-path reference.
*
* A same-authority target with an empty path is expressible by a shorter
* relative reference unless resolving one would inherit a base component
* the target lacks: the base path (kept by any empty-path reference), or
* the base query or fragment (inherited by the empty reference).
*/
private static function needsNetworkPathReference(UriInterface $base, UriInterface $target): bool
{
if ($target->getAuthority() === '' || Uri::rawPath($target) !== '') {
return false;
}
return Uri::rawPath($base) !== ''
|| ($base->getQuery() !== '' && $target->getQuery() === '')
|| ($base->getFragment() !== '' && $target->getFragment() === '' && $base->getQuery() === $target->getQuery());
}
private static function getRelativePath(UriInterface $base, UriInterface $target): string
{
$sourceSegments = explode('/', $base->getPath());
$targetSegments = explode('/', $target->getPath());
$sourceSegments = explode('/', Uri::rawPath($base));
$targetSegments = explode('/', Uri::rawPath($target));
array_pop($sourceSegments);
$targetLastSegment = array_pop($targetSegments);
foreach ($sourceSegments as $i => $segment) {
@@ -186,10 +269,10 @@ final class UriResolver
// A reference to am empty last segment or an empty first sub-segment must be prefixed with "./".
// This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used
// as the first segment of a relative-path reference, as it would be mistaken for a scheme name.
if ('' === $relativePath || false !== strpos(explode('/', $relativePath, 2)[0], ':')) {
if ($relativePath === '' || str_contains(explode('/', $relativePath, 2)[0], ':')) {
$relativePath = "./$relativePath";
} elseif ('/' === $relativePath[0]) {
if ($base->getAuthority() != '' && $base->getPath() === '') {
} elseif (str_starts_with($relativePath, '/')) {
if ($base->getAuthority() != '' && Uri::rawPath($base) === '') {
// In this case an extra slash is added by resolve() automatically. So we must not add one here.
$relativePath = ".$relativePath";
} else {

View File

@@ -4,12 +4,17 @@ declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
final class Utils
{
private function __construct()
{
}
/**
* Converts ASCII uppercase letters in a string to lowercase.
*
@@ -70,9 +75,9 @@ final class Utils
}
/**
* Remove the items given by the keys, case insensitively from the data.
* Remove the items given by the keys from the data, case-insensitively.
*
* @param (string|int)[] $keys
* @param array<array-key, string|int> $keys
*/
public static function caselessRemove(array $keys, array $data): array
{
@@ -93,12 +98,18 @@ final class Utils
/**
* Copy the contents of a stream into another stream until the given number
* of bytes have been read.
* of bytes have been read, returning the number of bytes copied as an
* `int`. On 32-bit PHP, an unbounded copy larger than `PHP_INT_MAX` bytes
* cannot be represented by that return type. 64-bit PHP is not affected.
*
* The copy stops if the destination write returns 0, for example a
* BufferStream at its high water mark or a full DroppingStream. For a
* guaranteed full copy use a normal writable stream such as a file or
* php://temp stream.
* The destination must accept writes that make positive progress. Streams
* that return 0 as a backpressure or drop signal (a `BufferStream` at its
* high water mark, or a full `DroppingStream`) will cause this method to
* throw. For full copies, use a normal writable stream such as a file or
* `php://temp` stream.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a source read or destination write cannot make progress.
*
* @param StreamInterface $source Stream to read from
* @param StreamInterface $dest Stream to write to
@@ -107,63 +118,71 @@ final class Utils
*
* @throws \RuntimeException on error.
*/
public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): void
public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): int
{
$bufferSize = 8192;
$copied = 0;
if ($maxLen === -1) {
while (!$source->eof()) {
$buf = $source->read($bufferSize);
$buf = StreamTimeout::read($source, $bufferSize, 'Unable to read from stream: timed out');
if ($buf === '') {
break;
}
if (!self::writeAll($dest, $buf)) {
break;
}
self::writeAll($dest, $buf);
$copied = Integers::add($copied, strlen($buf));
}
} else {
$remaining = $maxLen;
while ($remaining > 0 && !$source->eof()) {
$buf = $source->read(min($bufferSize, $remaining));
$buf = StreamTimeout::read($source, min($bufferSize, $remaining), 'Unable to read from stream: timed out');
$len = strlen($buf);
if (!$len) {
break;
}
$remaining -= $len;
if (!self::writeAll($dest, $buf)) {
break;
}
self::writeAll($dest, $buf);
$copied = Integers::add($copied, $len);
}
}
return $copied;
}
/**
* Writes the full buffer to the destination, retrying short writes.
*
* Returns false when the destination write returns 0 or less.
*/
private static function writeAll(StreamInterface $dest, string $buf): bool
private static function writeAll(StreamInterface $dest, string $buf): void
{
$written = 0;
$len = strlen($buf);
while ($written < $len) {
$result = $dest->write(substr($buf, $written));
try {
$result = $dest->write(substr($buf, $written));
} catch (TimeoutException $e) {
throw $e;
} catch (\RuntimeException $e) {
StreamTimeout::throwIfWriteTimedOut($dest, $e);
throw $e;
}
if ($result <= 0) {
return false;
StreamTimeout::throwIfWriteTimedOut($dest);
throw new \RuntimeException('Unable to write to stream');
}
$written += $result;
}
return true;
}
/**
* Copy the contents of a stream into a string until the given number of
* bytes have been read.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a stream read cannot make progress.
*
* @param StreamInterface $stream Stream to read
* @param int $maxLen Maximum number of bytes to read. Pass -1
* to read the entire stream.
@@ -176,7 +195,7 @@ final class Utils
if ($maxLen === -1) {
while (!$stream->eof()) {
$buf = $stream->read(1048576);
$buf = StreamTimeout::read($stream, 1048576, 'Unable to read from stream: timed out');
if ($buf === '') {
break;
}
@@ -188,7 +207,7 @@ final class Utils
$len = 0;
while (!$stream->eof() && $len < $maxLen) {
$buf = $stream->read($maxLen - $len);
$buf = StreamTimeout::read($stream, $maxLen - $len, 'Unable to read from stream: timed out');
if ($buf === '') {
break;
}
@@ -202,8 +221,11 @@ final class Utils
/**
* Calculate a hash of a stream.
*
* This method reads the entire stream to calculate a rolling hash, based
* on PHP's `hash_init` functions.
* This method reads the entire stream to calculate a rolling hash, based on
* PHP's `hash_init` functions.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a stream read cannot make progress.
*
* @param StreamInterface $stream Stream to calculate the hash for
* @param string $algo Hash algorithm (e.g. md5, crc32, etc)
@@ -221,7 +243,12 @@ final class Utils
$ctx = hash_init($algo);
while (!$stream->eof()) {
hash_update($ctx, $stream->read(1048576));
$buf = StreamTimeout::read($stream, 1048576, 'Unable to calculate stream hash: timed out');
if ($buf === '') {
break;
}
hash_update($ctx, $buf);
}
$out = hash_final($ctx, $rawOutput);
@@ -242,16 +269,27 @@ final class Utils
* or non-empty arrays of strings.
* - remove_headers: (array) Remove the given headers. Values may be
* strings or integers.
* - body: (mixed) Sets the given body. Present non-null values are converted
* with self::streamFor(), including scalar values, resources, streams,
* iterators, callable arrays, closures, invokable objects, and objects
* with __toString(). String inputs remain literal bodies.
* - uri: (UriInterface) Set the URI.
* - body: (mixed) Sets the given body. Present non-null values are
* converted with self::streamFor(), including resources, streams,
* iterators, callable arrays, closures, invokable objects, and stringable
* objects. String inputs remain literal bodies.
* - uri: (UriInterface) Set the URI. When the URI contains a host, the
* Host header is updated from it, and combining this with an explicit
* Host entry in set_headers throws an InvalidArgumentException. Apply
* an intentional Host override separately with withHeader() afterwards.
* - query: (string) Set the query string value of the URI.
* - version: (string) Set the protocol version.
*
* @param RequestInterface $request Request to clone and modify.
* @param array $changes Changes to apply.
* @param array{
* method?: string,
* set_headers?: array<array-key, string|non-empty-array<array-key, string>>,
* remove_headers?: array<array-key, string|int>,
* body?: resource|string|StreamInterface|callable|\Iterator|\Stringable,
* uri?: UriInterface,
* query?: string,
* version?: string
* } $changes Changes to apply.
*/
public static function modifyRequest(RequestInterface $request, array $changes): RequestInterface
{
@@ -259,16 +297,20 @@ final class Utils
return $request;
}
self::warnOnInvalidModifyRequestChanges($changes);
self::assertValidModifyRequestChanges($changes);
$headers = $request->getHeaders();
if (!isset($changes['uri'])) {
$uri = $request->getUri();
} else {
// Remove the host header if one is on the URI
$host = $changes['uri']->getHost();
/** @var UriInterface */
$uri = $changes['uri'];
$host = $uri->getHost();
if ($host !== '') {
Uri::assertValidHost($host);
if (isset($changes['set_headers']) && is_array($changes['set_headers'])) {
foreach (array_keys($changes['set_headers']) as $header) {
if (self::asciiToLower((string) $header) === 'host') {
@@ -281,15 +323,15 @@ final class Utils
$changes['set_headers']['Host'] = $host;
if ($port = $changes['uri']->getPort()) {
$port = $uri->getPort();
if ($port !== null) {
$standardPorts = ['http' => 80, 'https' => 443];
$scheme = $changes['uri']->getScheme();
if (isset($standardPorts[$scheme]) && $port != $standardPorts[$scheme]) {
$scheme = $uri->getScheme();
if (!isset($standardPorts[$scheme]) || $port != $standardPorts[$scheme]) {
$changes['set_headers']['Host'] .= ':'.$port;
}
}
}
$uri = $changes['uri'];
}
if (!empty($changes['remove_headers'])) {
@@ -316,6 +358,7 @@ final class Utils
// Match Request::__construct() by adding a Host header when one is not provided.
if (!$hasHost && $uri->getHost() !== '') {
$host = $uri->getHost();
Uri::assertValidHost($host);
if (($port = $uri->getPort()) !== null) {
$host .= ':'.$port;
@@ -372,45 +415,45 @@ final class Utils
/**
* @param array<array-key, mixed> $changes
*/
private static function warnOnInvalidModifyRequestChanges(array $changes): void
private static function assertValidModifyRequestChanges(array $changes): void
{
foreach (['method', 'query', 'version'] as $key) {
if (\array_key_exists($key, $changes) && !\is_string($changes[$key])) {
self::warnOnInvalidModifyRequestChange($key, 'string', $changes[$key]);
self::assertValidModifyRequestChange($key, 'string', $changes[$key]);
}
}
if (\array_key_exists('uri', $changes) && !$changes['uri'] instanceof UriInterface) {
self::warnOnInvalidModifyRequestChange('uri', 'UriInterface', $changes['uri']);
self::assertValidModifyRequestChange('uri', 'UriInterface', $changes['uri']);
}
if (\array_key_exists('body', $changes) && $changes['body'] === null) {
self::warnOnInvalidModifyRequestChange('body', 'resource|string|int|float|bool|StreamInterface|callable|\Iterator|\Stringable', $changes['body']);
self::assertValidModifyRequestChange('body', 'resource|string|StreamInterface|callable|\Iterator|\Stringable', $changes['body']);
}
if (\array_key_exists('set_headers', $changes)) {
if (!\is_array($changes['set_headers'])) {
self::warnOnInvalidModifyRequestChange('set_headers', 'array<array-key, string|non-empty-array<array-key, string>>', $changes['set_headers']);
self::assertValidModifyRequestChange('set_headers', 'array<array-key, string|non-empty-array<array-key, string>>', $changes['set_headers']);
} else {
foreach ($changes['set_headers'] as $header => $value) {
$headerPath = \sprintf('set_headers.%s', (string) $header);
if (\is_array($value)) {
if ($value === []) {
self::warnOnInvalidModifyRequestChange($headerPath, 'string|non-empty-array<array-key, string>', $value);
self::assertValidModifyRequestChange($headerPath, 'string|non-empty-array<array-key, string>', $value);
break;
}
foreach ($value as $index => $item) {
if (!\is_string($item)) {
self::warnOnInvalidModifyRequestChange(\sprintf('%s.%s', $headerPath, (string) $index), 'string', $item);
self::assertValidModifyRequestChange(\sprintf('%s.%s', $headerPath, (string) $index), 'string', $item);
break 2;
}
}
} elseif (!\is_string($value)) {
self::warnOnInvalidModifyRequestChange($headerPath, 'string|non-empty-array<array-key, string>', $value);
self::assertValidModifyRequestChange($headerPath, 'string|non-empty-array<array-key, string>', $value);
break;
}
@@ -423,14 +466,14 @@ final class Utils
}
if (!\is_array($changes['remove_headers'])) {
self::warnOnInvalidModifyRequestChange('remove_headers', 'array<array-key, string|int>', $changes['remove_headers']);
self::assertValidModifyRequestChange('remove_headers', 'array<array-key, string|int>', $changes['remove_headers']);
return;
}
foreach ($changes['remove_headers'] as $index => $header) {
if (!\is_string($header) && !\is_int($header)) {
self::warnOnInvalidModifyRequestChange(\sprintf('remove_headers.%s', (string) $index), 'string|int', $header);
self::assertValidModifyRequestChange(\sprintf('remove_headers.%s', (string) $index), 'string|int', $header);
return;
}
@@ -440,21 +483,17 @@ final class Utils
/**
* @param mixed $value
*/
private static function warnOnInvalidModifyRequestChange(string $key, string $expected, $value): void
private static function assertValidModifyRequestChange(string $key, string $expected, $value): void
{
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to Utils::modifyRequest() change "%s" is deprecated; guzzlehttp/psr7 3.0 requires %s.',
\get_debug_type($value),
$key,
$expected
);
throw new \InvalidArgumentException(\sprintf('Utils::modifyRequest() change "%s" must be %s; %s provided.', DiagnosticValue::escape($key), $expected, \get_debug_type($value)));
}
/**
* Read a line from the stream up to the maximum allowed buffer length.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a stream read cannot make progress.
*
* @param StreamInterface $stream Stream to read from
* @param int|null $maxLength Maximum buffer length
*/
@@ -464,7 +503,7 @@ final class Utils
$size = 0;
while (!$stream->eof()) {
if ('' === ($byte = $stream->read(1))) {
if ('' === ($byte = StreamTimeout::read($stream, 1, 'Unable to read line from stream: timed out'))) {
return $buffer;
}
$buffer .= $byte;
@@ -478,54 +517,113 @@ final class Utils
}
/**
* Redact the password in the user info part of a URI.
* Redact the user info part of a URI.
*
* Returns the URI with the whole userinfo component replaced by "***"
* when one is present, so neither the username nor the password survives
* into logs and diagnostics. A URI without userinfo is returned
* unchanged.
*/
public static function redactUserInfo(UriInterface $uri): UriInterface
{
$userInfo = $uri->getUserInfo();
public static function redactUserInfo(
#[\SensitiveParameter]
UriInterface $uri
): UriInterface {
return $uri->getUserInfo() === '' ? $uri : $uri->withUserInfo('***');
}
if (false !== ($pos = \strpos($userInfo, ':'))) {
return $uri->withUserInfo(\substr($userInfo, 0, $pos), '***');
/**
* Redacts the userinfo of a raw URI string wherever it appears in a
* subject string.
*
* The needle is taken verbatim from the raw URI rather than from parsed
* components, so credentials that URI normalization would rewrite, such
* as raw control bytes or unencoded reserved characters, are still found
* in text that embeds the URI exactly as given, for example transport
* error messages. A URI without "://" is treated as authority-form: a
* host and port with optional userinfo.
*
* A URI that does not parse has no trustworthy authority boundary, so
* everything between any scheme and its last "@" is redacted as a safe-side
* fallback.
*
* @param string $subject Text that may embed the URI
* @param string $uri Raw URI whose userinfo is redacted in the text
*/
public static function redactUserInfoInString(string $subject, string $uri): string
{
if (\strpos($uri, '@') === false) {
return $subject;
}
return $uri;
$schemePosition = \strpos($uri, '://');
$remainder = $schemePosition === false ? $uri : \substr($uri, $schemePosition + 3);
if (\parse_url($schemePosition === false ? 'http://'.$uri : $uri) === false) {
// Raw '/', '?', or '#' separators may sit inside the credentials
// of a URI that defeats parse_url(), so the redaction cannot stop
// at the apparent authority.
$atPosition = \strrpos($remainder, '@');
if ($atPosition === false || $atPosition === 0) {
return $subject;
}
return \str_replace(\substr($remainder, 0, $atPosition).'@', '***@', $subject);
}
$authority = \substr($remainder, 0, \strcspn($remainder, '/?#'));
$atPosition = \strrpos($authority, '@');
if ($atPosition === false || $atPosition === 0) {
// A parseable URI with '@' only past its authority, or with an
// empty userinfo, carries no credentials to redact.
return $subject;
}
return \str_replace(\substr($authority, 0, $atPosition).'@', '***@', $subject);
}
/**
* Create a new stream based on the input type.
*
* Options is an associative array that can contain the following keys:
* Options are provided as an associative array that can contain the
* following keys:
* - metadata: Array of custom metadata.
* - size: Size of the stream.
*
* This method accepts the following `$resource` types:
* - `Psr\Http\Message\StreamInterface`: Returns the value as-is.
* - `string`: Creates a stream object that uses the given string as the contents.
* - `resource`: Creates a stream object that wraps the given PHP stream resource.
* - `Iterator`: If the provided value implements `Iterator`, then a read-only
* stream object will be created that wraps the given iterable. Each time the
* stream is read from, data from the iterator will fill a buffer and will be
* continuously called until the buffer is equal to the requested read size.
* Subsequent read calls will first read from the buffer and then call `next`
* on the underlying iterator until it is exhausted.
* - `object` with `__toString()`: If the object has the `__toString()` method,
* the object will be cast to a string and then a stream will be returned that
* uses the string value.
* - `string`: Creates a stream object that uses the given string as the
* contents.
* - `resource`: Creates a stream object that wraps the given PHP stream
* resource.
* - `Iterator`: If the provided value implements `Iterator`, then a
* read-only stream object will be created that wraps the given iterable.
* Each time the stream is read from, data from the iterator will fill a
* buffer and will be continuously called until the buffer is equal to the
* requested read size. Yielded strings, integers, finite floats,
* booleans, `null`, and stringable objects are converted to string
* chunks; non-finite floats and other values throw
* `UnexpectedValueException` when the stream is read. Values that
* stringify to an empty string are skipped while the iterator advances.
* Subsequent read calls will first read from the buffer and then call
* `next` on the underlying iterator until it is exhausted.
* - `object` with `__toString()`: If the object has the `__toString()`
* method, the object will be cast to a string and then a stream will be
* returned that uses the string value.
* - `NULL`: When `null` is passed, an empty stream object is returned.
* - `callable`: When a callable array, closure, or invokable object is passed
* and no earlier resource or object rule applies, a read-only stream object
* will be created that invokes the given callable. The callable is invoked
* with the suggested number of bytes to read. The callable can return fewer
* or more bytes than requested, but MUST return `false` or `null` when there
* is no more data to return. Any additional bytes will be buffered and used
* in subsequent reads. String inputs are always treated as string bodies,
* even when they name callable functions.
* - `callable`: When a callable array, closure, or invokable object is
* passed and no earlier resource or object rule applies, a read-only
* stream object will be created that invokes the given callable. The
* callable is invoked with the suggested number of bytes to read. The
* callable can return fewer or more bytes than requested, but MUST return
* a non-empty string to provide data and MUST return `false` or `null`
* when there is no more data to return. Any additional bytes will be
* buffered and used in subsequent reads. String inputs are always treated
* as string bodies, even when they name callable functions.
*
* Passing a non-string scalar (`int`, `float`, or `bool`) is deprecated; cast
* it to a string instead. guzzlehttp/psr7 3.0 will reject non-string scalars.
*
* @param resource|string|int|float|bool|StreamInterface|callable|\Iterator|null $resource Entity body data
* @param array{size?: int, metadata?: array} $options Additional options
* @param resource|string|StreamInterface|callable|\Iterator|\Stringable|null $resource Entity body data
* @param array{size?: int, metadata?: array} $options Additional options
*
* @throws \InvalidArgumentException if the $resource arg is not valid.
*/
@@ -533,24 +631,15 @@ final class Utils
{
if (is_scalar($resource)) {
if (!is_string($resource)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.12',
'Passing %s to Utils::streamFor() is deprecated; cast it to a string. guzzlehttp/psr7 3.0 will only accept string, resource, StreamInterface, Stringable, Iterator, callable, or null.',
\gettype($resource)
);
if (is_float($resource) && !is_finite($resource)) {
// Normalized only to avoid PHP 8.5's (string) NAN warning
// while deprecated; 3.0 rejects non-finite floats with every
// other non-string scalar.
$resource = is_nan($resource) ? 'NAN' : ($resource > 0 ? 'INF' : '-INF');
}
throw new \InvalidArgumentException(\sprintf(
'Cannot create a stream from %s; pass a string, resource, StreamInterface, Stringable, Iterator, callable, or null.',
\get_debug_type($resource)
));
}
$stream = self::tryFopen('php://temp', 'r+');
if ($resource !== '') {
fwrite($stream, (string) $resource);
fwrite($stream, $resource);
fseek($stream, 0);
}
@@ -578,14 +667,29 @@ final class Utils
if ($resource instanceof StreamInterface) {
return $resource;
} elseif ($resource instanceof \Iterator) {
return new PumpStream(function () use ($resource) {
if (!$resource->valid()) {
return false;
}
$result = $resource->current();
$resource->next();
return new PumpStream(function (int $length) use ($resource) {
while ($resource->valid()) {
$result = $resource->current();
$resource->next();
return $result;
if (is_float($result) && !is_finite($result)) {
throw new \UnexpectedValueException('Iterator must not yield non-finite float values');
}
if ($result === null || is_scalar($result)) {
$data = (string) $result;
} elseif (is_object($result) && method_exists($result, '__toString')) {
$data = (string) $result;
} else {
throw new \UnexpectedValueException('Iterator must yield scalar, null, or stringable values');
}
if ($data !== '') {
return $data;
}
}
return false;
}, $options);
} elseif (method_exists($resource, '__toString')) {
return self::streamFor((string) $resource, $options);
@@ -599,14 +703,14 @@ final class Utils
return new PumpStream($resource, $options);
}
throw new \InvalidArgumentException('Invalid resource type: '.gettype($resource));
throw new \InvalidArgumentException('Invalid resource type: '.\get_debug_type($resource));
}
/**
* Safely opens a PHP stream resource using a filename.
*
* When fopen fails, PHP normally raises a warning. This function adds an
* error handler that checks for errors and throws an exception instead.
* When `fopen()` fails, PHP normally raises a warning. This function adds
* an error handler that checks for errors and throws an exception instead.
*
* @param string $filename File to open
* @param string $mode Mode used to open the file
@@ -619,12 +723,7 @@ final class Utils
{
$ex = null;
set_error_handler(static function (int $errno, string $errstr) use ($filename, $mode, &$ex): bool {
$ex = new \RuntimeException(sprintf(
'Unable to open "%s" using mode "%s": %s',
$filename,
$mode,
$errstr
));
$ex = new \RuntimeException(sprintf('Unable to open %s using mode %s: %s', DiagnosticValue::escape($filename), DiagnosticValue::escape($mode), DiagnosticValue::escape($errstr)));
return true;
});
@@ -633,12 +732,7 @@ final class Utils
/** @var resource $handle */
$handle = fopen($filename, $mode);
} catch (\Throwable $e) {
$ex = new \RuntimeException(sprintf(
'Unable to open "%s" using mode "%s": %s',
$filename,
$mode,
$e->getMessage()
), 0, $e);
$ex = new \RuntimeException(sprintf('Unable to open %s using mode %s: %s', DiagnosticValue::escape($filename), DiagnosticValue::escape($mode), $e->getMessage()), 0, $e);
}
restore_error_handler();
@@ -654,10 +748,13 @@ final class Utils
/**
* Safely gets the contents of a given stream.
*
* When stream_get_contents fails, PHP normally raises a warning. This
* When `stream_get_contents()` fails, PHP normally raises a warning. This
* function adds an error handler that checks for errors and throws an
* exception instead.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a stream read cannot make progress.
*
* @param resource $stream
*
* @throws \RuntimeException if the stream cannot be read
@@ -666,10 +763,7 @@ final class Utils
{
$ex = null;
set_error_handler(static function (int $errno, string $errstr) use (&$ex): bool {
$ex = new \RuntimeException(sprintf(
'Unable to read stream contents: %s',
$errstr
));
$ex = new \RuntimeException(sprintf('Unable to read stream contents: %s', DiagnosticValue::escape($errstr)));
return true;
});
@@ -679,13 +773,18 @@ final class Utils
$contents = stream_get_contents($stream);
if ($contents === false) {
$ex = new \RuntimeException('Unable to read stream contents');
$ex = StreamTimeout::isResourceReadTimedOut($stream)
? new TimeoutException('Unable to read stream contents: timed out')
: new \RuntimeException('Unable to read stream contents');
} elseif (StreamTimeout::isResourceReadTimedOut($stream)) {
$ex = new TimeoutException('Unable to read stream contents: timed out');
}
} catch (TimeoutException $e) {
$ex = $e;
} catch (\Throwable $e) {
$ex = new \RuntimeException(sprintf(
'Unable to read stream contents: %s',
$e->getMessage()
), 0, $e);
$ex = StreamTimeout::isResourceReadTimedOut($stream)
? new TimeoutException('Unable to read stream contents: timed out', 0, $e)
: new \RuntimeException(sprintf('Unable to read stream contents: %s', $e->getMessage()), 0, $e);
}
restore_error_handler();
@@ -699,11 +798,11 @@ final class Utils
}
/**
* Returns a UriInterface for the given value.
* Returns a `UriInterface` for the given value.
*
* This function accepts a string or UriInterface and returns a
* UriInterface for the given value. If the value is already a
* UriInterface, it is returned as-is.
* This function accepts a string or `UriInterface` and returns a
* `UriInterface` for the given value. If the value is already a
* `UriInterface`, it is returned as-is.
*
* @param string|UriInterface $uri
*