This commit is contained in:
Javier Casares 2026-08-24 13:41:08 +00:00
commit 476389b914
93 changed files with 1864 additions and 305 deletions

View file

@ -3,6 +3,32 @@
Please refer to [UPGRADING](UPGRADING.md) guide for upgrading to a major version.
## 8.1.0 - Upcoming
### Added
- Add PHP 8.6+ stream TLS session sharing, while persistent sharing remains cURL-only
- Add support for PHP 8.6
- Add in-transfer resends of seekable streamed uploads when PHP exposes `CURLOPT_SEEKFUNCTION`
### Changed
- Adjusted `guzzlehttp/promises` version constraint to `^3.0.2`
- Adjusted `guzzlehttp/psr7` version constraint to `^3.1`
- Classify stream handler transport failures using PHP 8.6+ structured stream error codes
- Hide URI credentials, queries, and fragments in automatic exception messages
- Match `no_proxy` rules against IPv4 hosts written in the shorthand a transport reads as an address
- Hold the cURL easy handle out of the reuse pool until a silent retry has been dispatched
- Treat a deferred resolved with a pending retry promise as progress when waiting on cURL transfers
## 8.0.3 - 2026-08-24
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^3.0.1`
## 8.0.2 - 2026-08-05
### Changed

View file

@ -30,8 +30,8 @@ composer require guzzlehttp/guzzle
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 8.0 | Latest | >=7.4,<8.6 |
| 7.15 | Maintenance | >=7.2.5,<8.6 |
| 8.1 | Latest | >=7.4,<8.7 |
| 7.15 | Maintenance | >=7.2.5,<8.7 |
| 6.5 | End of Life | >=5.5,<8.0 |
## Quick Start

View file

@ -199,8 +199,10 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
throw new InvalidArgumentException('handler must be a callable');
} elseif ($handlerOptions !== []) {
throw new InvalidArgumentException('The "max_host_connections" and "max_total_connections" client options require Guzzle to create the default handler. Configure the options on the CurlMultiHandler constructor for numeric enforcement, or on the StreamHandler constructor to reject enabled response streaming, when providing a custom handler.');
} elseif (\in_array($transportSharingMode, [TransportSharing::HANDLER_REQUIRE, TransportSharing::PERSISTENT_REQUIRE], true)) {
throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler or CurlMultiHandler when providing a custom cURL handler.');
} elseif ($transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler, CurlMultiHandler, or StreamHandler when providing a custom handler.');
} elseif ($transportSharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler or CurlMultiHandler when providing a custom handler.');
}
$factory = new HttpFactory();

View file

@ -60,14 +60,14 @@ class RequestException extends TransferException implements RequestExceptionInte
$label = 'Unsuccessful request';
}
$uri = \GuzzleHttp\Psr7\Utils::redactUserInfo($request->getUri());
$uri = \GuzzleHttp\Psr7\Utils::redactUriForMessage($request->getUri());
// Client error: `GET /` resulted in a `404 Not Found` response: <html> ... (truncated)
$message = \sprintf(
'%s: `%s %s` resulted in a `%s %s` response',
$label,
DiagnosticValue::escape($request->getMethod()),
DiagnosticValue::escape($uri->__toString()),
$uri,
$response->getStatusCode(),
DiagnosticValue::escape($response->getReasonPhrase())
);

View file

@ -110,6 +110,26 @@ final class CurlFactory implements CurlFactoryInterface
*/
private const CURL_READFUNC_ABORT = 0x10000000;
/**
* libcurl's CURL_SEEKFUNC_OK value.
*/
private const CURL_SEEKFUNC_OK = 0;
/**
* libcurl's CURL_SEEKFUNC_FAIL value.
*/
private const CURL_SEEKFUNC_FAIL = 1;
/**
* libcurl's CURL_SEEKFUNC_CANTSEEK value.
*/
private const CURL_SEEKFUNC_CANTSEEK = 2;
/**
* Maximum number of times libcurl may replay a streamed request body.
*/
private const CURL_SEEK_MAX_REPLAYS = 3;
/**
* libcurl's CURLE_SEND_FAIL_REWIND value.
*/
@ -372,6 +392,7 @@ final class CurlFactory implements CurlFactoryInterface
* @param array<int|string, mixed> $conf
*/
private function applyCurlOptions(
#[\SensitiveParameter]
$handle,
#[\SensitiveParameter]
array $conf
@ -712,6 +733,7 @@ final class CurlFactory implements CurlFactoryInterface
self::addConflictingCurlOption($options, 'CURLOPT_UPLOAD', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_POSTFIELDS', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_READFUNCTION', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_SEEKFUNCTION', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_READDATA', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_INFILE', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_INFILESIZE', 'the request body');
@ -952,8 +974,10 @@ final class CurlFactory implements CurlFactoryInterface
/**
* @param resource|\CurlHandle $handle
*/
private function discardHandle($handle): void
{
private function discardHandle(
#[\SensitiveParameter]
$handle
): void {
$failure = null;
try {
@ -980,8 +1004,10 @@ final class CurlFactory implements CurlFactoryInterface
/**
* @param resource|\CurlHandle $handle
*/
private function clearEasyHandleCallbacks($handle): void
{
private function clearEasyHandleCallbacks(
#[\SensitiveParameter]
$handle
): void {
curl_setopt($handle, \CURLOPT_HEADERFUNCTION, null);
curl_setopt($handle, \CURLOPT_READFUNCTION, null);
curl_setopt($handle, \CURLOPT_WRITEFUNCTION, null);
@ -991,6 +1017,10 @@ final class CurlFactory implements CurlFactoryInterface
curl_setopt($handle, (int) \constant('CURLOPT_PREREQFUNCTION'), null);
}
if (\defined('CURLOPT_SEEKFUNCTION')) {
curl_setopt($handle, (int) \constant('CURLOPT_SEEKFUNCTION'), null);
}
if (\defined('CURLOPT_XFERINFOFUNCTION')) {
curl_setopt($handle, (int) \constant('CURLOPT_XFERINFOFUNCTION'), null);
}
@ -1136,22 +1166,40 @@ final class CurlFactory implements CurlFactoryInterface
): PromiseInterface {
// Get error information and release the handle to the factory.
$ctx = self::createErrorContext($easy);
if (self::shouldRetryFailedRewind($easy)) {
// Release after dispatching the retry so the replacement
// transfer cannot reuse this native handle ID.
try {
if ($onStats !== null && $stats !== null) {
$onStats($stats);
}
return self::retryFailedRewind($handler, $easy, $ctx);
} finally {
$factory->release($easy);
}
}
$factory->release($easy);
if ($onStats !== null && $stats !== null) {
$onStats($stats);
}
if (self::shouldRetryFailedRewind($easy)) {
return self::retryFailedRewind($handler, $easy, $ctx);
}
if (self::isChallengeRewindFailure($easy)) {
$ctx['error'] = 'The server issued an authentication challenge '
.'after the request body had already been sent, and the body '
.'could not be rewound to resend it. The request was not '
.'retried because a retry replays the same challenge. See '
.'https://bugs.php.net/bug.php?id=47204 for more information.';
if (self::isResponseRewindFailure($easy)) {
$status = $easy->response !== null ? $easy->response->getStatusCode() : 0;
$ctx['error'] = $status === 401 || $status === 407
? 'The server issued an authentication challenge '
.'after the request body had already been sent, and the body '
.'could not be rewound to resend it. The request was not '
.'retried because a retry replays the same challenge. See '
.'https://bugs.php.net/bug.php?id=47204 for more information.'
: 'The server responded after the request body had already '
.'been sent, and the body could not be rewound for '
.'libcurl\'s automatic retry. The request was not retried '
.'because a retry replays the same response. See '
.'https://bugs.php.net/bug.php?id=47204 for more information.';
}
return self::createRejection($easy, $ctx);
@ -1167,6 +1215,8 @@ final class CurlFactory implements CurlFactoryInterface
private static function hasLocalFailure(EasyHandle $easy): bool
{
return $easy->bodyReadTimeoutException !== null
|| $easy->bodyRewindTimeoutException !== null
|| $easy->bodyRewindException !== null
|| $easy->bodyReadException !== null
|| $easy->responseHeaderException !== null
|| $easy->sinkWriteTimeoutException !== null
@ -1181,10 +1231,10 @@ final class CurlFactory implements CurlFactoryInterface
return false;
}
if (self::isChallengeRewindFailure($easy)) {
// Re-issuing the identical request replays the same challenge and
if (self::isResponseRewindFailure($easy)) {
// Re-issuing the identical request replays the same response and
// the same in-transfer rewind, so retrying is futile and the
// challenge response is surfaced instead.
// received response is surfaced instead.
return false;
}
@ -1192,9 +1242,10 @@ final class CurlFactory implements CurlFactoryInterface
//
// - errno === CURLE_SEND_FAIL_REWIND (65): libcurl needed to rewind an
// already-partially-sent upload to resend it on a reused connection
// that died before any response arrived, but could not, because PHP
// registers no seek callback for a streamed request body. See
// https://bugs.php.net/bug.php?id=47204.
// that died before any response arrived, but could not. PHP builds
// without CURLOPT_SEEKFUNCTION register no seek callback for a
// streamed request body (https://bugs.php.net/bug.php?id=47204);
// builds with it can still report 65 when the body is not seekable.
//
// - errno === 0: libcurl reported success yet no usable response
// reached us. This is the legacy curl_multi silent-failure variant of
@ -1207,13 +1258,15 @@ final class CurlFactory implements CurlFactoryInterface
/**
* Whether the transfer failed because libcurl could not rewind the
* request body to resend it in reply to a challenge response, such as a
* 401 or 407 during multi-pass authentication. libcurl's only other
* rewind triggers are followed redirects, which the built-in handlers
* never enable, and reused connections that died, which cannot have
* produced a response.
* request body to resend it in reply to a response, such as a 401 or
* 407 during multi-pass authentication, or a 417 that makes libcurl
* retry an Expect: 100-continue upload without the expectation. Rewinds
* for replays that follow no response, such as a reused connection that
* died or a refused HTTP/2 stream, retry through
* shouldRetryFailedRewind() instead. Followed redirects also rewind,
* but the built-in handlers never enable them.
*/
private static function isChallengeRewindFailure(EasyHandle $easy): bool
private static function isResponseRewindFailure(EasyHandle $easy): bool
{
return $easy->errno === self::CURLE_SEND_FAIL_REWIND
&& $easy->response !== null
@ -1283,6 +1336,23 @@ final class CurlFactory implements CurlFactoryInterface
);
}
if ($easy->bodyRewindTimeoutException) {
// Rewinding the request body stalled, which is a caller-stream failure.
return self::createRequestOrResponseRejection(
$easy,
'Timed out while rewinding the request body',
$easy->bodyRewindTimeoutException
);
}
if ($easy->bodyRewindException) {
$message = $easy->bodyRewindException->getMessage() !== ''
? $easy->bodyRewindException->getMessage()
: 'Failed to rewind the request body';
return self::createRequestOrResponseRejection($easy, $message, $easy->bodyRewindException);
}
if ($easy->bodyReadException) {
$message = $easy->bodyReadException->getMessage() !== ''
? $easy->bodyReadException->getMessage()
@ -1340,9 +1410,9 @@ final class CurlFactory implements CurlFactoryInterface
);
if ('' !== $sanitizedError) {
$redactedUriString = Psr7\DiagnosticValue::escape(Psr7\Utils::redactUserInfo($uri)->__toString());
if ($redactedUriString !== '' && false === \strpos($sanitizedError, $redactedUriString)) {
$message .= \sprintf(' for %s', $redactedUriString);
$safeUri = Psr7\Utils::redactUriForMessage($uri);
if ($safeUri !== '' && false === \strpos($sanitizedError, $safeUri)) {
$message .= \sprintf(' for %s', $safeUri);
}
}
@ -1440,15 +1510,7 @@ final class CurlFactory implements CurlFactoryInterface
$error = self::redactProxyUserInfo($error, $proxy);
$baseUri = $uri->withQuery('')->withFragment('');
$baseUriString = $baseUri->__toString();
if ('' !== $baseUriString) {
$redactedUriString = Psr7\Utils::redactUserInfo($baseUri)->__toString();
$error = str_replace($baseUriString, $redactedUriString, $error);
}
return Psr7\DiagnosticValue::escape($error);
return UriDiagnostic::redactInMessage($error, $uri);
}
private static function redactProxyUserInfo(
@ -2242,6 +2304,9 @@ final class CurlFactory implements CurlFactoryInterface
$conf[\CURLOPT_FILE],
$conf[\CURLOPT_INFILE]
);
if (\defined('CURLOPT_SEEKFUNCTION')) {
unset($conf[(int) \constant('CURLOPT_SEEKFUNCTION')]);
}
if (\trim($easy->request->getHeaderLine('Content-Length'), " \n\r\t\0\x0B") !== '0') {
$this->removeHeader('Content-Length', $conf);
}
@ -2301,7 +2366,13 @@ final class CurlFactory implements CurlFactoryInterface
* @return int|string
*/
$remaining = $contentLength;
$conf[\CURLOPT_READFUNCTION] = static function ($ch, $fd, int $length) use ($easy, $body, &$remaining) {
$conf[\CURLOPT_READFUNCTION] = static function (
#[\SensitiveParameter]
$ch,
#[\SensitiveParameter]
$fd,
int $length
) use ($easy, $body, &$remaining) {
if ($remaining === 0) {
return '';
}
@ -2339,6 +2410,56 @@ final class CurlFactory implements CurlFactoryInterface
return $data;
};
if (\defined('CURLOPT_SEEKFUNCTION')) {
// Give libcurl a seek callback so it can reposition the
// streamed upload itself when a transfer must resend the body
// (multi-pass auth such as NTLM/Negotiate, or a reused
// connection that died) instead of failing with
// CURLE_SEND_FAIL_REWIND. See
// https://bugs.php.net/bug.php?id=47204. A failed seek is
// recorded so the rejection carries the caller's exception
// and the failed-rewind retry does not fire.
$successfulSeeks = 0;
$conf[(int) \constant('CURLOPT_SEEKFUNCTION')] = static function (
#[\SensitiveParameter]
$ch,
int $offset,
int $origin
) use ($easy, $body, $contentLength, &$remaining, &$successfulSeeks): int {
if ($origin !== \SEEK_SET) {
return self::CURL_SEEKFUNC_CANTSEEK;
}
try {
if (!$body->isSeekable()) {
return self::CURL_SEEKFUNC_CANTSEEK;
}
if ($offset < 0 || ($contentLength !== null && $offset > $contentLength)) {
throw new \RuntimeException('Request body seek offset is outside the declared Content-Length');
}
if ($successfulSeeks >= self::CURL_SEEK_MAX_REPLAYS) {
throw new \RuntimeException(\sprintf('Request body cannot be replayed more than %d times', self::CURL_SEEK_MAX_REPLAYS));
}
$body->seek($offset, \SEEK_SET);
} catch (TimeoutException $e) {
$easy->bodyRewindTimeoutException = $e;
return self::CURL_SEEKFUNC_FAIL;
} catch (\Throwable $e) {
$easy->bodyRewindException = $e;
return self::CURL_SEEKFUNC_FAIL;
}
$remaining = $contentLength === null ? null : $contentLength - $offset;
++$successfulSeeks;
return self::CURL_SEEKFUNC_OK;
};
}
}
// If the Expect header is not present, prevent curl from adding it
@ -2692,11 +2813,12 @@ final class CurlFactory implements CurlFactoryInterface
/** @var (callable(int, int, int, int): mixed)|null $progress */
$conf[\CURLOPT_NOPROGRESS] = false;
$progressCallback = static function ($resource, $downloadSize, $downloaded, $uploadSize, $uploaded) use ($easy, $progress): int {
// Abort the transfer when the request body read failed (the
// cross-version abort path, since older PHP ignores the read
// callback's return). progressAborted is left unset so the
// failure is classified from the stored request-body exception.
if ($easy->bodyReadTimeoutException !== null || $easy->bodyReadException !== null) {
// Abort the transfer when the request body read or rewind
// failed (the cross-version abort path, since older PHP
// ignores the read callback's return). progressAborted is left
// unset so the failure is classified from the stored
// request-body exception.
if ($easy->bodyReadTimeoutException !== null || $easy->bodyRewindTimeoutException !== null || $easy->bodyRewindException !== null || $easy->bodyReadException !== null) {
return 1;
}

View file

@ -319,7 +319,7 @@ final class CurlMultiHandler
// Never null: assigned below before any wait can invoke this.
/** @var Promise<ResponseInterface, mixed> $promise */
if (!P\Is::pending($promise)) {
if ($easy->deferredSettled || !P\Is::pending($promise)) {
return;
}
@ -1328,8 +1328,10 @@ final class CurlMultiHandler
/**
* @param resource|\CurlHandle $handle
*/
private function clearEasyHandleCallbacks($handle): void
{
private function clearEasyHandleCallbacks(
#[\SensitiveParameter]
$handle
): void {
curl_setopt($handle, \CURLOPT_HEADERFUNCTION, null);
curl_setopt($handle, \CURLOPT_READFUNCTION, null);
curl_setopt($handle, \CURLOPT_WRITEFUNCTION, null);
@ -1339,6 +1341,10 @@ final class CurlMultiHandler
curl_setopt($handle, (int) \constant('CURLOPT_PREREQFUNCTION'), null);
}
if (\defined('CURLOPT_SEEKFUNCTION')) {
curl_setopt($handle, (int) \constant('CURLOPT_SEEKFUNCTION'), null);
}
if (\defined('CURLOPT_XFERINFOFUNCTION')) {
curl_setopt($handle, (int) \constant('CURLOPT_XFERINFOFUNCTION'), null);
}
@ -1347,8 +1353,11 @@ final class CurlMultiHandler
/**
* @param resource|\CurlHandle $handle
*/
private function removeHandleFromMulti(int $id, $handle): void
{
private function removeHandleFromMulti(
int $id,
#[\SensitiveParameter]
$handle
): void {
// Removing a still-running transfer performs a final progress update
// that can run a user progress callback, so removal is guarded like
// native execution.
@ -1530,6 +1539,7 @@ final class CurlMultiHandler
$result = CurlFactory::finish($this, $entry['easy'], $this->factory);
} catch (\Throwable $e) {
if (P\Is::pending($entry['deferred'])) {
$entry['easy']->deferredSettled = true;
$entry['deferred']->reject($e);
}
@ -1537,6 +1547,7 @@ final class CurlMultiHandler
}
if (P\Is::pending($entry['deferred'])) {
$entry['easy']->deferredSettled = true;
$entry['deferred']->resolve($result);
}
}

View file

@ -106,6 +106,24 @@ final class CurlShareHandleState
));
}
/**
* Whether cURL can share both the DNS and SSL session cache state that
* required handler-lifetime sharing needs, checked without configuring a
* share handle so default handler selection can fall back to the stream
* handler instead.
*/
public static function supportsHandlerRequireShare(): bool
{
return CurlVersion::supportsHandlerSharing()
&& CurlVersion::supportsSslSessionSharing()
&& \function_exists('curl_share_init')
&& \function_exists('curl_share_setopt')
&& \defined('CURLOPT_SHARE')
&& \defined('CURLSHOPT_SHARE')
&& \defined('CURL_LOCK_DATA_DNS')
&& \defined('CURL_LOCK_DATA_SSL_SESSION');
}
private static function createHandlerShareOrNull(string $mode): ?self
{
try {

View file

@ -52,6 +52,12 @@ final class EasyHandle
*/
public bool $usesPipewait = false;
/**
* @var bool Whether processMessages() settled the deferred promise, which
* a rewind retry settles with a still-pending promise
*/
public bool $deferredSettled = false;
/**
* @var ResponseInterface|null Received response (if any)
*/
@ -108,6 +114,16 @@ final class EasyHandle
*/
public ?TimeoutException $bodyReadTimeoutException = null;
/**
* @var TimeoutException|null Exception during request body rewind timeout.
*/
public ?TimeoutException $bodyRewindTimeoutException = null;
/**
* @var \Throwable|null Exception during request body rewind.
*/
public ?\Throwable $bodyRewindException = null;
/**
* @var \Throwable|null Exception during request body read.
*/

View file

@ -26,6 +26,7 @@ use GuzzleHttp\RequestOptions;
use GuzzleHttp\TransferStats;
use GuzzleHttp\TransportSharing;
use GuzzleHttp\Utils;
use Openssl\Session;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
@ -46,6 +47,12 @@ final class StreamHandler
'transport_sharing' => true,
];
/**
* Peers tracked by the TLS session cache, matching libcurl's share handle
* TLS session cache (25 peers, up to 2 sessions each).
*/
private const TLS_SESSION_CACHE_MAX_KEYS = 25;
private const CONNECTION_ERRORS = [
'php_network_getaddresses:',
'getaddrinfo',
@ -76,6 +83,21 @@ final class StreamHandler
'unexpected eof while reading',
];
// PHP's HTTP wrapper can collapse wrapper-level errors to OpenFailed.
// These codes classify only errors delivered directly to the context
// handler. NetworkRecvFailed is omitted because PHP currently emits it for
// handshake-time certificate policy failures.
private const STRUCTURED_NETWORK_ERROR_CODES = [
'NetworkSendFailed',
];
// SslNotSupported can be emitted directly by a transport that cannot
// enable crypto. Standard ext-openssl proxy failures are normally
// collapsed to OpenFailed and continue to use message classification.
private const STRUCTURED_CONNECTION_ERROR_CODES = [
'SslNotSupported',
];
/**
* Default idle timeout in milliseconds when the "read_timeout" option is
* not set. Matches PHP's default_socket_timeout default, which the
@ -93,6 +115,8 @@ final class StreamHandler
private bool $connectionCapsConfigured = false;
private ?StreamTlsSessionCache $sessionCache = null;
/**
* Accepts an associative array of options:
*
@ -225,19 +249,7 @@ final class StreamHandler
if (!$e instanceof TransferException) {
$message = $e->getMessage();
if (self::isSendError($message)) {
$e = self::isConnectTimeoutError($message)
? new NetworkTimeoutException($message, $request, $e)
: new NetworkException($message, $request, $e);
} elseif (self::isConnectTimeoutError($message)) {
$e = new ConnectTimeoutException($message, $request, $e);
} elseif (self::isConnectionError($message)) {
$e = new ConnectException($message, $request, $e);
} elseif (self::isNetworkError($message)) {
$e = new NetworkException($message, $request, $e);
} else {
$e = new RequestException($message, $request, 0, $e);
}
$e = self::createStreamFailureException($message, $request, $e, []);
}
$this->invokeStats($options, $request, $startTime, null, $e);
@ -296,6 +308,56 @@ final class StreamHandler
return false;
}
/**
* @param string[] $streamErrorCodes
*/
private static function createStreamFailureException(
#[\SensitiveParameter]
string $message,
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
\Exception $previous,
array $streamErrorCodes
): TransferException {
if (self::isSendError($message) || self::hasStreamErrorCode($streamErrorCodes, self::STRUCTURED_NETWORK_ERROR_CODES)) {
return self::isConnectTimeoutError($message)
? new NetworkTimeoutException($message, $request, $previous)
: new NetworkException($message, $request, $previous);
}
if (self::isConnectTimeoutError($message)) {
return new ConnectTimeoutException($message, $request, $previous);
}
if (self::hasStreamErrorCode($streamErrorCodes, self::STRUCTURED_CONNECTION_ERROR_CODES) || self::isConnectionError($message)) {
return new ConnectException($message, $request, $previous);
}
if (self::isNetworkError($message)) {
return new NetworkException($message, $request, $previous);
}
return new RequestException($message, $request, 0, $previous);
}
/**
* @param string[] $streamErrorCodes
* @param string[] $codes
*/
private static function hasStreamErrorCode(array $streamErrorCodes, array $codes): bool
{
foreach ($streamErrorCodes as $streamErrorCode) {
foreach ($codes as $code) {
if ($streamErrorCode === $code) {
return true;
}
}
}
return false;
}
private function invokeStats(
#[\SensitiveParameter]
array $options,
@ -829,8 +891,11 @@ final class StreamHandler
*
* @throws \RuntimeException when the callback returns false or resource creation emits an error.
*/
private function createResource(callable $callback)
{
private function createResource(
callable $callback,
#[\SensitiveParameter]
?UriInterface $diagnosticUri = null
) {
$errors = [];
\set_error_handler(static function (int $_, string $msg, string $file, int $line) use (&$errors): bool {
$errors[] = [
@ -852,7 +917,10 @@ final class StreamHandler
$details = [];
foreach ($errors as $err) {
foreach ($err as $key => $value) {
$details[] = \sprintf('[%s] %s', $key, Psr7\DiagnosticValue::escape((string) $value));
$rendered = $key === 'message' && $diagnosticUri !== null
? UriDiagnostic::redactInMessage((string) $value, $diagnosticUri)
: Psr7\DiagnosticValue::escape((string) $value);
$details[] = \sprintf('[%s] %s', $key, $rendered);
}
}
@ -877,6 +945,10 @@ final class StreamHandler
array $options,
string $body
) {
// Report a stream-open failure against the request passed here, not the
// Connection: close clone built for the wire.
$callerRequest = $request;
// HTTP/1.1 streams using the PHP stream wrapper require a
// Connection: close header
if ($request->getProtocolVersion() === '1.1'
@ -892,6 +964,7 @@ final class StreamHandler
$params = [];
$context = $this->getDefaultContext($request, $body);
$customSslContext = [];
if (isset($options['on_headers']) && !\is_callable($options['on_headers'])) {
throw new InvalidArgumentException('on_headers must be callable');
@ -921,10 +994,17 @@ final class StreamHandler
self::rejectConflictingStreamContextOptions($streamContext);
self::rejectUnsupportedStreamContextOptions($streamContext);
$context = \array_replace_recursive($context, $streamContext);
$sslContext = $streamContext['ssl'] ?? null;
if (\is_array($sslContext)) {
$customSslContext = $sslContext;
}
}
$this->addDefaultTlsMinimum($request, $context);
$commitTlsSessions = $this->applyTlsSessionResumption($request, $context, $customSslContext);
// The context timeout governs connecting and the header-phase packet
// gaps: the idle timeout, tightened to the deadline when that is
// lower; -1 disables it so default_socket_timeout is never consulted.
@ -936,6 +1016,10 @@ final class StreamHandler
$context['http']['timeout'] = -1;
}
$streamErrorCodes = [];
$captureStreamErrors = true;
$this->addStructuredStreamErrorHandler($context, $streamErrorCodes, $captureStreamErrors);
$uri = $this->resolveHost($request, $options);
$contextResource = $this->createResource(
@ -944,8 +1028,8 @@ final class StreamHandler
}
);
return $this->createResource(
function () use ($uri, $contextResource, $idleTimeout, $timeout) {
try {
$resource = $this->createResource(function () use ($uri, $contextResource, $idleTimeout, $timeout) {
$this->lastDeadline = $timeout > 0 ? Clock::now() + $timeout / 1000 : null;
// Blank the from ini setting for the transfer so ambient
@ -982,8 +1066,69 @@ final class StreamHandler
}
return $resource;
}, $uri);
} catch (TransferException $e) {
// Notification callbacks run during fopen(); an exception a
// callback throws is already a fully-formed transfer failure for
// its own request, so it passes through unchanged instead of
// being reclassified against this request.
throw $e;
} catch (\RuntimeException $e) {
throw self::createStreamFailureException($e->getMessage(), $callerRequest, $e, $streamErrorCodes);
} finally {
$captureStreamErrors = false;
}
if ($commitTlsSessions !== null) {
$commitTlsSessions();
}
return $resource;
}
/**
* @param string[] $streamErrorCodes
*/
private function addStructuredStreamErrorHandler(
#[\SensitiveParameter]
array &$context,
array &$streamErrorCodes,
bool &$captureStreamErrors
): void {
if (!self::supportsStructuredStreamErrors()) {
return;
}
if (!isset($context['stream']) || !\is_array($context['stream'])) {
$context['stream'] = [];
}
$context['stream']['error_mode'] = \StreamErrorMode::Error;
$context['stream']['error_store'] = \StreamErrorStore::None;
/** @param \StreamError[] $errors */
$context['stream']['error_handler'] = static function (
#[\SensitiveParameter]
array $errors
) use (&$streamErrorCodes, &$captureStreamErrors): void {
if (!$captureStreamErrors) {
return;
}
);
foreach ($errors as $error) {
$name = $error->code->name;
if (!\in_array($name, $streamErrorCodes, true)) {
$streamErrorCodes[] = $name;
}
}
};
}
private static function supportsStructuredStreamErrors(): bool
{
// Any PHP 8.6 build qualifies, including pre-release and nightly
// builds; the class check keeps unstable builds that do not carry
// the final API fail-closed.
return \PHP_VERSION_ID >= 80600 && \class_exists(\StreamError::class, false);
}
private static function assertRequestUriSupported(
@ -1128,6 +1273,160 @@ final class StreamHandler
$context['ssl']['min_proto_version'] = \STREAM_CRYPTO_PROTO_TLSv1_2;
}
/**
* Wires PHP 8.6+ TLS session resumption into the SSL context. Preferred
* sharing modes fall back to no sharing when a request cannot safely use
* Guzzle-managed sessions; HANDLER_REQUIRE fails loudly instead.
*
* New sessions are held temporarily and committed only after the HTTPS
* stream opens successfully. If PHP rejects peer verification or another
* stream-open step fails, any session reported during that attempt is
* discarded.
*
* @param array $customSslContext User-supplied stream_context['ssl']
* values.
*/
private function applyTlsSessionResumption(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array &$context,
#[\SensitiveParameter]
array $customSslContext = []
): ?\Closure {
if (!$this->transportSharingRequested()) {
return null;
}
$uri = $request->getUri();
if ('https' !== $uri->getScheme()) {
$this->failRequiredTlsSharingForRequest($request, 'handler-lifetime TLS session sharing only applies to HTTPS requests.');
return null;
}
$host = $uri->getHost();
// PHP opens a proxy transport with the full stream context, so a TLS
// proxy handshake would consume origin-keyed session state; only a
// tcp:// proxy keeps TLS on the origin leg alone.
$proxy = $context['http']['proxy'] ?? null;
if (\is_string($proxy) && ProxyOptions::proxyScheme($proxy) !== 'tcp') {
$this->failRequiredTlsSharingForRequest($request, 'the proxy uses a TLS stream transport, which would mix proxy and origin TLS session state.');
return null;
}
if (!isset($context['ssl']) || !\is_array($context['ssl'])) {
$this->failRequiredTlsSharingForConfiguration('the final stream SSL context is not an array.');
return null;
}
$unsupported = StreamTlsSessionCache::unsupportedContextReason($context['ssl'], $customSslContext);
if ($unsupported !== null) {
$this->failRequiredTlsSharingForConfiguration($unsupported);
return null;
}
$cache = $this->sessionCache();
if ($cache === null) {
$this->failRequiredTlsSharingForConfiguration('PHP 8.6+ with the OpenSSL session API is required.');
return null;
}
$key = StreamTlsSessionCache::peerKey(self::canonicalConnectionHost($host), $uri->getPort() ?? 443, $context['ssl']);
$credentials = StreamTlsSessionCache::credentialFingerprint($context['ssl']);
$session = $cache->find($key, $credentials);
if ($session !== null) {
$context['ssl']['session_data'] = $session;
}
// Sessions captured before the stream opens are staged so handshakes
// that fail PHP's peer verification policy are never cached.
$accepted = false;
$staged = [];
$context['ssl']['session_new_cb'] = static function (
#[\SensitiveParameter]
$stream,
#[\SensitiveParameter]
Session $session
) use ($cache, $key, $credentials, &$accepted, &$staged): void {
if ($accepted) {
$cache->store($key, $credentials, $session);
return;
}
// A server can stream session tickets while withholding response
// headers; keep only as many staged sessions as the cache retains.
if (\count($staged) >= StreamTlsSessionCache::MAX_SESSIONS_PER_KEY) {
\array_shift($staged);
}
$staged[] = $session;
};
return static function () use ($cache, $key, $credentials, &$accepted, &$staged): void {
$accepted = true;
foreach ($staged as $session) {
$cache->store($key, $credentials, $session);
}
$staged = [];
};
}
private function transportSharingRequested(): bool
{
return $this->transportSharingMode !== TransportSharing::NONE
&& $this->transportSharingMode !== TransportSharing::PERSISTENT_REQUIRE;
}
private function transportSharingRequired(): bool
{
return $this->transportSharingMode === TransportSharing::HANDLER_REQUIRE;
}
private function failRequiredTlsSharingForRequest(
#[\SensitiveParameter]
RequestInterface $request,
string $reason
): void {
if ($this->transportSharingRequired()) {
throw new RequestException('The "transport_sharing" option requires stream handler TLS session sharing, but '.$reason, $request);
}
}
private function failRequiredTlsSharingForConfiguration(string $reason): void
{
if ($this->transportSharingRequired()) {
throw new InvalidArgumentException('The "transport_sharing" option requires stream handler TLS session sharing, but '.$reason);
}
}
/**
* Returns this handler's TLS session cache, or null when the configured
* sharing mode shares nothing or the OpenSSL session API is unavailable.
* Persistent (process-wide) sharing is a cURL-only feature, so
* PERSISTENT_PREFER degrades to this per-handler cache while
* PERSISTENT_REQUIRE is rejected before sharing applies.
*/
private function sessionCache(): ?StreamTlsSessionCache
{
if (!$this->transportSharingRequested() || !StreamTlsSessionCache::isSupported()) {
return null;
}
return $this->sessionCache ?? ($this->sessionCache = new StreamTlsSessionCache(self::TLS_SESSION_CACHE_MAX_KEYS));
}
private function getDefaultContext(
#[\SensitiveParameter]
RequestInterface $request,
@ -1182,6 +1481,20 @@ final class StreamHandler
return $context;
}
private function assertTransportSharingSupported(): void
{
// The stream handler cannot pool live connections or share state across
// handler instances; persistent sharing is cURL-only.
if ($this->transportSharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" option requires persistent transport sharing, which is only available through cURL share handles. The stream handler can only share handler-lifetime TLS sessions.');
}
// Handler-scoped sharing needs the PHP 8.6+ OpenSSL session API.
if ($this->transportSharingMode === TransportSharing::HANDLER_REQUIRE && !StreamTlsSessionCache::isSupported()) {
throw new InvalidArgumentException('The "transport_sharing" option requires handler-lifetime transport sharing, but the stream handler only supports it through TLS session resumption on PHP 8.6+ with the OpenSSL session API.');
}
}
private static function rejectUnsupportedRequestOptions(
#[\SensitiveParameter]
RequestInterface $request,
@ -1361,20 +1674,14 @@ final class StreamHandler
'verify_peer' => 'the "verify" request option',
'verify_peer_name' => 'the "verify" request option',
],
'stream' => [
'error_handler' => 'Guzzle stream error handling',
'error_mode' => 'Guzzle stream error handling',
'error_store' => 'Guzzle stream error handling',
],
];
}
private function assertTransportSharingSupported(): void
{
if ($this->transportSharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" option requires persistent transport sharing, which is only available through cURL share handles.');
}
if ($this->transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" option requires transport sharing, but the stream handler does not support it.');
}
}
/**
* @param mixed $value as passed via Request transfer options.
*

View file

@ -0,0 +1,583 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\HostIdentity;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7;
use Openssl\Session;
/**
* Per-handler in-memory TLS session resumption cache for the stream handler.
*
* Backed by the OpenSSL TLS session API added in PHP 8.6. A session is only
* ever replayed for an identical TLS identity: the non-secret identity is
* encoded in the lookup key and secret material is matched in constant time.
* TLS resumption reuses the original certificate-chain verification result
* instead of building and verifying the chain again. Guzzle includes ambient
* trust configuration paths in the cache identity, but cannot detect
* trust-store contents rewritten at an unchanged path. Recreate the handler
* or disable transport sharing when trust changes must take effect
* immediately.
*
* New sessions are held temporarily and committed only after the HTTPS stream
* opens successfully. If PHP rejects peer verification or another stream-open
* step fails, any session reported during that attempt is discarded.
*
* @internal
*/
final class StreamTlsSessionCache
{
use NonSerializableTrait;
/**
* Sessions retained per peer. libcurl uses 2 to buffer single-use TLS 1.3
* tickets; the synchronous stream handler does not need more.
*/
public const MAX_SESSIONS_PER_KEY = 2;
/**
* TLS 1.3 tickets should not live longer than RFC 8446 allows. All
* pre-TLS-1.3 sessions use libcurl's tighter one-day cap.
*/
private const MAX_TLS13_LIFETIME = 604800;
private const MAX_PRE_TLS13_LIFETIME = 86400;
private const USER_MANAGED_SESSION_OPTIONS = [
'session_cache' => true,
'session_cache_size' => true,
'session_data' => true,
'session_get_cb' => true,
'session_id_context' => true,
'session_new_cb' => true,
'session_remove_cb' => true,
'session_stream' => true,
'session_timeout' => true,
];
private const USER_MANAGED_PSK_OPTIONS = [
'psk_client_cb' => true,
'psk_server_cb' => true,
];
/**
* TLS 1.3 early data (0-RTT) options are deliberately never shared: an
* injected cached session would let PHP send the replayable early data
* payload ahead of the HTTP request.
*/
private const USER_MANAGED_EARLY_DATA_OPTIONS = [
'early_data' => true,
'early_data_cb' => true,
'max_early_data' => true,
];
/**
* File/path-bearing SSL context options are deliberately not shared: their
* contents can change outside this handler.
*/
private const PATH_OPTIONS = [
'SNI_server_certs' => true,
'cafile' => true,
'capath' => true,
'dh_param' => true,
'local_cert' => true,
'local_pk' => true,
];
private const CERT_CAPTURE_OPTIONS = [
'capture_peer_cert' => true,
'capture_peer_cert_chain' => true,
];
/**
* Scalar custom SSL context options that are known to be self-contained
* and safe to include in the TLS session cache identity.
*/
private const CUSTOM_SCALAR_KEYABLE_OPTIONS = [
'SNI_enabled' => true,
'allow_self_signed' => true,
'alpn_protocols' => true,
'ciphers' => true,
'crypto_method' => true,
'disable_compression' => true,
'max_proto_version' => true,
'min_proto_version' => true,
'peer_name' => true,
'security_level' => true,
'verify_depth' => true,
'verify_peer' => true,
'verify_peer_name' => true,
];
private const INVALID_PEER_FINGERPRINT_REASON = 'the SSL context option "peer_fingerprint" must be a string or a non-empty, flat array with string algorithm names and string fingerprints.';
private int $maxKeys;
/**
* @var array<string, list<array{session: Session, credentials: string, expiresAt: int, singleUse: bool}>>
*/
private array $sessions = [];
public function __construct(int $maxKeys)
{
if ($maxKeys < 1) {
throw new InvalidArgumentException('maxKeys must be a positive integer.');
}
$this->maxKeys = $maxKeys;
}
public static function isSupported(): bool
{
// Any PHP 8.6 build qualifies, including pre-release and nightly
// builds; the class check keeps unstable builds that do not carry
// the final API fail-closed.
return \PHP_VERSION_ID >= 80600 && \class_exists(Session::class, false);
}
/**
* @param array $ssl The assembled 'ssl' stream context array.
* @param array $customSsl The user-supplied stream_context['ssl'] array.
*/
public static function unsupportedContextReason(array $ssl, array $customSsl = []): ?string
{
foreach ($customSsl as $key => $value) {
if (
!isset(self::CUSTOM_SCALAR_KEYABLE_OPTIONS[$key])
&& !isset(self::PATH_OPTIONS[$key])
&& !isset(self::USER_MANAGED_SESSION_OPTIONS[$key])
&& !isset(self::USER_MANAGED_PSK_OPTIONS[$key])
&& !isset(self::USER_MANAGED_EARLY_DATA_OPTIONS[$key])
&& !isset(self::CERT_CAPTURE_OPTIONS[$key])
&& $key !== 'no_ticket'
&& $key !== 'peer_fingerprint'
) {
return \sprintf('the custom SSL context option "%s" is not known to be safe for TLS session sharing.', Psr7\DiagnosticValue::escape((string) $key));
}
}
foreach ($ssl as $key => $value) {
if (isset(self::PATH_OPTIONS[$key])) {
return \sprintf('the SSL context option "%s" uses file or path state that cannot be safely shared.', Psr7\DiagnosticValue::escape((string) $key));
}
if (isset(self::USER_MANAGED_SESSION_OPTIONS[$key])) {
return \sprintf('the SSL context option "%s" is user-managed TLS session state.', Psr7\DiagnosticValue::escape((string) $key));
}
if (isset(self::USER_MANAGED_PSK_OPTIONS[$key])) {
return \sprintf('the SSL context option "%s" is user-managed TLS PSK state.', Psr7\DiagnosticValue::escape((string) $key));
}
if (isset(self::USER_MANAGED_EARLY_DATA_OPTIONS[$key])) {
return \sprintf('the SSL context option "%s" is user-managed TLS early data state.', Psr7\DiagnosticValue::escape((string) $key));
}
if (isset(self::CERT_CAPTURE_OPTIONS[$key]) && $value) {
return \sprintf('the SSL context option "%s" requires a fresh peer certificate handshake.', Psr7\DiagnosticValue::escape((string) $key));
}
if ($key === 'no_ticket' && $value) {
return 'the SSL context option "no_ticket" disables TLS ticket sharing.';
}
if ($key === 'peer_fingerprint') {
if (!self::isPeerFingerprint($value)) {
return self::INVALID_PEER_FINGERPRINT_REASON;
}
continue;
}
if (!self::isCanonicalScalar($value)) {
return \sprintf('the SSL context option "%s" cannot be safely included in the TLS session cache identity.', Psr7\DiagnosticValue::escape((string) $key));
}
}
return null;
}
/**
* Builds the non-secret lookup key: host/port plus every TLS parameter that
* defines the verification/identity context. Keyed on the connection host
* a transport reads, with numeric IPv4 spellings folded to one dotted
* quad; names are never resolved. Secret material is excluded.
*
* @param array $ssl The assembled 'ssl' stream context array.
*/
public static function peerKey(
string $host,
?int $port,
#[\SensitiveParameter]
array $ssl
): string {
$identity = [
'schema' => 'guzzle-stream-tls-session-v1',
'runtime' => self::runtimeIdentity(),
'peer' => [
'host' => self::canonicalHost($host),
'port' => $port,
],
'ssl' => self::canonicalPeerSslContext($ssl),
];
return \hash('sha256', \serialize($identity));
}
/**
* Builds the secret-aware credential fingerprint matched in constant time,
* keeping the passphrase out of the loggable peer key.
*
* Every credential-bearing context is currently rejected by
* unsupportedContextReason() before sharing, so fingerprints can only
* diverge if the allow-lists are ever widened; the constant-time match is
* retained as defense in depth for that case.
*
* @param array $ssl The assembled 'ssl' stream context array.
*/
public static function credentialFingerprint(array $ssl): string
{
$material = [
'local_cert' => isset($ssl['local_cert']) && \is_string($ssl['local_cert']) ? self::pathIdentity($ssl['local_cert']) : null,
'local_pk' => isset($ssl['local_pk']) && \is_string($ssl['local_pk']) ? self::pathIdentity($ssl['local_pk']) : null,
'passphrase' => isset($ssl['passphrase']) && \is_string($ssl['passphrase']) ? $ssl['passphrase'] : null,
];
return \hash('sha256', \serialize($material));
}
public function find(string $key, string $credentials): ?Session
{
if (!isset($this->sessions[$key])) {
return null;
}
$now = \time();
$entries = $this->sessions[$key];
$found = null;
foreach ($entries as $i => $entry) {
if ($entry['expiresAt'] <= $now) {
unset($entries[$i]);
continue;
}
if (!\hash_equals($entry['credentials'], $credentials)) {
continue;
}
if (!$entry['session']->isResumable()) {
unset($entries[$i]);
continue;
}
$found = $entry['session'];
// TLS 1.3 tickets are single-use; consume on take.
if ($entry['singleUse']) {
unset($entries[$i]);
}
break;
}
if ($entries === []) {
unset($this->sessions[$key]);
} else {
$this->sessions[$key] = \array_values($entries);
if ($found !== null) {
$this->touch($key);
}
}
return $found;
}
public function store(
string $key,
string $credentials,
#[\SensitiveParameter]
Session $session
): void {
if (!$session->isResumable()) {
return;
}
$expiresAt = self::expiry($session);
if ($expiresAt === null) {
return;
}
$list = $this->sessions[$key] ?? [];
$list[] = [
'session' => $session,
'credentials' => $credentials,
'expiresAt' => $expiresAt,
'singleUse' => self::isTls13($session),
];
if (\count($list) > self::MAX_SESSIONS_PER_KEY) {
$list = \array_slice($list, -self::MAX_SESSIONS_PER_KEY);
}
$this->sessions[$key] = $list;
$this->touch($key);
$this->evictExcessKeys();
}
private function touch(string $key): void
{
if (!isset($this->sessions[$key])) {
return;
}
$value = $this->sessions[$key];
unset($this->sessions[$key]);
$this->sessions[$key] = $value;
}
private function evictExcessKeys(): void
{
while (\count($this->sessions) > $this->maxKeys) {
\array_shift($this->sessions);
}
}
private static function isTls13(
#[\SensitiveParameter]
Session $session
): bool {
$protocol = $session->getProtocol();
return $protocol !== null && Psr7\Utils::caselessContains($protocol, '1.3');
}
/**
* Returns the absolute expiry timestamp, or null when the session must
* not be cached.
*/
private static function expiry(
#[\SensitiveParameter]
Session $session
): ?int {
return self::expiryFromLifetimes(
self::isTls13($session),
$session->hasTicket(),
$session->getTicketLifetimeHint(),
$session->getTimeout(),
$session->getCreatedAt(),
\time()
);
}
/**
* Decides the absolute expiry timestamp from a session's scalar lifetime
* attributes, or null when the session must not be cached.
*
* A non-positive session timeout is never cached. TLS 1.3 resumption
* requires a New Session Ticket, so a TLS 1.3 session without a ticket
* or without a positive RFC 8446 ticket lifetime is not cached, and the
* ticket lifetime bounds the expiry together with the session timeout
* and the seven-day cap. Pre-TLS-1.3 tickets treat a zero or missing
* lifetime hint as unspecified per RFC 5077, so only a positive hint
* tightens the session timeout and the one-day cap. An expiry that has
* already passed is not cached.
*/
private static function expiryFromLifetimes(bool $isTls13, bool $hasTicket, ?int $ticketLifetimeHint, int $timeout, int $createdAt, int $now): ?int
{
if ($timeout <= 0) {
return null;
}
$lifetime = \min($timeout, $isTls13 ? self::MAX_TLS13_LIFETIME : self::MAX_PRE_TLS13_LIFETIME);
if ($isTls13) {
if (!$hasTicket || $ticketLifetimeHint === null || $ticketLifetimeHint <= 0) {
return null;
}
$lifetime = \min($lifetime, $ticketLifetimeHint);
} elseif ($hasTicket && $ticketLifetimeHint !== null && $ticketLifetimeHint > 0) {
$lifetime = \min($lifetime, $ticketLifetimeHint);
}
$expiresAt = $createdAt + $lifetime;
return $expiresAt > $now ? $expiresAt : null;
}
private static function runtimeIdentity(): array
{
return [
'php' => \PHP_VERSION_ID,
'openssl' => \defined('OPENSSL_VERSION_NUMBER') ? \OPENSSL_VERSION_NUMBER : null,
'trust' => [
'openssl.cafile' => (string) \ini_get('openssl.cafile'),
'openssl.capath' => (string) \ini_get('openssl.capath'),
'SSL_CERT_FILE' => (string) \getenv('SSL_CERT_FILE'),
'SSL_CERT_DIR' => (string) \getenv('SSL_CERT_DIR'),
'cwd' => \getcwd(),
],
];
}
/**
* Returns the host identity hashed into the cache key. Valid bracketed
* IPv6 literals are canonicalized to their RFC 5952 form so equivalent
* spellings of one address share a single session entry; other valid
* hosts, such as reg-names and IPvFuture literals, fall back to ASCII
* case folding. Text that is not a valid RFC 3986 host, such as
* zone-bearing or malformed bracketed literals, must never become a
* usable cache key, so it is rejected.
*/
private static function canonicalHost(string $host): string
{
if (!Psr7\Rfc3986::isValidHost($host)) {
throw new InvalidArgumentException('Hosts used in the TLS session cache identity must be valid RFC 3986 hosts.');
}
return HostIdentity::canonicalHost($host);
}
private static function canonicalPeerSslContext(
#[\SensitiveParameter]
array $ssl
): array {
$context = [];
foreach ($ssl as $key => $value) {
if (isset(self::USER_MANAGED_SESSION_OPTIONS[$key]) || isset(self::USER_MANAGED_PSK_OPTIONS[$key]) || isset(self::USER_MANAGED_EARLY_DATA_OPTIONS[$key]) || $key === 'passphrase') {
continue;
}
if ($key === 'peer_name' && \is_string($value)) {
$context[$key] = ['string', self::canonicalHost($value)];
continue;
}
if ($key === 'peer_fingerprint') {
$context[$key] = self::canonicalPeerFingerprint($value);
continue;
}
if (\in_array($key, ['cafile', 'capath', 'local_cert', 'local_pk'], true) && \is_string($value)) {
$context[$key] = self::pathIdentity($value);
continue;
}
$context[$key] = self::canonicalScalar($value);
}
\ksort($context);
return $context;
}
private static function pathIdentity(string $path): array
{
$realPath = \realpath($path);
return [
'path',
$realPath !== false ? $realPath : $path,
];
}
/**
* @param mixed $value
*/
private static function isCanonicalScalar($value): bool
{
return $value === null
|| \is_bool($value)
|| \is_int($value)
|| \is_float($value)
|| \is_string($value);
}
/**
* @param mixed $value
*/
private static function isPeerFingerprint($value): bool
{
if (\is_string($value)) {
return true;
}
if (!\is_array($value) || $value === []) {
return false;
}
foreach ($value as $algorithm => $fingerprint) {
if (!\is_string($algorithm) || !\is_string($fingerprint)) {
return false;
}
}
return true;
}
/**
* @param mixed $value
*
* @return array{0: string, 1: mixed}
*/
private static function canonicalScalar($value): array
{
if ($value === null) {
return ['null', null];
}
if (\is_bool($value)) {
return ['bool', $value];
}
if (\is_int($value)) {
return ['int', $value];
}
if (\is_float($value)) {
return ['float', \bin2hex(\pack('E', $value))];
}
if (\is_string($value)) {
return ['string', $value];
}
throw new InvalidArgumentException('SSL context values used in the TLS session cache identity must be scalar or null.');
}
/**
* @param mixed $value
*
* @return array{0: string, 1: mixed}
*/
private static function canonicalPeerFingerprint($value): array
{
if (!self::isPeerFingerprint($value)) {
throw new InvalidArgumentException(self::INVALID_PEER_FINGERPRINT_REASON);
}
if (\is_string($value)) {
return ['string', $value];
}
if (!\is_array($value)) {
throw new InvalidArgumentException(self::INVALID_PEER_FINGERPRINT_REASON);
}
$items = [];
foreach ($value as $algorithm => $fingerprint) {
$items[$algorithm] = ['string', $fingerprint];
}
\ksort($items, \SORT_STRING);
return ['map', $items];
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Psr7;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
final class UriDiagnostic
{
private function __construct()
{
}
public static function redactInMessage(
#[\SensitiveParameter]
string $message,
#[\SensitiveParameter]
UriInterface $uri
): string {
$message = Psr7\Utils::redactUserInfoInString($message, (string) $uri);
$query = $uri->getQuery();
if ($query !== '') {
$message = \str_replace('?'.$query, '', $message);
}
$fragment = $uri->getFragment();
if ($fragment !== '') {
$message = \str_replace('#'.$fragment, '', $message);
}
return Psr7\DiagnosticValue::escape($message);
}
}

View file

@ -240,7 +240,9 @@ final class ProxyOptions
*
* This method will strip a port from the host if it is present. Domain
* patterns are matched case-insensitively. Exact IP literal patterns are
* matched by their normalized binary address.
* matched by their normalized binary address, and a host or pattern
* written in the inet_aton() shorthand a transport reads as an address,
* such as 127.1 or 0x7f000001, is matched as that address.
*
* Areas are matched in the following cases:
* 1. "*" (without quotes) always matches any hosts.
@ -388,7 +390,7 @@ final class ProxyOptions
$host = $address;
}
$packedIp = self::packIpAddress($host);
$packedIp = self::packHostAddress($host);
if ($packedIp !== false) {
return [
'type' => 'ip',
@ -442,7 +444,7 @@ final class ProxyOptions
return $port === null ? null : [$host, $port];
}
if (self::packIpAddress($area) !== false) {
if (self::packHostAddress($area) !== false) {
return [$area, null];
}
@ -494,6 +496,9 @@ final class ProxyOptions
$network = \substr($network, 1, -1);
}
// A bare rule denotes a host identity, but a CIDR rule uses network
// configuration syntax, so the inet_aton() shorthand is not read as
// a network: 127/8 would name 0.0.0.127/8 rather than 127.0.0.0/8.
$network = self::packIpAddress($network);
if ($network === false) {
return null;
@ -576,11 +581,27 @@ final class ProxyOptions
*/
private static function packIpAddress(string $ip)
{
if (!\filter_var($ip, \FILTER_VALIDATE_IP)) {
return false;
if (\filter_var($ip, \FILTER_VALIDATE_IP)) {
return \inet_pton($ip);
}
return \inet_pton($ip);
return false;
}
/**
* @return string|false
*/
private static function packHostAddress(string $host)
{
$packed = self::packIpAddress($host);
if ($packed !== false) {
return $packed;
}
// A transport reads the inet_aton() shorthand as an address too, so a
// rule and a request host that name one address have to match
// whichever spelling each of them happens to use.
return HostIdentity::numericIpv4ToBinary($host) ?? false;
}
private static function ipMatchesPrefix(string $address, string $network, int $prefix): bool

View file

@ -351,12 +351,12 @@ class RedirectMiddleware
$resolvedUri = $uriFactory->createUri((string) $resolvedUri);
}
} catch (\InvalidArgumentException $e) {
throw new BadResponseException(\sprintf('Redirect URI, %s, is invalid: %s', Psr7\DiagnosticValue::escape($location), $e->getMessage()), $request, $response, $e);
throw new BadResponseException(\sprintf('Redirect URI, %s, is invalid.', Psr7\Utils::redactUriStringForMessage($location)), $request, $response, $e);
}
// Ensure that the redirect URI is allowed based on the protocols.
if (!\in_array($resolvedUri->getScheme(), $protocols, true)) {
throw new BadResponseException(\sprintf('Redirect URI, %s, does not use one of the allowed redirect protocols: %s', Psr7\DiagnosticValue::escape((string) $resolvedUri), \implode(', ', $protocols)), $request, $response);
throw new BadResponseException(\sprintf('Redirect URI, %s, does not use one of the allowed redirect protocols: %s', Psr7\Utils::redactUriForMessage($resolvedUri), \implode(', ', $protocols)), $request, $response);
}
return $resolvedUri;

View file

@ -12,7 +12,7 @@ use Psr\Http\Message\UriInterface;
/**
* This class contains a list of built-in Guzzle request options.
*
* @see https://github.com/guzzle/guzzle/blob/8.0/docs/request-options.md
* @see https://github.com/guzzle/guzzle/blob/8.1/docs/request-options.md
*/
final class RequestOptions
{
@ -383,11 +383,12 @@ final class RequestOptions
* as a comma- or whitespace-delimited string, array of strings, or null to
* specify hosts, host-and-port pairs, IP literals, IP CIDR rules, or
* wildcard rules that should not be proxied. Domain rules are matched
* case-insensitively. Exact IP literals are normalized before matching.
* CIDR rules match IP literals only and are not port-specific. Custom
* handlers can use ProxyOptions::resolve() to apply Guzzle-compatible proxy
* selection; the built-in handlers' environment-variable fallback is not
* part of that helper.
* case-insensitively. Exact IP literals are normalized before matching,
* including the inet_aton() shorthand spellings a transport reads as an
* address. CIDR rules match IP literals only and are not port-specific.
* Custom handlers can use ProxyOptions::resolve() to apply
* Guzzle-compatible proxy selection; the built-in handlers'
* environment-variable fallback is not part of that helper.
*/
public const PROXY = 'proxy';

View file

@ -11,6 +11,7 @@ use GuzzleHttp\Handler\CurlShareHandleState;
use GuzzleHttp\Handler\CurlVersion;
use GuzzleHttp\Handler\Proxy;
use GuzzleHttp\Handler\StreamHandler;
use GuzzleHttp\Handler\StreamTlsSessionCache;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
@ -88,8 +89,19 @@ final class Utils
$handler = self::createCurlHandler($sharingMode, $handlerOptions);
// Handler-scoped required sharing can also be satisfied by the stream
// handler's TLS session resumption (PHP 8.6+); persistent required
// sharing is cURL-only.
$streamCanShareSessions = (bool) \ini_get('allow_url_fopen') && StreamTlsSessionCache::isSupported();
if ($sharingRequired && $handler === null) {
throw new \RuntimeException('Required transport sharing requires the PHP cURL extension, curl_exec() or curl_multi_exec(), and a supported libcurl version with SSL support.');
if ($sharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new \RuntimeException('Required persistent transport sharing requires the PHP cURL extension, curl_exec() or curl_multi_exec(), and a supported libcurl version with SSL support.');
}
if (!$streamCanShareSessions) {
throw new \RuntimeException('Required transport sharing requires the PHP cURL extension (curl_exec()/curl_multi_exec()) with a supported libcurl version and SSL support, or PHP 8.6+ with the OpenSSL TLS session API and the allow_url_fopen ini setting.');
}
}
if (\ini_get('allow_url_fopen')) {
@ -131,6 +143,13 @@ final class Utils
return null;
}
if ($sharingMode === TransportSharing::HANDLER_REQUIRE && !CurlShareHandleState::supportsHandlerRequireShare()) {
// Required handler sharing can also be satisfied by the stream
// handler's TLS session resumption, so a cURL install that cannot
// share is skipped instead of failing handler selection.
return null;
}
$connectionCapOptions = self::connectionCapOptions($handlerOptions);
if ($connectionCapOptions !== [] && !\function_exists('curl_multi_exec')) {
return null;

View file

@ -1,6 +1,13 @@
# CHANGELOG
## 3.0.2 - 2026-08-24
### Added
- Added support for PHP 8.6
## 3.0.1 - 2026-08-05
### Changed

View file

@ -19,8 +19,8 @@ composer require guzzlehttp/promises
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 3.0 | Latest | >=7.4,<8.6 |
| 2.5 | Maintenance | >=7.2.5,<8.6 |
| 3.0 | Latest | >=7.4,<8.7 |
| 2.5 | Maintenance | >=7.2.5,<8.7 |
| 1.5 | End of Life | >=5.5,<8.3 |
## Quick Start
@ -50,6 +50,14 @@ You can wait for a promise to complete synchronously:
$value = $promise->wait();
```
A promise's `getState()` describes how it was settled, not the eventual
outcome: a promise that was resolved with another promise, directly or by
returning one from a `then()` handler, reports `fulfilled` while the inner
promise may still be pending, and `wait()` can still throw if the inner
promise rejects. Poll the state only on promises settled with plain values,
or call `wait()` first (see
[guzzle/promises#101](https://github.com/guzzle/promises/issues/101)).
When using Guzzle HTTP requests, asynchronous methods return
`GuzzleHttp\Promise\PromiseInterface` instances:

View file

@ -62,7 +62,16 @@ interface PromiseInterface
* The three states can be checked against the constants defined on
* PromiseInterface: PENDING, FULFILLED, and REJECTED.
*
* The state describes how this promise was settled, not the eventual
* outcome: a promise that was resolved with another promise, directly or
* by returning one from a then() handler, reports FULFILLED while the
* inner promise may still be pending, and wait() can still throw if the
* inner promise rejects. Poll the state only on promises settled with
* plain values, or call wait() first.
*
* @return self::PENDING|self::FULFILLED|self::REJECTED
*
* @see https://github.com/guzzle/promises/issues/101
*/
public function getState(): string;
@ -71,7 +80,8 @@ interface PromiseInterface
*
* @param TValue|PromiseInterface<TValue, TReason>|null $value
*
* @throws \RuntimeException if the promise is already resolved.
* @throws \LogicException if the promise is already settled with a
* conflicting resolution.
*/
public function resolve($value = null): void;
@ -80,7 +90,8 @@ interface PromiseInterface
*
* @param TReason $reason
*
* @throws \RuntimeException if the promise is already resolved.
* @throws \LogicException if the promise is already settled with a
* conflicting resolution.
*/
public function reject($reason): void;

View file

@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 3.1.0 - 2026-08-24
### Added
- Add `Utils::redactUriForMessage()` and `Utils::redactUriStringForMessage()` for URI diagnostics
- Add support for PHP 8.6
### Changed
- Omit rejected header values and sensitive URI components from automatic exception messages
## 3.0.1 - 2026-08-24
### Fixed
- Prefix relative paths that begin with a colon segment with `./` instead of throwing
- Apply the `/.` prefix for authority-less `//` paths to percent-encoding normalizations as well
- Keep colon-leading first path segments when reading the paths of scheme-less non-native URIs
- Stop throwing when removing the default `file` host strands a `//` path, prefixing it with `/.`
## 3.0.0 - 2026-07-20
### Added

View file

@ -19,8 +19,8 @@ composer require guzzlehttp/psr7
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 3.0 | Latest | >=7.4,<8.6 |
| 2.13 | Maintenance | >=7.2.5,<8.6 |
| 3.1 | Latest | >=7.4,<8.7 |
| 2.13 | Maintenance | >=7.2.5,<8.7 |
| 1.9 | End of Life | >=5.4,<8.2 |
## Quick Start

View file

@ -78,7 +78,7 @@ trait MessageTrait
public function withHeader(string $name, $value): MessageInterface
{
$this->assertHeader($name);
$value = $this->normalizeHeaderValue($value);
$value = $this->normalizeHeaderValue($name, $value);
$normalized = Utils::asciiToLower($name);
$new = clone $this;
@ -97,7 +97,7 @@ trait MessageTrait
public function withAddedHeader(string $name, $value): MessageInterface
{
$this->assertHeader($name);
$value = $this->normalizeHeaderValue($value);
$value = $this->normalizeHeaderValue($name, $value);
$normalized = Utils::asciiToLower($name);
$new = clone $this;
@ -166,7 +166,7 @@ trait MessageTrait
$header = (string) $header;
$this->assertHeader($header);
$value = $this->normalizeHeaderValue($value);
$value = $this->normalizeHeaderValue($header, $value);
$normalized = Utils::asciiToLower($header);
if (isset($this->headerNames[$normalized])) {
$header = $this->headerNames[$normalized];
@ -183,17 +183,17 @@ trait MessageTrait
*
* @return string[]
*/
private function normalizeHeaderValue($value): array
private function normalizeHeaderValue(string $header, $value): array
{
if (is_array($value) && $value === []) {
throw new \InvalidArgumentException('Header value must be a non-empty array or string.');
}
if (!is_array($value)) {
return $this->trimAndValidateHeaderValues([$value]);
return $this->trimAndValidateHeaderValues($header, [$value]);
}
return $this->trimAndValidateHeaderValues($value);
return $this->trimAndValidateHeaderValues($header, $value);
}
/**
@ -210,9 +210,9 @@ trait MessageTrait
*
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5
*/
private function trimAndValidateHeaderValues(array $values): array
private function trimAndValidateHeaderValues(string $header, array $values): array
{
return array_map(function ($value): string {
return array_map(function ($value) use ($header): string {
if (!is_string($value)) {
throw new \InvalidArgumentException(sprintf(
'Header value must be a string or array of strings but %s provided.',
@ -221,7 +221,7 @@ trait MessageTrait
}
$trimmed = trim($value, " \t");
$this->assertValue($trimmed);
$this->assertValue($header, $trimmed);
return $trimmed;
}, array_values($values));
@ -254,7 +254,7 @@ trait MessageTrait
* obs-text = %x80-FF
* obs-fold = CRLF 1*( SP / HTAB )
*/
private function assertValue(string $value): void
private function assertValue(string $header, string $value): void
{
// The regular expression intentionally does not support the obs-fold
// production, because as per RFC 9112#5.2:
@ -269,7 +269,11 @@ trait MessageTrait
// 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)));
$reason = strpbrk($value, "\r\n") !== false
? 'must not contain CR or LF characters'
: 'contains an invalid control character';
throw new \InvalidArgumentException(sprintf('Header "%s" %s.', DiagnosticValue::escape($header), $reason));
}
}
}

View file

@ -72,7 +72,7 @@ final class MultipartStream implements StreamInterface
$key = (string) $key;
self::validatePartHeaderName($key);
self::validatePartHeaderValue($value);
self::validatePartHeaderValue($key, $value);
$str .= "{$key}: {$value}\r\n";
}
@ -252,7 +252,7 @@ final class MultipartStream implements StreamInterface
throw new \InvalidArgumentException('Multipart part header value must be a string.');
}
self::validatePartHeaderValue($value);
self::validatePartHeaderValue($key, $value);
$normalized[$key] = $value;
}
@ -267,10 +267,14 @@ final class MultipartStream implements StreamInterface
}
}
private static function validatePartHeaderValue(string $value): void
private static function validatePartHeaderValue(string $name, string $value): void
{
if (!Rfc9110::isFieldValue($value)) {
throw new \InvalidArgumentException(sprintf('Invalid multipart part header value: %s', DiagnosticValue::escape($value)));
$reason = strpbrk($value, "\r\n") !== false
? 'must not contain CR or LF characters'
: 'contains an invalid control character';
throw new \InvalidArgumentException(sprintf('Multipart part header "%s" %s.', DiagnosticValue::escape($name), $reason));
}
}

View file

@ -150,7 +150,7 @@ class Request implements RequestInterface
$host .= ':'.$port;
}
$this->assertValue($host);
$this->assertValue('Host', $host);
return $host;
}

View file

@ -69,7 +69,7 @@ class Uri implements UriInterface, \JsonSerializable
if ($uri !== '') {
$parts = UriParser::parse($uri);
if ($parts === false) {
throw new MalformedUriException(\sprintf('Unable to parse URI: %s', DiagnosticValue::escape($uri)));
throw new MalformedUriException(\sprintf('Unable to parse URI: %s', Utils::redactUriStringForMessage($uri)));
}
try {
$this->applyParts($parts);
@ -411,7 +411,10 @@ class Uri implements UriInterface, \JsonSerializable
* 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.
* Appendix B, without validating or decoding any other component. The
* scheme is only split off when the instance reports one, as a relative
* reference can begin with a segment containing a colon that the Appendix
* B expression would otherwise read as a scheme.
*
* @throws \RuntimeException If the path cannot be split from the string form.
*
@ -429,7 +432,10 @@ class Uri implements UriInterface, \JsonSerializable
return $uri->path;
}
$count = preg_match('%^(?:[^:/?#]+:)?(?://[^/?#]*)?([^?#]*)%', (string) $uri, $matches);
$pattern = $uri->getScheme() === ''
? '%^(?://[^/?#]*)?([^?#]*)%'
: '%^(?:[^:/?#]+:)?(?://[^/?#]*)?([^?#]*)%';
$count = preg_match($pattern, (string) $uri, $matches);
if ($count === false) {
throw new \RuntimeException('Unable to read the URI path: '.preg_last_error_msg());

View file

@ -86,7 +86,11 @@ final class UriNormalizer
* second format in the Uri class. See
* `GuzzleHttp\Psr7\Uri::composeComponents`.
*
* When removing the host leaves a URI without an authority whose path
* begins with `//`, the path is serialized with a `/.` prefix.
*
* Example: file://localhost/myfile file:///myfile
* Example: file://localhost//x → file:///.//x
*/
public const REMOVE_DEFAULT_HOST = 8;
@ -166,6 +170,13 @@ final class UriNormalizer
* uncommon in reality. So this potential normalization is implied in PSR-7
* as well.
*
* A path the URI cannot hold, such as a `//`-leading path without an
* authority or a relative-path reference whose first segment contains a
* colon, is prefixed with `/.` or `./` respectively instead of throwing, as
* `UriResolver::resolve()` does. The percent-encoding normalizations only
* do so where they rewrote the path. For example, decoding `a%41:` yields
* `./aA:`, since `aA:` would be an absolute URI with the scheme `aa`.
*
* @param UriInterface $uri The URI to normalize
* @param int $flags A bitmask of normalizations to apply, see constants
*
@ -188,6 +199,14 @@ final class UriNormalizer
}
if ($flags & self::REMOVE_DEFAULT_HOST && $uri->getScheme() === 'file' && $uri->getHost() === 'localhost') {
if ($uri->getUserInfo() === '' && $uri->getPort() === null) {
$path = Uri::rawPath($uri);
if (str_starts_with($path, '//')) {
// "/." keeps a "//" path unambiguous once the authority is gone
$uri = $uri->withPath('/.'.$path);
}
}
$uri = $uri->withHost('');
}
@ -260,8 +279,7 @@ final class UriNormalizer
$uri = self::withNormalizedUserInfo($uri, $regex, $callback);
$uri = self::withNormalizedHost($uri, $regex, $callback);
return $uri
->withPath(self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
return self::withGuardedPath($uri, self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback))
->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback));
}
@ -284,12 +302,24 @@ final class UriNormalizer
$uri = self::withNormalizedUserInfo($uri, $regex, $callback);
$uri = self::withNormalizedHost($uri, $regex, $hostCallback);
return $uri
->withPath(self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
return self::withGuardedPath($uri, self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback))
->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback));
}
/**
* Writes the given path only when it differs from the current one, guarded
* so the write cannot throw.
*/
private static function withGuardedPath(UriInterface $uri, string $path): UriInterface
{
if ($path === Uri::rawPath($uri)) {
return $uri;
}
return $uri->withPath(UriResolver::guardedPath($uri, $path));
}
/**
* @param callable(array): string $callback
*/

