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

@ -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.
*