View file

@ -65,8 +65,8 @@ final class UriResolver
}
/**
* Returns the path, prefixed with "/." when it would otherwise start the
* URI's string form with an authority-like "//".
* Returns the path, prefixed with "/." or "./" when the URI could not
* otherwise hold it.
*
* A URI without an authority cannot hold a path beginning with "//" (RFC
* 3986 Section 3.3), but removeDotSegments() can produce one. The "/."
@ -76,28 +76,47 @@ final class UriResolver
* written, so the path cannot be mistaken for an authority and the prefix
* is not added.
*
* A relative-path reference cannot begin with a segment containing a colon
* (RFC 3986 Section 4.2), as it would be mistaken for a scheme name, but
* reference resolution and percent-encoding normalization can produce one.
* The "./" prefix the RFC prescribes resolves back to the same path.
*
* @see https://url.spec.whatwg.org/#url-serializing
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*
* @internal
*/
public static function guardedPath(UriInterface $uri, string $path): string
{
if (!str_starts_with($path, '//') || $uri->getAuthority() !== '') {
if ($uri->getAuthority() !== '') {
return $path;
}
if ($uri instanceof Uri && ($uri->getScheme() === 'http' || $uri->getScheme() === 'https')) {
return $path;
if (str_starts_with($path, '//')) {
if ($uri instanceof Uri && ($uri->getScheme() === 'http' || $uri->getScheme() === 'https')) {
return $path;
}
return '/.'.$path;
}
return '/.'.$path;
if ($uri->getScheme() === '' && str_contains(explode('/', $path, 2)[0], ':')) {
return './'.$path;
}
return $path;
}
/**
* Converts the relative URI into a new URI that is resolved against the
* base URI.
*
* When the resolved path is a relative-path reference whose first segment
* contains a colon, which would be mistaken for a scheme name (RFC 3986
* Section 4.2), it is prefixed with `./`, e.g. `./a:b`.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
public static function resolve(UriInterface $base, UriInterface $rel): UriInterface
{

View file

@ -583,6 +583,56 @@ final class Utils
return \str_replace(\substr($authority, 0, $atPosition).'@', '***@', $subject);
}
/**
* Formats a URI for automatic diagnostics.
*
* The whole userinfo component is replaced by "***", and the query and
* fragment are removed. The scheme, host, port, and path are preserved.
* The returned string is diagnostic-escaped and the formatter never
* throws.
*/
public static function redactUriForMessage(
#[\SensitiveParameter]
UriInterface $uri
): string {
try {
$raw = (string) $uri;
} catch (\Throwable $e) {
return '[unavailable URI]';
}
try {
if ($uri->getUserInfo() !== '') {
$uri = $uri->withUserInfo('***');
}
return DiagnosticValue::escape((string) $uri->withQuery('')->withFragment(''));
} catch (\Throwable $e) {
return self::redactUriStringForMessage($raw);
}
}
/**
* Formats a raw URI for automatic diagnostics, including malformed input.
*
* The whole userinfo component is replaced by "***", and the query and
* fragment are removed. The scheme, host, port, and path are preserved
* where their boundaries can be determined safely. The returned string is
* diagnostic-escaped and the formatter never throws.
*/
public static function redactUriStringForMessage(
#[\SensitiveParameter]
string $uri
): string {
try {
$uri = self::redactUserInfoInString($uri, $uri);
return DiagnosticValue::escape(\substr($uri, 0, \strcspn($uri, '?#')));
} catch (\Throwable $e) {
return '[unavailable URI]';
}
}
/**
* Create a new stream based on the input type.
*