This commit is contained in:
Javier Casares 2026-08-18 07:00:18 +00:00
commit 77d0cea926
995 changed files with 28142 additions and 8603 deletions

View file

@ -3,6 +3,462 @@
Please refer to [UPGRADING](UPGRADING.md) guide for upgrading to a major version.
## 8.0.2 - 2026-08-05
### Changed
- Adjusted `guzzlehttp/promises` version constraint to `^3.0.1`
### Fixed
- Fail a cURL multi handler wait with an attributable error when the transfer is no longer tracked
- Fix `StreamHandler` resolving numeric IPv4 hosts differently from cURL handlers on macOS and Windows
- Fix `StreamHandler` TLS peer names and proxy authorities for numeric IPv4 hosts on all platforms
- Settle a cURL multi handler transfer displaced by a request reusing its native handle ID
## 8.0.1 - 2026-07-26
### Security
- Reject request URI hosts and `Host` header values that are not printable ASCII (GHSA-v5mv-p594-2x33)
- Reject URI hosts with a percent escape or that are not valid RFC 3986 hosts (GHSA-v5mv-p594-2x33)
- Reject numeric-looking URI hosts with trailing dots, read as IPv4 addresses (GHSA-v5mv-p594-2x33)
- Treat percent-escaped cookie domains as exact-match-only (GHSA-f7vp-7xgx-4w4r)
- Regenerate a derived `Host` header after client URI rewrites (GHSA-v5mv-p594-2x33)
## 8.0.0 - 2026-07-20
### Added
- Add `SensitiveParameter` metadata to credential-bearing parameters so PHP 8.2+ redacts their values in exception traces
- Add HTTP/3 request support to the built-in cURL handlers when PHP 8.4+ and libcurl provide HTTP/3 support
- Add `Multiplexing::NONE` support as a client, cURL multi handler, and conditional request option
- Add generic and structured PHPDoc annotations to client request/config option, async promise, handler, middleware, pool, and mock handler APIs
- Add `ConnectTimeoutException` for connect-phase timeouts, extending `ConnectException`
- Add `NetworkException` for no-response network failures
- Add `NetworkTimeoutException` for no-response transport timeouts
- Add `ResponseTransferException`, with `ResponseTimeoutException` for response-transfer timeouts
- Add PSR-17 `request_factory`, `response_factory`, `stream_factory`, and `uri_factory` request options
- Add explicit `close()` lifecycle methods to the built-in cURL handlers and concrete cURL factory
- Add `HandlerClosedException` for pending transfers rejected by `CurlMultiHandler::close()`
- Add persistent transport sharing modes (`TransportSharing::PERSISTENT_PREFER` and `TransportSharing::PERSISTENT_REQUIRE`)
- Add `ProxyOptions` for proxy option resolution
- Add `ResponseException` for request failures with responses
- Add auth middleware for built-in Basic and Digest authentication
### Changed
- Canonicalize IPv6 hosts in Digest challenge cache keys
- Canonicalize IPv6 hosts in cookie domains, host-only identities, and domain matching
- Restrict cookie domain suffix matching to valid non-literal, nonnumeric host names
- Reject Secure cookies and insecure overlays received over insecure connections
- Enforce the `__Secure-` and `__Host-` prefix requirements on response cookies
- Hardened `FileCookieJar` and `SessionCookieJar` persistence against unsafe unserialization
- Reject native PHP serialization of runtime objects
- Restrict persisted `FileCookieJar` cookie files to owner-only permissions
- Require persisted cookie data to use JSON lists and construct all records before changing the jar
- Normalize persistent cookie JSON failures as `RuntimeException`
- Moved the internal `Utils` time, timeout, IDN, and environment helpers to dedicated internal classes
- Adjusted `guzzlehttp/promises` version constraint to `^3.0`
- Adjusted `guzzlehttp/psr7` version constraint to `^3.0`
- Quote multipart `Content-Type` boundary parameters when required
- Added parameter and return types to `SetCookie` methods
- Added native property types to supported public cURL handler state properties
- Added a `string` return type to `SetCookie::__toString()`
- Validate proxy and no-proxy option types strictly across handlers
- Match leading-dot no-proxy entries against the bare domain and split string no-proxy lists on whitespace
- Validate `force_ip_resolve`, protocols, and `delay` ranges at the client boundary
- Treat a matching proxy `no` entry as final even without a scheme-specific proxy entry
- Validate proxy URLs in the built-in handlers and reject malformed or unsupported ones up front
- Default a port-less proxy to 1080 in the stream handler, matching libcurl
- Downgrade HTTP/3 requests to HTTP/2 or HTTP/1.1 when the proxy is resolved from environment variables
- Throw `RequestException`, not `InvalidArgumentException`, for an unavailable proxy or TLS feature
- Resolve proxy environment variables in the stream handler, consistent with the cURL handlers
- Honor `no_proxy`/`NO_PROXY` from the environment in the stream handler, including `*` to disable proxying
- Reject an environment-resolved `https://` or SOCKS proxy in the stream handler, matching its `proxy` option behavior
- Require cURL proxy header separation for first-class `Proxy-Authorization` on every route
- Reject every first-class `Proxy-Authorization` field, including empty, on stream proxies
- Reject raw `CURLOPT_PROXYHEADER` without proxy header separation support
- Pass the request as the second argument to `on_headers` callbacks
- Pass the `Pool` iterable key as a trailing argument to per-request observer callbacks
- Declare strict types across remaining source files
- Reject request option values that do not match their documented types
- Reject invalid `idn_conversion`, `retries`, and built-in handler `on_stats` option values before use
- Reject non-finite floats in the `query` and `form_params` options
- Reject non-string scalar values in the `body` option
- Apply automatic `Expect: 100-Continue` injection to HTTP/1.1 requests only
- Reject invalid `SetCookie` constructor field types instead of coercing them
- Validate and normalize request framing across the built-in cURL and stream handlers
- Reject raw cURL request options outside the built-in cURL handlers' allow-list
- Reject non-string raw cURL header-list entries before applying them
- Reject proxy tunnels that require fresh connections when persistent transport sharing requires reuse
- Reject PHP stream context options outside the built-in stream handler allow-list
- Reject selected request options ignored by incompatible built-in handlers
- Treat only `null` as an omitted path or name when clearing cookies
- Validate malformed `auth` request option arrays
- Reject colons in built-in Basic usernames and ASCII control characters in Basic credentials
- Move built-in Basic and Digest authentication handling to the default auth middleware
- Reject unchallenged Digest probes for body-bearing requests instead of replaying the request unauthenticated
- Reject malformed Digest challenge parameter lists that libcurl's Digest parser may have tolerated
- Reuse Digest challenges to authorize subsequent body-less requests preemptively
- Advance the Digest nonce count when a stale challenge repeats the same nonce during the initial handshake
- Remove first-class NTLM authentication from the `auth` request option
- Stop forwarding the generic `auth` request option when following cross-origin redirects
- Limit the `Referer` header to the origin on cross-origin redirects
- Follow only redirect status codes 301, 302, 303, 307, and 308
- Reject invalid `HandlerStack::remove()` arguments
- Require `Pool` request collections to be iterable
- Raised the built-in cURL handler floor to libcurl 7.34.0 with SSL support
- Store response cookies without a `Domain` attribute as host-only cookies
- Prefer cookie `Max-Age` over `Expires` when both attributes are present
- Match cookie names case-sensitively in `CookieJar::getCookieByName()`
- Ignore float-like or exponent `Max-Age` cookie values instead of truncating them
- Tighten invalid response handling and avoid exposing response-derived cURL stats
- Reject malformed response protocol versions and reason phrases
- Escape controls and malformed UTF-8 when copying raw values into exception messages
- Reject malformed or conflicting response `Content-Length` and combinations with `Transfer-Encoding`
- Expose raw stream-handler `Transfer-Encoding` metadata and coalesced framing in `progress` on newer PHP
- Wrap malformed redirect `Location` values in `BadResponseException`
- Default HTTPS requests sent by the built-in cURL and stream handlers to TLS 1.2 or newer
- Apply the stream handler `crypto_method` option through the SSL context so it consistently controls the minimum TLS version
- Validate built-in handler timeout options before applying them
- Require a request when constructing `TransferException` and its subclasses
- Classify empty, malformed, or handler-unsupported request protocol versions as request exceptions
- Classify additional cURL transport failures without a response as `NetworkException`
- Classify stream connect failures as `ConnectException`, with connect timeouts as `ConnectTimeoutException`
- Classify stream transport failures without a response as `NetworkException`, with timeouts as `NetworkTimeoutException`
- Classify generic response-aware request failures as `ResponseException`
- Classify response-aware transfer failures as `ResponseTransferException`
- The stream handler returns an empty body and releases the connection at the end of the headers for HEAD and CONNECT-2xx exchanges and 1xx, 204, and 304 responses
- The stream handler no longer writes to the `sink` option or reads trailing bytes for responses that cannot carry a body
- Reject short buffered stream-handler bodies against `Content-Length`, including decoded gzip/deflate
- Normalize duplicate `Content-Length` casings and preserve encoded values on decoded responses
- Reject unrepresentable byte counts and response sizes requiring integer bounds as `ResponseException`
- Ignore cURL informational responses other than `101 Switching Protocols` before the final response
- Treat response sink rewind failures as `ResponseException` and skip non-seekable sink rewinds
- Classify redirect request-body rewind failures as `ResponseException`
- Ignore stream source close failures after a complete response body transfer
- Throw `GuzzleHttp\Exception\InvalidArgumentException` for invalid built-in handler options
- Classify built-in cURL handle, `sink`, and HTTP/3 setup failures as `RequestException`
- Throw `ConnectTimeoutException` for connect timeouts
- Throw `NetworkTimeoutException` for cURL no-response timeout errors
- Throw `ResponseTimeoutException` for response-aware transfer timeouts
- Enforce the `timeout` option as a total transfer deadline in the stream handler when it buffers the response
- Reject stream handler responses whose header block arrives after the `timeout` deadline
- Stop consulting the `default_socket_timeout` ini setting in the stream handler
- Treat stream handler `read_timeout` as an idle timeout for every request stage, defaulting to 60 seconds
- Default the cURL connect timeout to 60 seconds, with `connect_timeout` set to `0` disabling it
- Stop the stream handler from injecting `User-Agent` and `From` header values from the `user_agent` and `from` ini settings
- Classify request-body stream size detection, read, stringification, and rewind failures as `RequestException` or `ResponseException` by phase
- Classify cURL response sink write failures, including timeouts, as `ResponseException` or `RequestException` by phase
- Treat request method names case-sensitively in built-in handler and redirect method-specific behavior
- Treat PHP resources passed as `sink` as caller-owned in the built-in cURL and stream handlers
- Use the configured PSR-17 URI factory when parsing redirect `Location` headers
- Allow built-in cURL handler `progress` callbacks to abort transfers with truthy return values
- Normalize built-in handler `progress` callback arguments to integer byte counts
- Reject built-in cURL `progress` throwables with `ResponseException` when a response exists, otherwise `RequestException`
- Release built-in cURL easy handles before invoking `on_stats`
- Prefer `CURLOPT_XFERINFOFUNCTION` for built-in cURL progress callbacks when available
- Made `MessageFormatter` final and required `Middleware::log()` formatters to implement `MessageFormatterInterface`
- Made `CurlFactory`, `CurlHandler`, `CurlMultiHandler`, `MockHandler`, and `StreamHandler` final
- Made static utility classes non-instantiable and declared `GuzzleHttp\Handler\Proxy` final
- Pass the request to `on_trailers` callbacks, reject non-callable `on_trailers` values, and wrap `on_trailers` callback exceptions in `ResponseException`
- Wait for in-progress HTTP/2-capable connections by default (`multiplex` defaults to `Multiplexing::WAIT`)
- Require libcurl 7.65.2 or newer for HTTP/2 requests so multiplex waiting is never silently unavailable
- Require libcurl 7.54.0 for HTTPS proxies and requests tunneled through HTTP proxies
- Suppress proxy CONNECT response headers for tunneled requests
- Point rejections of the raw `CURLOPT_PIPEWAIT` cURL option at the `multiplex` request option
- Reject raw `CURLMOPT_PIPELINING` in favour of the `multiplex` cURL multi handler option
- Reject required multiplexing when the final `CURLOPT_HTTPAUTH` mask permits NTLM
- Reject cURL multi options that the runtime libcurl cannot apply
- Reject unknown handler constructor options
- Reject invalid `select_timeout` cURL multi handler option values
- Reject raw cURL multi connection cap options in favour of the named options
- Parse `Set-Cookie` strings with RFC 6265 whitespace trimming
- Ignore valueless `Set-Cookie` attributes that require a value when parsing
- Trim only the trailing CRLF from the stream handler header block
- Fail streamed uploads immediately when the body cannot be resent for an auth challenge
### Removed
- Dropped support for PHP 7.2 and 7.3
- Removed `Client::__call()`; use the typed HTTP verb methods or `request()`/`requestAsync()`
- Removed `ClientInterface::getConfig()`; the concrete `Client::getConfig()` remains available
- Removed support for the `GUZZLE_CURL_SELECT_TIMEOUT` environment variable; use `CurlMultiHandler`'s `select_timeout` option
- Removed support for the `handler` request option; configure the handler on the client
- Removed direct access to `CurlMultiHandler::$_mh`; pass `CURLMOPT_*` values through constructor `options` instead
- Removed `RedirectMiddleware::$defaultSettings`; use `RedirectMiddleware::DEFAULT_SETTINGS`
- Removed the deprecated `RetryMiddleware::exponentialDelay()` method
- Removed the deprecated `RequestException::wrapException()` method
- Removed the deprecated `Utils::describeType()` method
- Removed `Utils::jsonDecode()` and `Utils::jsonEncode()` in favor of native JSON functions
- Removed deprecated `GuzzleHttp` namespace functions in favor of native or class equivalents
- Removed `Utils::defaultCaBundle()`; rely on the system trust store or pass a bundle path via the `verify` option
- Removed `HandlerStack::__toString()`
- Removed `RequestException::getHandlerContext()` and `ConnectException::getHandlerContext()`
- Removed response access from `RequestException`; use `ResponseException`
- Removed `Utils::isHostInNoProxy()`; use `ProxyOptions` helpers for Guzzle 8 no-proxy matching
- Removed `Utils::isUriInNoProxy()`; use `ProxyOptions::isUriInNoProxy()`
- Removed `Handler\Proxy::wrapTlsFallback()`; the default handler stack selects the cURL or stream handler by TLS support automatically
## 7.15.1 - 2026-07-18
### Security
- Preserve host-only cookie scope and require explicit persistence markers (GHSA-wm3w-8rrp-j577)
- Bound response cookie admission and generated `Cookie` headers (GHSA-f283-ghqc-fg79)
- Exclude URI fragments from `Referer` headers generated for redirects (GHSA-h95v-h523-3mw8)
## 7.15.0 - 2026-07-17
### Added
- Added `Multiplexing::NONE` support as a client, cURL multi handler, and conditional request option
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.13`
- Use locale-independent ASCII folding for all case normalization and comparison
- Bound cURL upload reads to the declared `Content-Length`
- Sanitize the cURL error text exposed through exception handler context
- Fail closed when a named cURL multi connection cap cannot be applied
- Reject the request-level `CURLOPT_SHARE` cURL option when named connection caps are configured
- Strengthen old-libcurl SOCKS isolation for raw `CURLOPT_PRE_PROXY` and opaque share handles
- Isolate HTTP proxy tunnels from opaque shared connection caches
- Trigger runtime deprecations for previously deprecated functionality in 7.1.0
### Deprecated
- Deprecated `Utils::jsonDecode()` and `Utils::jsonEncode()` in favor of native JSON functions
- Deprecated passing `CURLMOPT_PIPELINING` in the cURL multi handler `options` array
- Deprecated passing `CURLOPT_PROXYHEADER` without cURL proxy header separation support
### Fixed
- Defer cURL requests created from multi callbacks until native execution unwinds
- Fail synchronous waits from native cURL callbacks promptly instead of self-deadlocking
- Guard cURL multi handle removal against progress callbacks re-entering the handler
- Scope promise waits on the cURL multi handler to the awaited transfer
- Strip `Content-Length` and `Transfer-Encoding` when redirects discard the request body
- Stop re-applying the `delay` request option to followed redirects
## 7.14.2 - 2026-07-14
### Security
- Prevent first-class and proxy URL credentials from reaching origins (GHSA-94pj-82f3-465w)
## 7.14.1 - 2026-07-13
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12.5`
### Fixed
- Fail closed when a proxy tunnel isolation cURL option cannot be applied
- Normalize Stringable proxy credential values before computing connection-reuse section signatures
- Restore conservative credential redaction for unparseable proxies with multiple `@` separators
- Redact request URI credentials from the stream handler connection error message
- Reject enabled response streaming (`stream => true`) on cap-configured stream handlers
- Distinguish CurlMultiHandler and StreamHandler outcomes in connection-cap custom-handler guidance
- Reject raw cURL options that conflict with explicit multiplexing guarantees
- Stop explicit multiplexing conflict checks faulting on non-array cURL multi `options` values
- Reject required multiplexing when the final `CURLOPT_HTTPAUTH` mask permits NTLM
- Require an integer `CURLMOPT_PIPELINING` when combined with explicit multiplexing
- Check the required multiplexing cleartext proxy rule against the final cURL configuration
- Bound cURL multi handler blocking selects by the earliest pending request delay
- Stop synchronous cURL multi handler waits blocking on other transfers once the target has settled
- Stop cURL multi completion processing double-settling promises canceled from completion callbacks
- Run ready promise queue tasks before sleeping for delayed cURL multi requests
- Avoid integer overflow in cURL multi delay timing on 32-bit platforms
- Roll back failed cURL multi handle attachment instead of leaving requests pending
- Release the cURL easy handle when the `on_stats` callback throws
- Normalize response trailer field names to lowercase with values in wire order
- Retain response trailers only when an `on_trailers` callback is configured
- Validate the `on_trailers` callback before starting a cURL transfer
- Reject the `on_trailers` request option on the stream handler, which cannot observe trailers
- Match cookies, proxy schemes, auth types, and header names with locale-independent ASCII folding
- Reject proxy option values that Guzzle cannot classify identically to ext-curl
## 7.14.0 - 2026-07-08
### Added
- Added the `on_trailers` request option to expose parsed HTTP response trailers
- Added the `multiplex` request option with `Multiplexing::*` modes to control or require HTTP/2 multiplexing
- Added rejection of explicit `multiplex` requests when `CURLMOPT_PIPELINING` disables multiplexing
- Added the `max_host_connections` and `max_total_connections` client and cURL multi handler options
### Changed
- Redirects that discard the request body no longer require it to be rewindable
- Synchronous cURL multi handler requests no longer wait for other queued transfers
- Section SOCKS proxy connections by credentials on libcurl before 7.69.0
- Reject request-level `CURLOPT_SHARE` when combined with authenticated SOCKS proxy configuration
- Redact proxy userinfo containing raw control bytes in cURL errors
- Check linked curl/libcurl NTLM support before applying NTLM auth
- Clarify that NTLM is deprecated by both Guzzle and curl/libcurl
- Remove deprecation for the raw cURL `CURLOPT_CERTINFO` option
- Warn when a cURL multi option cannot be applied
### Deprecated
- Deprecate the raw `CURLOPT_PIPEWAIT` cURL option in favour of the `multiplex` request option
- Deprecate unknown handler constructor options
- Deprecate invalid `select_timeout` cURL multi handler option values
- Deprecate raw cURL multi connection cap options in favour of the named options
## 7.13.3 - 2026-07-08
### Changed
- Adjusted `guzzlehttp/promises` version constraint to `^2.5.1`
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12.4`
- Pass explicit trim characters ahead of the PHP 8.6 trim default change
### Fixed
- Stop matching cookie domains against hosts with a trailing newline
- Reject HTTP status codes and certificate type extensions with a trailing newline
- Treat PCRE engine failures as invalid cookie names during cookie validation
- Report PCRE engine failures when formatting log messages
- Report PCRE engine failures when splitting `no_proxy` values
## 7.13.2 - 2026-07-05
### Fixed
- Stop the cURL multi handler busy-waiting on request delays shorter than one second
- Stop cURL HEAD requests with request bodies hanging on responses that declare a content length
- The cURL handler no longer transmits request bodies on HEAD requests
- Preserve response headers when a response includes HTTP trailers
- Harden cURL response header block detection when HTTP trailers are received
- Corrected the PSR-7 class names in the Pool iterator exception
- Redirect body rewind failures no longer leak a bare `RuntimeException`
## 7.13.1 - 2026-06-29
### Fixed
- Allow middleware to rewrite partial URIs before transports validate them
## 7.13.0 - 2026-06-29
### Added
- Added the `crypto_method_max` request option to cap the maximum TLS protocol version
- Added HTTP QUERY redirect support, preserving method and body on 301 and 302
### Changed
- Section proxy tunnel connection reuse by credential so distinct credentials never share a tunnel
- Isolate concurrent foreign cURL proxy tunnels added while another owner's tunnel is active
- Route credentialed HTTP(S) proxy Proxy-Authorization headers through cURL proxy header handling
- Reject request-level `CURLOPT_SHARE` when combined with authenticated HTTP/HTTPS proxy tunnel configuration
- Remove deprecation for raw cURL `CURLOPT_PREREQFUNCTION` callbacks when defined by PHP cURL
- Route TLS 1.2 `crypto_method` requests to the stream handler when cURL cannot select TLS 1.2
- Reject final request URIs missing a scheme or host before transfer
### Deprecated
- Deprecate invalid protocols, force_ip_resolve, delay, cookies, and allow_redirects values
## 7.12.3 - 2026-06-23
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12.3`
### Security
- Treat IP and numeric cookie domains as exact-match-only (GHSA-g446-98w2-8p5w)
## 7.12.2 - 2026-06-23
### Fixed
- Clamp out-of-range `Max-Age` so a very large value no longer overflows to an already-expired timestamp
- Use strict comparison in `CookieJar` conflict resolution so distinct numeric-string names don't overwrite
- Store a cookie whose `Domain` has a trailing dot on the origin host instead of silently discarding it
- Fix `StreamHandler` hard-failing on bracketed IPv6 literal hosts when `force_ip_resolve` is set
- Use strict cookie `Path` comparison so `CookieJar::clear()` with a numeric path keeps a distinct-path cookie
- Fixed cookie handling for falsey `Domain`, `Max-Age`, path, and name values
- Fixed `decode_content` handling for falsey string values
- Fixed deprecated request option values reaching built-in handlers before normalization
## 7.12.1 - 2026-06-18
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12.1`
### Fixed
- Reject proxy URLs with a malformed scheme in the cURL handlers instead of letting libcurl mishandle them
### Security
- Reject HTTPS proxies when the installed libcurl lacks HTTPS-proxy support (GHSA-wpwq-4j6v-78m3)
- Reject dot-only cookie `Domain` attributes as match-all (GHSA-cwxw-98qj-8qjx)
## 7.12.0 - 2026-06-16
### Added
- Added `RequestOptions` constants for `curl`, `retries`, and `stream_context`
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12`
- Constrain cURL transport sharing to safe libcurl DNS and SSL session support
- Resolve proxy environment variables in the cURL handlers; libcurl no longer reads the environment itself
- Ignore proxy environment variables when the `proxy` request option makes a decision
- Disable proxy environment variables on Windows SAPIs other than CLI (httpoxy hardening)
- Redact proxy credentials from cURL handler error messages, following `Psr7\Utils::redactUserInfo()`
- Normalize no-proxy domain and IP literal matching across the cURL and stream handlers
### Deprecated
- Deprecated the request-level `handler` option, which will be ignored in 8.0
- Deprecated raw cURL request options outside the built-in cURL handlers' allow-list
- Deprecated the `CURLOPT_PROXYTYPE` cURL request option; set the proxy type via a scheme-prefixed proxy URL
- Deprecated PHP stream context options outside the built-in stream handler allow-list
- Deprecated passing `ntlm` as a built-in `auth` type
- Deprecated `Utils::describeType()`
- Deprecated non-finite floats in the `query` and `form_params` options; 8.0 rejects them
- Deprecated non-string scalar values in the `body` option; 8.0 rejects them
### Fixed
- Fix cURL TLS and HTTP/2 capability detection using libcurl feature checks
- Fix proxy `no` list matches being re-proxied through environment-configured proxies by libcurl
- Fix `no` list and `NO_PROXY` matching to support IP CIDR ranges, matching libcurl
- Fix the stream handler not applying scheme-less proxies and their credentials
## 7.11.2 - 2026-06-12
### Fixed
- Fixed non-finite float values emitting coercion warnings on PHP 8.5
## 7.11.1 - 2026-06-07
### Fixed

View file

@ -1,10 +1,6 @@
![Guzzle](.github/logo.png?raw=true)
# Guzzle, PHP HTTP client
[![Latest Version](https://img.shields.io/github/release/guzzle/guzzle.svg?style=flat-square)](https://github.com/guzzle/guzzle/releases)
[![Build Status](https://img.shields.io/github/actions/workflow/status/guzzle/guzzle/ci.yml?label=ci%20build&style=flat-square)](https://github.com/guzzle/guzzle/actions?query=workflow%3ACI)
[![Total Downloads](https://img.shields.io/packagist/dt/guzzlehttp/guzzle.svg?style=flat-square)](https://packagist.org/packages/guzzlehttp/guzzle)
# Guzzle, PHP HTTP Client
Guzzle is a PHP HTTP client that makes it easy to send HTTP requests and
trivial to integrate with web services.
@ -15,40 +11,13 @@ trivial to integrate with web services.
- Can send both synchronous and asynchronous requests using the same interface.
- Uses PSR-7 interfaces for requests, responses, and streams. This allows you
to utilize other PSR-7 compatible libraries with Guzzle.
- Supports PSR-18 allowing interoperability between other PSR-18 HTTP Clients.
- Supports PSR-18, allowing interoperability with other PSR-18 HTTP clients.
- Abstracts away the underlying HTTP transport, allowing you to write
environment and transport agnostic code; i.e., no hard dependency on cURL,
PHP streams, sockets, or non-blocking event loops.
- Middleware system allows you to augment and compose client behavior.
```php
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', 'https://api.github.com/repos/guzzle/guzzle');
echo $response->getStatusCode(); // 200
echo $response->getHeaderLine('content-type'); // 'application/json; charset=utf8'
echo $response->getBody(); // '{"id": 1420053, "name": "guzzle", ...}'
// Send an asynchronous request.
$request = new \GuzzleHttp\Psr7\Request('GET', 'http://httpbin.org');
$promise = $client->sendAsync($request)->then(function ($response) {
echo 'I completed! ' . $response->getBody();
});
$promise->wait();
```
## Help and docs
We use GitHub issues only to discuss bugs and new features. For support please refer to:
- [Documentation](docs/index.md)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/guzzle)
- [#guzzle](https://app.slack.com/client/T0D2S9JCT/CE6UAAKL4) channel on [PHP-HTTP Slack](https://slack.httplug.io/)
- [Gitter](https://gitter.im/guzzle/guzzle)
## Installing Guzzle
## Installation
The recommended way to install Guzzle is through
[Composer](https://getcomposer.org/).
@ -57,38 +26,71 @@ The recommended way to install Guzzle is through
composer require guzzlehttp/guzzle
```
## Version Guidance
| Version | Status | Packagist | Namespace | Repo | Docs | PSR-7 | PHP Version |
|---------|---------------------|---------------------|--------------|---------------------|---------------------|-------|--------------|
| 3.x | EOL (2016-10-31) | `guzzle/guzzle` | `Guzzle` | [v3][guzzle-3-repo] | [v3][guzzle-3-docs] | No | >=5.3.3,<7.0 |
| 4.x | EOL (2016-10-31) | `guzzlehttp/guzzle` | `GuzzleHttp` | [v4][guzzle-4-repo] | N/A | No | >=5.4,<7.0 |
| 5.x | EOL (2019-10-31) | `guzzlehttp/guzzle` | `GuzzleHttp` | [v5][guzzle-5-repo] | [v5][guzzle-5-docs] | No | >=5.4,<7.4 |
| 6.x | EOL (2023-10-31) | `guzzlehttp/guzzle` | `GuzzleHttp` | [v6][guzzle-6-repo] | [v6][guzzle-6-docs] | Yes | >=5.5,<8.0 |
| 7.x | Latest | `guzzlehttp/guzzle` | `GuzzleHttp` | [v7][guzzle-7-repo] | [v7][guzzle-7-docs] | Yes | >=7.2.5,<8.6 |
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 8.0 | Latest | >=7.4,<8.6 |
| 7.15 | Maintenance | >=7.2.5,<8.6 |
| 6.5 | End of Life | >=5.5,<8.0 |
[guzzle-3-repo]: https://github.com/guzzle/guzzle3
[guzzle-4-repo]: https://github.com/guzzle/guzzle/tree/4.x
[guzzle-5-repo]: https://github.com/guzzle/guzzle/tree/5.3
[guzzle-6-repo]: https://github.com/guzzle/guzzle/tree/6.5
[guzzle-7-repo]: https://github.com/guzzle/guzzle/tree/7.11
[guzzle-3-docs]: https://github.com/guzzle/guzzle3/tree/master/docs
[guzzle-5-docs]: https://github.com/guzzle/guzzle/tree/5.3/docs
[guzzle-6-docs]: https://github.com/guzzle/guzzle/tree/6.5/docs
[guzzle-7-docs]: https://github.com/guzzle/guzzle/blob/7.11/docs/index.md
## Quick Start
```php
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', 'https://api.example.com/users/123');
echo $response->getStatusCode(); // 200
echo $response->getHeaderLine('content-type'); // 'application/json'
echo $response->getBody(); // '{"id": 123, "name": "Ada"}'
```
For more examples, see the [Quick Start](docs/quick-start.md).
## Documentation
- [Quick Start](docs/quick-start.md)
- [Overview](docs/overview.md)
- [Request Options](docs/request-options.md)
- [Uploading Data](docs/uploading-data.md)
- [Cookies](docs/cookies.md)
- [Exceptions](docs/exceptions.md)
- [Guzzle and PSR-7](docs/guzzle-and-psr-7.md)
- [Handlers](docs/handlers.md)
- [Middleware](docs/middleware.md)
- [Testing Guzzle Clients](docs/testing-guzzle-clients.md)
- [FAQ](docs/faq.md)
- [Package Ecosystem](docs/package-ecosystem.md)
- [Upgrade Guide](UPGRADING.md)
- [Changelog](CHANGELOG.md)
We use GitHub issues only to discuss bugs and new features. For support, use
[Stack Overflow](https://stackoverflow.com/questions/tagged/guzzle), the
[#guzzle](https://app.slack.com/client/T0D2S9JCT/CE6UAAKL4) channel on
[PHP-HTTP Slack](https://slack.httplug.io/), or
[Gitter](https://gitter.im/guzzle/guzzle).
## Security
If you discover a security vulnerability within this package, please send an email to security@tidelift.com. All security vulnerabilities will be promptly addressed. Please do not disclose security-related issues publicly until a fix has been announced. Please see [Security Policy](https://github.com/guzzle/guzzle/security/policy) for more information.
If you discover a security vulnerability within this package, please send an
email to security@tidelift.com. All security vulnerabilities will be promptly
addressed. Please do not disclose security-related issues publicly until a fix
has been announced. Please see
[Security Policy](https://github.com/guzzle/guzzle/security/policy) for more
information.
## License
Guzzle is made available under the MIT License (MIT). Please see [License File](LICENSE) for more information.
Guzzle is made available under the MIT License (MIT). Please see
[License File](LICENSE) for more information.
## For Enterprise
Available as part of the Tidelift Subscription
The maintainers of Guzzle and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-guzzle?utm_source=packagist-guzzlehttp-guzzle&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)
The maintainers of Guzzle and thousands of other packages are working with
Tidelift to deliver commercial support and maintenance for the open source
dependencies you use to build your applications. Save time, reduce risk, and
improve code health, while paying the maintainers of the exact dependencies you
use.
[Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-guzzle?utm_source=packagist-guzzlehttp-guzzle&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
{
"name": "guzzle",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Auth;
use GuzzleHttp\Psr7;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* @internal
*/
final class DigestAuth
{
/**
* @var array<string, array{hash: string, sess: bool, rank: int, header: string}>
*/
private const ALGORITHMS = [
'MD5' => ['hash' => 'md5', 'sess' => false, 'rank' => 10, 'header' => 'MD5'],
'MD5-SESS' => ['hash' => 'md5', 'sess' => true, 'rank' => 11, 'header' => 'MD5-sess'],
'SHA-256' => ['hash' => 'sha256', 'sess' => false, 'rank' => 20, 'header' => 'SHA-256'],
'SHA-256-SESS' => ['hash' => 'sha256', 'sess' => true, 'rank' => 21, 'header' => 'SHA-256-sess'],
'SHA-512-256' => ['hash' => 'sha512/256', 'sess' => false, 'rank' => 30, 'header' => 'SHA-512-256'],
'SHA-512-256-SESS' => ['hash' => 'sha512/256', 'sess' => true, 'rank' => 31, 'header' => 'SHA-512-256-sess'],
];
/**
* @var array<string, true>
*/
private const DIGEST_CHALLENGE_PARAMETER_NAMES = [
'realm' => true,
'domain' => true,
'nonce' => true,
'opaque' => true,
'stale' => true,
'algorithm' => true,
'qop' => true,
'charset' => true,
'userhash' => true,
];
private function __construct()
{
}
public static function selectChallenge(
#[\SensitiveParameter]
ResponseInterface $response
): ?DigestChallenge {
$selected = null;
foreach ($response->getHeader('WWW-Authenticate') as $header) {
foreach (self::parseAuthenticateHeader($header) as $challenge) {
if ($challenge['scheme'] !== 'digest' || $challenge['invalid']) {
continue;
}
$digest = self::createChallenge($challenge['params']);
if ($digest === null) {
continue;
}
if ($selected === null || $digest->algorithm['rank'] > $selected->algorithm['rank']) {
$selected = $digest;
}
}
}
return $selected;
}
public static function authorizationHeader(
#[\SensitiveParameter]
RequestInterface $request,
DigestChallenge $challenge,
string $username,
#[\SensitiveParameter]
string $password,
string $cnonce,
string $nc = '00000001'
): ?string {
if ($challenge->algorithm['sess'] && $challenge->qop === null) {
return null;
}
$uri = $request->getRequestTarget();
if ($uri === '') {
$uri = '/';
}
foreach ([$username, $challenge->realm, $challenge->nonce, $uri, $cnonce] as $value) {
if (!self::isHeaderSafe($value)) {
return null;
}
}
if ($challenge->opaque !== null && !self::isHeaderSafe($challenge->opaque)) {
return null;
}
if ($challenge->qop !== null && !self::isNonceCount($nc)) {
return null;
}
$hash = $challenge->algorithm['hash'];
$ha1 = \hash($hash, $username.':'.$challenge->realm.':'.$password);
if ($challenge->algorithm['sess']) {
$ha1 = \hash($hash, $ha1.':'.$challenge->nonce.':'.$cnonce);
}
$ha2 = \hash($hash, $request->getMethod().':'.$uri);
$response = $challenge->qop === null
? \hash($hash, $ha1.':'.$challenge->nonce.':'.$ha2)
: \hash($hash, $ha1.':'.$challenge->nonce.':'.$nc.':'.$cnonce.':'.$challenge->qop.':'.$ha2);
if ($challenge->userhash) {
$headerUsername = \hash($hash, $username.':'.$challenge->realm);
if ($headerUsername === false) {
return null;
}
} else {
$headerUsername = $username;
}
$parts = [
'username='.self::quote($headerUsername),
'realm='.self::quote($challenge->realm),
'nonce='.self::quote($challenge->nonce),
'uri='.self::quote($uri),
'response='.self::quote($response),
'algorithm='.$challenge->algorithm['header'],
];
if ($challenge->opaque !== null) {
$parts[] = 'opaque='.self::quote($challenge->opaque);
}
if ($challenge->qop !== null) {
$parts[] = 'qop='.$challenge->qop;
$parts[] = 'nc='.$nc;
$parts[] = 'cnonce='.self::quote($cnonce);
}
if ($challenge->userhash) {
$parts[] = 'userhash=true';
}
return 'Digest '.\implode(', ', $parts);
}
/**
* @return list<array{scheme: string, params: array<string, string>, invalid: bool}>
*/
public static function parseAuthenticateHeader(string $header): array
{
$length = \strlen($header);
$offset = 0;
$challenges = [];
while (true) {
self::skipSeparators($header, $offset, $length);
if ($offset >= $length) {
break;
}
$scheme = self::readToken($header, $offset, $length);
if ($scheme === null) {
break;
}
self::skipWhitespace($header, $offset, $length);
if (self::skipToken68Challenge($header, $offset, $length)) {
$challenges[] = [
'scheme' => Psr7\Utils::asciiToLower($scheme),
'params' => [],
'invalid' => false,
];
continue;
}
$params = [];
$invalid = false;
while ($offset < $length) {
self::skipWhitespace($header, $offset, $length);
if ($offset < $length && $header[$offset] === ',') {
if (self::commaStartsNextChallenge($header, $offset + 1, $length)) {
if (Psr7\Utils::caselessEquals($scheme, 'Digest')
&& self::commaStartsKnownDigestParameterWithoutValue($header, $offset + 1, $length)
) {
$invalid = true;
break;
}
++$offset;
break;
}
++$offset;
continue;
}
$name = self::readToken($header, $offset, $length);
if ($name === null) {
if ($offset < $length) {
$invalid = true;
}
break;
}
self::skipWhitespace($header, $offset, $length);
if ($offset >= $length || $header[$offset] !== '=') {
$invalid = true;
break;
}
++$offset;
self::skipWhitespace($header, $offset, $length);
$value = self::readValue($header, $offset, $length);
if ($value === null) {
$invalid = true;
break;
}
$lowerName = Psr7\Utils::asciiToLower($name);
if (\array_key_exists($lowerName, $params)) {
$invalid = true;
}
$params[$lowerName] = $value;
self::skipWhitespace($header, $offset, $length);
if ($offset >= $length) {
break;
}
if ($header[$offset] !== ',') {
$invalid = true;
break;
}
}
$challenges[] = [
'scheme' => Psr7\Utils::asciiToLower($scheme),
'params' => $params,
'invalid' => $invalid,
];
}
return $challenges;
}
/**
* @param array<string, string> $params
*/
private static function createChallenge(array $params): ?DigestChallenge
{
if (!isset($params['nonce']) || $params['nonce'] === '') {
return null;
}
if (isset($params['charset']) && !Psr7\Utils::caselessEquals($params['charset'], 'UTF-8')) {
return null;
}
$algorithm = self::algorithm($params['algorithm'] ?? null);
if ($algorithm === null) {
return null;
}
$qop = self::selectQop($params['qop'] ?? null);
if ($qop === false) {
return null;
}
if ($algorithm['sess'] && $qop === null) {
return null;
}
$challenge = new DigestChallenge();
$challenge->algorithm = $algorithm;
$challenge->realm = $params['realm'] ?? '';
$challenge->nonce = $params['nonce'];
$challenge->opaque = $params['opaque'] ?? null;
if (isset($params['domain'])) {
$domainAreas = \preg_split('/[ \t]+/', $params['domain']);
if ($domainAreas === false) {
throw new \RuntimeException('Unable to split the Digest domain list: '.\preg_last_error_msg());
}
$challenge->domain = \array_values(\array_filter($domainAreas));
} else {
$challenge->domain = [];
}
$challenge->qop = $qop;
$challenge->stale = isset($params['stale']) && Psr7\Utils::caselessEquals($params['stale'], 'true');
$challenge->userhash = isset($params['userhash']) && Psr7\Utils::caselessEquals($params['userhash'], 'true');
return $challenge;
}
/**
* @return array{name: string, hash: string, sess: bool, rank: int, header: string}|null
*/
private static function algorithm(?string $algorithm): ?array
{
$name = Psr7\Utils::asciiToUpper($algorithm ?? 'MD5');
if (!isset(self::ALGORITHMS[$name])) {
return null;
}
$mapped = self::ALGORITHMS[$name];
if (!\in_array($mapped['hash'], \hash_algos(), true)) {
return null;
}
return ['name' => $name] + $mapped;
}
/**
* @return string|false|null
*/
private static function selectQop(?string $qop)
{
if ($qop === null) {
return null;
}
$tokens = \array_map(
static function (string $token): string {
return Psr7\Utils::asciiToLower(\trim($token, " \t"));
},
\explode(',', $qop)
);
return \in_array('auth', $tokens, true) ? 'auth' : false;
}
private static function quote(string $value): string
{
return '"'.\strtr($value, [
'\\' => '\\\\',
'"' => '\\"',
]).'"';
}
private static function isHeaderSafe(string $value): bool
{
return \preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value) === 1;
}
private static function isNonceCount(string $nc): bool
{
// Nonce counts start at one.
return $nc !== '00000000' && \preg_match('/^[0-9a-f]{8}$/D', $nc) === 1;
}
private static function commaStartsNextChallenge(string $header, int $offset, int $length): bool
{
self::skipWhitespace($header, $offset, $length);
$nextOffset = $offset;
$token = self::readToken($header, $nextOffset, $length);
if ($token === null) {
return false;
}
self::skipWhitespace($header, $nextOffset, $length);
return $nextOffset >= $length || $header[$nextOffset] !== '=';
}
private static function commaStartsKnownDigestParameterWithoutValue(string $header, int $offset, int $length): bool
{
self::skipWhitespace($header, $offset, $length);
$name = self::readToken($header, $offset, $length);
if ($name === null || !isset(self::DIGEST_CHALLENGE_PARAMETER_NAMES[Psr7\Utils::asciiToLower($name)])) {
return false;
}
self::skipWhitespace($header, $offset, $length);
return $offset >= $length || $header[$offset] !== '=';
}
private static function skipToken68Challenge(string $header, int &$offset, int $length): bool
{
$cursor = $offset;
$hasValue = false;
while ($cursor < $length && self::isToken68Char($header[$cursor])) {
$hasValue = true;
++$cursor;
}
while ($cursor < $length && $header[$cursor] === '=') {
++$cursor;
}
if (!$hasValue) {
return false;
}
$after = $cursor;
self::skipWhitespace($header, $after, $length);
if ($after < $length && $header[$after] !== ',') {
return false;
}
$offset = $after;
return true;
}
private static function skipSeparators(string $header, int &$offset, int $length): void
{
while ($offset < $length) {
$char = $header[$offset];
if ($char !== ',' && !self::isWhitespace($char)) {
return;
}
++$offset;
}
}
private static function skipWhitespace(string $header, int &$offset, int $length): void
{
while ($offset < $length && self::isWhitespace($header[$offset])) {
++$offset;
}
}
private static function isWhitespace(string $char): bool
{
return $char === ' ' || $char === "\t";
}
private static function readToken(string $header, int &$offset, int $length): ?string
{
$start = $offset;
while ($offset < $length && self::isTokenChar($header[$offset])) {
++$offset;
}
if ($offset === $start) {
return null;
}
$token = \substr($header, $start, $offset - $start);
return $token === false ? null : $token;
}
private static function isTokenChar(string $char): bool
{
return \strspn($char, "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") === 1;
}
private static function isToken68Char(string $char): bool
{
return \strspn($char, '-._~+/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') === 1;
}
private static function readValue(string $header, int &$offset, int $length): ?string
{
if ($offset >= $length) {
return null;
}
if ($header[$offset] === '"') {
return self::readQuotedString($header, $offset, $length);
}
return self::readToken($header, $offset, $length);
}
private static function readQuotedString(string $header, int &$offset, int $length): ?string
{
++$offset;
$value = '';
$escaped = false;
while ($offset < $length) {
$char = $header[$offset++];
if ($escaped) {
$value .= $char;
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === '"') {
return $value;
}
$value .= $char;
}
return null;
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Auth;
/**
* @internal
*/
final class DigestChallenge
{
/** @var array{name: string, hash: string, sess: bool, rank: int, header: string} */
public array $algorithm;
public string $realm = '';
public string $nonce;
public ?string $opaque = null;
/** @var list<string> */
public array $domain = [];
public ?string $qop = null;
public bool $stale = false;
public bool $userhash = false;
}

View file

@ -0,0 +1,843 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Auth\DigestAuth;
use GuzzleHttp\Auth\DigestChallenge;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\Psr7\LazyOpenStream;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* Applies built-in Basic authentication and handles Digest authentication
* challenges.
*/
final class AuthMiddleware
{
use NonSerializableTrait;
private const DIGEST_MAX_RETRIES = 2;
private const CHALLENGE_CACHE_LIMIT = 32;
/**
* Request option carrying nonce counts already spent during a Digest
* handshake, so stale retries advance instead of repeating a count.
*/
private const DIGEST_NONCE_COUNTS_OPTION = '__guzzle_digest_nonce_counts';
/**
* Headers that describe the request payload. A probe with an empty body
* must not carry them.
*
* @var list<string>
*/
private const DIGEST_PROBE_PAYLOAD_HEADERS = [
'Transfer-Encoding',
'Expect',
'Trailer',
'Content-Range',
'Content-Encoding',
'Content-MD5',
'Digest',
'Content-Digest',
'Repr-Digest',
];
/**
* @var callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private $nextHandler;
/**
* @var callable(): string
*/
private $cnonceGenerator;
private bool $reuseChallenges;
private ?string $credentialHashSecret = null;
/**
* @var array<string, array{challenge: DigestChallenge, credentials: string, nextNc: int}>
*/
private array $digestChallenges = [];
/**
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $nextHandler
* @param (callable(): string)|null $cnonceGenerator
*/
public function __construct(callable $nextHandler, ?callable $cnonceGenerator = null, bool $reuseChallenges = true)
{
$this->nextHandler = $nextHandler;
$this->cnonceGenerator = $cnonceGenerator ?? static function (): string {
return \bin2hex(\random_bytes(16));
};
$this->reuseChallenges = $reuseChallenges;
}
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
$auth = $options['auth'] ?? null;
if ($auth === null || $auth === false || $auth === [] || \is_string($auth)) {
return ($this->nextHandler)($request, $options);
}
$normalizedAuth = self::normalizeAuth($auth);
if ($normalizedAuth === null) {
return ($this->nextHandler)($request, $options);
}
[$username, $password, $type] = $normalizedAuth;
if ($type === 'basic') {
return $this->sendBasic($request, $options, $username, $password);
}
return $this->sendDigest($request, $options, $username, $password);
}
/**
* @param array<array-key, mixed> $auth
*
* @return array{0: string, 1: string, 2: 'basic'|'digest'}|null
*/
private static function normalizeAuth(
#[\SensitiveParameter]
array $auth
): ?array {
$type = 'basic';
if (\array_key_exists(2, $auth) && $auth[2] !== null) {
if (!\is_string($auth[2])) {
throw new InvalidArgumentException('auth type must be a string');
}
$type = Psr7\Utils::asciiToLower($auth[2]);
}
if (!\in_array($type, ['basic', 'digest'], true)) {
return null;
}
if (!\array_key_exists(0, $auth) || !\array_key_exists(1, $auth)) {
throw new InvalidArgumentException('auth must contain username and password strings');
}
if (!\is_string($auth[0]) || !\is_string($auth[1])) {
throw new InvalidArgumentException('auth must contain username and password strings');
}
return [$auth[0], $auth[1], $type];
}
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function sendBasic(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
string $username,
#[\SensitiveParameter]
string $password
): PromiseInterface {
if (\strpos($username, ':') !== false) {
throw new InvalidArgumentException('Basic authentication username must not contain a colon');
}
if (\preg_match('/[\x00-\x1F\x7F]/', $username.$password) !== 0) {
throw new InvalidArgumentException('Basic authentication credentials must not contain ASCII control characters');
}
unset($options['auth']);
return ($this->nextHandler)(
$request->withHeader('Authorization', 'Basic '.\base64_encode($username.':'.$password)),
$options
);
}
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function sendDigest(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
string $username,
#[\SensitiveParameter]
string $password
): PromiseInterface {
$preemptive = $this->reuseChallenges
? $this->preemptiveDigestRequest($request, $username, $password)
: null;
if ($preemptive !== null) {
$preemptiveOptions = self::withTemporarySink($options);
unset($preemptiveOptions['auth']);
return ($this->nextHandler)($preemptive, $preemptiveOptions)->then(
function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($request, $options, $preemptiveOptions, $username, $password) {
// Clear on 4xx/5xx other than 401 so non-conformant stale-nonce
// errors cannot poison the cache indefinitely.
$status = $response->getStatusCode();
if ($status >= 400 && $status !== 401) {
$this->clearDigestChallenge($request);
}
return $this->handleDigestResponse($request, $options, $preemptiveOptions, $response, $username, $password, false);
},
function (
#[\SensitiveParameter]
$reason
) use ($request, $preemptiveOptions) {
return $this->handleDigestRejection($request, $preemptiveOptions, $reason, true);
}
);
}
$probeOptions = self::withTemporarySink($options);
unset($probeOptions['auth']);
[$probeRequest, $bodyWithheld] = self::probeRequest($request, $options);
if ($bodyWithheld) {
// The probe has no payload, so prepare_body must not add Expect
// if a custom empty stream has unknown size.
$probeOptions[RequestOptions::EXPECT] = false;
}
return ($this->nextHandler)($probeRequest, $probeOptions)->then(
function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($request, $options, $probeOptions, $username, $password, $bodyWithheld) {
return $this->handleDigestResponse($request, $options, $probeOptions, $response, $username, $password, $bodyWithheld);
},
function (
#[\SensitiveParameter]
$reason
) use ($request, $probeOptions) {
return $this->handleDigestRejection($request, $probeOptions, $reason, false);
}
);
}
private function preemptiveDigestRequest(
#[\SensitiveParameter]
RequestInterface $request,
string $username,
#[\SensitiveParameter]
string $password
): ?RequestInterface {
try {
if ($request->getBody()->getSize() !== 0) {
return null;
}
} catch (\Exception $e) {
return null;
}
$key = self::digestCacheKey($request);
if ($key === null) {
return null;
}
$entry = $this->digestChallenges[$key] ?? null;
if ($entry === null
|| $entry['credentials'] !== $this->digestCredentialKey($username, $password)
|| !self::challengeCoversRequest($entry['challenge'], $request)
) {
return null;
}
$authorization = DigestAuth::authorizationHeader(
$request,
$entry['challenge'],
$username,
$password,
($this->cnonceGenerator)(),
\sprintf('%08x', $entry['nextNc'])
);
if ($authorization === null) {
return null;
}
++$this->digestChallenges[$key]['nextNc'];
return $request->withHeader('Authorization', $authorization);
}
/**
* Builds the unauthenticated probe request for a Digest handshake.
*
* @return array{0: RequestInterface, 1: bool}
*/
private static function probeRequest(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): array {
$probe = $request->withoutHeader('Authorization');
try {
$size = $probe->getBody()->getSize();
} catch (\Exception $e) {
$size = null;
}
if ($size === 0) {
return [$probe, false];
}
$streamFactory = self::requireStreamFactory(
$options[RequestOptions::STREAM_FACTORY] ?? new HttpFactory()
);
$probe = $probe->withBody($streamFactory->createStream(''))
->withHeader('Content-Length', '0');
foreach (self::DIGEST_PROBE_PAYLOAD_HEADERS as $header) {
$probe = $probe->withoutHeader($header);
}
return [$probe, true];
}
/**
* @return ResponseInterface|PromiseInterface<ResponseInterface, mixed>
*/
private function handleDigestResponse(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
array $probeOptions,
#[\SensitiveParameter]
ResponseInterface $response,
string $username,
#[\SensitiveParameter]
string $password,
bool $bodyWithheld
) {
$status = $response->getStatusCode();
$previousEntry = null;
if ($this->reuseChallenges && $status === 401) {
$key = self::digestCacheKey($request);
$previousEntry = $key !== null ? ($this->digestChallenges[$key] ?? null) : null;
$this->clearDigestChallenge($request);
}
if ($status !== 401) {
if ($this->reuseChallenges && $response->hasHeader('Authentication-Info')) {
$this->clearDigestChallenge($request);
}
$response = self::restoreOriginalSink($request, $response, $probeOptions);
if ($bodyWithheld && $status !== 407 && !self::isRedirectLikeResponse($response)) {
throw new ResponseException(
'Digest authentication failed because the server did not issue a challenge; the request was probed without its body',
$request,
$response
);
}
return $response;
}
$challenge = DigestAuth::selectChallenge($response);
if ($challenge === null) {
return self::restoreOriginalSink($request, $response, $probeOptions);
}
$retries = $options['__guzzle_digest_retries'] ?? 0;
if (!\is_int($retries)) {
$retries = 0;
}
if (($retries > 0 && !$challenge->stale) || $retries >= self::DIGEST_MAX_RETRIES) {
return self::restoreOriginalSink($request, $response, $probeOptions);
}
try {
self::rewindBodyForRetry($request, $bodyWithheld);
} catch (\Exception $e) {
$response = self::restoreOriginalSink($request, $response, $probeOptions);
throw new ResponseException(
'Digest authentication failed because the request body could not be rewound',
$request,
$response,
$e
);
}
$nonceCounts = self::digestNonceCountsFromOptions($options);
if ($previousEntry !== null
&& $previousEntry['credentials'] === $this->digestCredentialKey($username, $password)
) {
self::rememberDigestNonceCount($nonceCounts, $previousEntry['challenge'], $previousEntry['nextNc']);
}
$nc = $challenge->qop === null ? 1 : ($nonceCounts[self::digestNonceCountKey($challenge)] ?? 1);
$authorization = DigestAuth::authorizationHeader(
$request,
$challenge,
$username,
$password,
($this->cnonceGenerator)(),
\sprintf('%08x', $nc)
);
if ($authorization === null) {
return self::restoreOriginalSink($request, $response, $probeOptions);
}
$response->getBody()->close();
self::rememberDigestNonceCount($nonceCounts, $challenge, $nc + 1);
$retryOptions = $options;
$retryOptions['__guzzle_digest_retries'] = $retries + 1;
$retryOptions[self::DIGEST_NONCE_COUNTS_OPTION] = $nonceCounts;
$downstreamOptions = $retryOptions;
// The nonce-count map only carries handshake state into the retry
// recursion; downstream handlers must not observe it.
unset($downstreamOptions[self::DIGEST_NONCE_COUNTS_OPTION]);
// The caller's delay applies once, before the first Digest leg, not
// before each handshake retry.
unset($downstreamOptions[RequestOptions::DELAY]);
$downstreamOptions = self::withTemporarySink($downstreamOptions);
unset($downstreamOptions['auth']);
$retryRequest = $request->withHeader('Authorization', $authorization);
return ($this->nextHandler)($retryRequest, $downstreamOptions)->then(
function (
#[\SensitiveParameter]
ResponseInterface $retryResponse
) use ($retryRequest, $retryOptions, $downstreamOptions, $username, $password, $challenge, $nc) {
if ($this->reuseChallenges && self::isCacheableDigestSuccess($retryResponse)) {
$this->storeDigestChallenge($retryRequest, $challenge, $username, $password, $nc + 1);
}
return $this->handleDigestResponse($retryRequest, $retryOptions, $downstreamOptions, $retryResponse, $username, $password, false);
},
function (
#[\SensitiveParameter]
$reason
) use ($retryRequest, $downstreamOptions) {
return $this->handleDigestRejection($retryRequest, $downstreamOptions, $reason, false);
}
);
}
private static function isCacheableDigestSuccess(ResponseInterface $response): bool
{
$status = $response->getStatusCode();
return $status >= 200 && $status < 400 && !$response->hasHeader('Authentication-Info');
}
private function storeDigestChallenge(
#[\SensitiveParameter]
RequestInterface $request,
DigestChallenge $challenge,
string $username,
#[\SensitiveParameter]
string $password,
int $nextNc
): void {
$key = self::digestCacheKey($request);
if ($key === null || $challenge->qop === null) {
return;
}
unset($this->digestChallenges[$key]);
if (\count($this->digestChallenges) >= self::CHALLENGE_CACHE_LIMIT) {
\array_shift($this->digestChallenges);
}
$this->digestChallenges[$key] = [
'challenge' => $challenge,
'credentials' => $this->digestCredentialKey($username, $password),
'nextNc' => $nextNc,
];
}
private function clearDigestChallenge(RequestInterface $request): void
{
$key = self::digestCacheKey($request);
if ($key !== null) {
unset($this->digestChallenges[$key]);
}
}
/**
* @param array<array-key, mixed> $options
*
* @return array<string, int>
*/
private static function digestNonceCountsFromOptions(array $options): array
{
$counts = $options[self::DIGEST_NONCE_COUNTS_OPTION] ?? [];
if (!\is_array($counts)) {
return [];
}
$normalized = [];
foreach ($counts as $key => $nextNc) {
if (\is_string($key) && \is_int($nextNc) && $nextNc > 0 && $nextNc < \PHP_INT_MAX) {
$normalized[$key] = $nextNc;
}
}
return $normalized;
}
private static function digestNonceCountKey(DigestChallenge $challenge): string
{
return \implode("\0", [
$challenge->realm,
$challenge->nonce,
$challenge->qop ?? '',
$challenge->algorithm['header'],
]);
}
/**
* @param array<string, int> $counts
*/
private static function rememberDigestNonceCount(array &$counts, DigestChallenge $challenge, int $nextNc): void
{
if ($challenge->qop === null) {
return;
}
$key = self::digestNonceCountKey($challenge);
$counts[$key] = \max($counts[$key] ?? 1, $nextNc);
}
/**
* @param mixed $reason
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function handleDigestRejection(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
$reason,
bool $preemptive
): PromiseInterface {
if ($this->reuseChallenges && $reason instanceof ResponseException) {
$response = $reason->getResponse();
if ($response->getStatusCode() === 401
|| $response->hasHeader('Authentication-Info')
|| ($preemptive && $response->getStatusCode() >= 400)
) {
$this->clearDigestChallenge($request);
}
}
return self::restoreOriginalSinkOnRejection($options, $reason);
}
private static function digestCacheKey(RequestInterface $request): ?string
{
$uri = $request->getUri();
$scheme = Psr7\Utils::asciiToLower($uri->getScheme());
$host = HostIdentity::canonicalHost($uri->getHost());
if (($scheme !== 'http' && $scheme !== 'https') || $host === '') {
return null;
}
$port = $uri->getPort() ?? ($scheme === 'https' ? 443 : 80);
return $scheme.'://'.$host.':'.$port.'|'.HostIdentity::canonicalHostHeader($request->getHeaderLine('Host'));
}
private function digestCredentialKey(
string $username,
#[\SensitiveParameter]
string $password
): string {
if ($this->credentialHashSecret === null) {
$this->credentialHashSecret = \random_bytes(32);
}
return \hash_hmac('sha256', $username."\0".$password, $this->credentialHashSecret);
}
private static function challengeCoversRequest(DigestChallenge $challenge, RequestInterface $request): bool
{
if ($challenge->domain === []) {
return true;
}
$target = $request->getRequestTarget();
foreach ($challenge->domain as $space) {
$prefix = self::protectionSpacePath($space, $request);
if ($prefix !== null && \str_starts_with($target, $prefix)) {
return true;
}
}
return false;
}
private static function protectionSpacePath(string $space, RequestInterface $request): ?string
{
if ($space === '') {
return null;
}
if ($space[0] === '/') {
return $space;
}
try {
$uri = new Psr7\Uri($space);
} catch (\InvalidArgumentException $e) {
return null;
}
if (Psr7\UriComparator::isCrossOrigin($uri, self::effectiveRequestUri($request))) {
return null;
}
$path = $uri->getPath();
$prefix = $path === '' ? '/' : $path;
if ($uri->getQuery() !== '') {
$prefix .= '?'.$uri->getQuery();
}
return $prefix;
}
private static function effectiveRequestUri(RequestInterface $request): UriInterface
{
$uri = $request->getUri();
$host = $request->getHeaderLine('Host');
if ($host === '' || Psr7\Utils::caselessEquals($host, $uri->getHost())) {
return $uri;
}
try {
$authority = new Psr7\Uri('//'.$host);
if ($authority->getHost() === '') {
return $uri;
}
return $uri->withHost($authority->getHost())->withPort($authority->getPort());
} catch (\InvalidArgumentException $e) {
return $uri;
}
}
private static function isRedirectLikeResponse(ResponseInterface $response): bool
{
return $response->hasHeader('Location')
&& \in_array($response->getStatusCode(), [301, 302, 303, 307, 308], true);
}
private static function rewindBodyForRetry(
#[\SensitiveParameter]
RequestInterface $request,
bool $bodyWithheld
): void {
$body = $request->getBody();
if (!$bodyWithheld) {
try {
if ($body->getSize() === 0) {
return;
}
} catch (\Exception $e) {
// Fall through to the standard rewind path.
}
Psr7\Message::rewindBody($request);
return;
}
if ($body->isSeekable()) {
$body->rewind();
}
}
private static function withTemporarySink(
#[\SensitiveParameter]
array $options
): array {
// The 'stream' option is deliberately not checked here: the cURL
// handlers do not support streaming and write each leg to the sink,
// so a configured sink always needs challenge-body protection.
if (!isset($options[RequestOptions::SINK])) {
return $options;
}
$streamFactory = self::requireStreamFactory(
$options[RequestOptions::STREAM_FACTORY] ?? new HttpFactory()
);
$options['__guzzle_auth_original_sink'] = $options[RequestOptions::SINK];
$options[RequestOptions::SINK] = $streamFactory->createStreamFromResource(
Psr7\Utils::tryFopen('php://temp', 'w+')
);
return $options;
}
private static function restoreOriginalSink(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
array $options
): ResponseInterface {
if (!\array_key_exists('__guzzle_auth_original_sink', $options)) {
return $response;
}
try {
$source = $response->getBody();
if ($source->isSeekable()) {
$source->rewind();
}
$target = self::streamForOriginalSink($options['__guzzle_auth_original_sink']);
Psr7\Utils::copyToStream($source, $target);
if ($target->isSeekable()) {
$target->rewind();
}
return $response->withBody($target);
} catch (\Exception $e) {
throw new ResponseException(
$e->getMessage() !== '' ? $e->getMessage() : 'Failed to write the response body',
$request,
$response,
$e
);
}
}
/**
* @param mixed $reason
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
private static function restoreOriginalSinkOnRejection(
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
$reason
): PromiseInterface {
if (!$reason instanceof ResponseException || !\array_key_exists('__guzzle_auth_original_sink', $options)) {
/** @var PromiseInterface<ResponseInterface, mixed> */
return P\Create::rejectionFor($reason);
}
try {
$response = self::restoreOriginalSink($reason->getRequest(), $reason->getResponse(), $options);
/** @var PromiseInterface<ResponseInterface, mixed> */
return P\Create::rejectionFor($reason->withResponse($response));
} catch (\Throwable $e) {
/** @var PromiseInterface<ResponseInterface, mixed> */
return P\Create::rejectionFor($e);
}
}
/**
* @param mixed $sink
*/
private static function streamForOriginalSink($sink): StreamInterface
{
if (\is_string($sink)) {
return new LazyOpenStream($sink, 'w+');
}
if (\is_resource($sink)) {
return self::streamForResourceSink(Psr7\Utils::streamFor($sink));
}
if (!$sink instanceof StreamInterface) {
throw new InvalidArgumentException(\sprintf(
'sink must be a resource, string, or %s',
StreamInterface::class
));
}
return Psr7\Utils::streamFor($sink);
}
/**
* Decorates a caller-owned sink stream so that closing the response body
* detaches Guzzle's wrapper without closing the original PHP resource.
*/
private static function streamForResourceSink(StreamInterface $stream): StreamInterface
{
return Psr7\FnStream::decorate($stream, [
'close' => static function () use ($stream): void {
$stream->detach();
},
]);
}
/**
* @param mixed $factory
*/
private static function requireStreamFactory($factory): StreamFactoryInterface
{
if (!$factory instanceof StreamFactoryInterface) {
throw new InvalidArgumentException(\sprintf(
'%s must be an instance of %s',
RequestOptions::STREAM_FACTORY,
StreamFactoryInterface::class
));
}
return $factory;
}
}

View file

@ -1,15 +1,14 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\MessageInterface;
final class BodySummarizer implements BodySummarizerInterface
{
/**
* @var int|null
*/
private $truncateAt;
private ?int $truncateAt;
public function __construct(?int $truncateAt = null)
{
@ -21,8 +20,10 @@ final class BodySummarizer implements BodySummarizerInterface
*/
public function summarize(MessageInterface $message): ?string
{
return $this->truncateAt === null
? Psr7\Message::bodySummary($message)
: Psr7\Message::bodySummary($message, $this->truncateAt);
try {
return Psr7\Message::bodySummary($message, $this->truncateAt);
} catch (\Exception $e) {
return null;
}
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\MessageInterface;

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,19 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
/**
@ -16,14 +24,86 @@ interface ClientInterface
/**
* The Guzzle major version.
*/
public const MAJOR_VERSION = 7;
public const MAJOR_VERSION = 8;
/**
* Send an HTTP request.
*
* @param RequestInterface $request Request to send
* @param array $options Request options to apply to the given
* request and to the transfer.
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options Request options to apply to the given request and to the transfer.
*
* @throws GuzzleException
*/
@ -33,8 +113,82 @@ interface ClientInterface
* Asynchronously send an HTTP request.
*
* @param RequestInterface $request Request to send
* @param array $options Request options to apply to the given
* request and to the transfer.
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options Request options to apply to the given request and to the transfer.
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface;
@ -45,9 +199,82 @@ interface ClientInterface
* relative path to append to the base path of the client. The URL can
* contain the query string as well.
*
* @param string $method HTTP method.
* @param string|UriInterface $uri URI object or string.
* @param array $options Request options to apply.
* @param string $method HTTP method.
* @param string|UriInterface $uri URI object or string.
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options Request options to apply.
*
* @throws GuzzleException
*/
@ -58,27 +285,86 @@ interface ClientInterface
*
* Use an absolute path to override the base path of the client, or a
* relative path to append to the base path of the client. The URL can
* contain the query string as well. Use an array to provide a URL
* template and additional variables to use in the URL template expansion.
* contain the query string as well.
*
* @param string $method HTTP method
* @param string|UriInterface $uri URI object or string.
* @param array $options Request options to apply.
* @param string $method HTTP method
* @param string|UriInterface $uri URI object or string.
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options Request options to apply.
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function requestAsync(string $method, $uri, array $options = []): PromiseInterface;
/**
* Get a client configuration option.
*
* These options include default request options of the client, a "handler"
* (if utilized by the concrete client), and a "base_uri" if utilized by
* the concrete client.
*
* @param string|null $option The config option to retrieve.
*
* @return mixed
*
* @deprecated ClientInterface::getConfig will be removed in guzzlehttp/guzzle:8.0.
*/
public function getConfig(?string $option = null);
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,11 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use GuzzleHttp\HostIdentity;
use GuzzleHttp\Psr7;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
@ -10,15 +14,17 @@ use Psr\Http\Message\ResponseInterface;
*/
class CookieJar implements CookieJarInterface
{
private const MAX_SET_COOKIE_FIELD_LENGTH = 8190;
private const MAX_SET_COOKIE_FIELDS = 50;
private const MAX_REQUEST_COOKIES = 150;
private const MAX_COOKIE_HEADER_LENGTH = 8190;
/**
* @var SetCookie[] Loaded cookie data
*/
private $cookies = [];
private array $cookies = [];
/**
* @var bool
*/
private $strictMode;
private bool $strictMode;
/**
* @param bool $strictMode Set to true to throw exceptions when invalid
@ -27,8 +33,11 @@ class CookieJar implements CookieJarInterface
* arrays that can be used with the SetCookie
* constructor
*/
public function __construct(bool $strictMode = false, array $cookieArray = [])
{
public function __construct(
bool $strictMode = false,
#[\SensitiveParameter]
array $cookieArray = []
) {
$this->strictMode = $strictMode;
foreach ($cookieArray as $cookie) {
@ -45,14 +54,21 @@ class CookieJar implements CookieJarInterface
* @param array $cookies Cookies to create the jar from
* @param string $domain Domain to set the cookies to
*/
public static function fromArray(array $cookies, string $domain): self
{
public static function fromArray(
#[\SensitiveParameter]
array $cookies,
string $domain
): self {
$cookieJar = new self();
foreach ($cookies as $name => $value) {
if (!\is_scalar($value) && !(\is_object($value) && \method_exists($value, '__toString'))) {
throw new \InvalidArgumentException('Cookie value must be scalar or stringable');
}
$cookieJar->setCookie(new SetCookie([
'Domain' => $domain,
'Name' => $name,
'Value' => $value,
'Name' => (string) $name,
'Value' => (string) $value,
'Discard' => true,
]));
}
@ -88,7 +104,7 @@ class CookieJar implements CookieJarInterface
public function getCookieByName(string $name): ?SetCookie
{
foreach ($this->cookies as $cookie) {
if ($cookie->getName() !== null && \strcasecmp($cookie->getName(), $name) === 0) {
if ($cookie->getName() === $name) {
return $cookie;
}
}
@ -109,14 +125,14 @@ class CookieJar implements CookieJarInterface
$this->cookies = [];
return;
} elseif (!$path) {
} elseif ($path === null) {
$this->cookies = \array_filter(
$this->cookies,
static function (SetCookie $cookie) use ($domain): bool {
return $cookie->getDomain() === null || !$cookie->matchesDomain($domain);
}
);
} elseif (!$name) {
} elseif ($name === null) {
$this->cookies = \array_filter(
$this->cookies,
static function (SetCookie $cookie) use ($path, $domain): bool {
@ -128,9 +144,9 @@ class CookieJar implements CookieJarInterface
} else {
$this->cookies = \array_filter(
$this->cookies,
static function (SetCookie $cookie) use ($path, $domain, $name) {
static function (SetCookie $cookie) use ($path, $domain, $name): bool {
return !($cookie->getDomain() !== null
&& $cookie->getName() == $name
&& $cookie->getName() === $name
&& $cookie->matchesPath($path)
&& $cookie->matchesDomain($domain));
}
@ -148,8 +164,10 @@ class CookieJar implements CookieJarInterface
);
}
public function setCookie(SetCookie $cookie): bool
{
public function setCookie(
#[\SensitiveParameter]
SetCookie $cookie
): bool {
// If the name string is empty (but not 0), ignore the set-cookie
// string entirely.
$name = $cookie->getName();
@ -168,13 +186,23 @@ class CookieJar implements CookieJarInterface
return false;
}
$maxAge = $cookie->getMaxAge();
if ($maxAge !== null && $maxAge <= 0) {
if ($cookie->getDomain() !== null) {
$this->removeCookie($cookie);
}
return false;
}
// Resolve conflicts with previously set cookies
foreach ($this->cookies as $i => $c) {
// Two cookies are identical, when their path, and domain are
// identical.
if ($c->getPath() != $cookie->getPath()
|| $c->getDomain() != $cookie->getDomain()
|| $c->getName() != $cookie->getName()
if ($c->getPath() !== $cookie->getPath()
|| $c->getDomain() !== $cookie->getDomain()
|| $c->getHostOnly() !== $cookie->getHostOnly()
|| $c->getName() !== $cookie->getName()
) {
continue;
}
@ -221,27 +249,105 @@ class CookieJar implements CookieJarInterface
return new \ArrayIterator(\array_values($this->cookies));
}
public function extractCookies(RequestInterface $request, ResponseInterface $response): void
{
public function extractCookies(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response
): void {
if ($cookieHeader = $response->getHeader('Set-Cookie')) {
$uri = $request->getUri();
$requestHost = HostIdentity::canonicalHost($uri->getHost());
$secure = $uri->getScheme() === 'https';
$accepted = 0;
foreach ($cookieHeader as $cookie) {
if (\strlen($cookie) > self::MAX_SET_COOKIE_FIELD_LENGTH) {
continue;
}
$sc = SetCookie::fromString($cookie);
if (!$sc->getDomain()) {
$sc->setDomain($request->getUri()->getHost());
$domain = $sc->getDomain();
if ($domain === null || $domain === '') {
$sc->setDomain($requestHost);
$sc->setHostOnly(true);
} else {
$sc->setHostOnly(false);
}
if (0 !== \strpos($sc->getPath(), '/')) {
$sc->setPath($this->getCookiePathFromRequest($request));
}
if (!$sc->matchesDomain($request->getUri()->getHost())) {
if (!$sc->matchesDomain($requestHost)) {
continue;
}
if (!$secure && ($sc->getSecure() || $this->overlaysSecureCookie($sc))) {
continue;
}
$prefixName = Psr7\Utils::asciiToLower((string) $sc->getName());
if (\str_starts_with($prefixName, '__secure-') && !$sc->getSecure()) {
continue;
}
if (\str_starts_with($prefixName, '__host-') && (!$sc->getSecure() || !$sc->getHostOnly() || $sc->getPath() !== '/' || !self::hasPathAttribute($cookie))) {
continue;
}
// Note: At this point `$sc->getDomain()` being a public suffix should
// be rejected, but we don't want to pull in the full PSL dependency.
$this->setCookie($sc);
if ($this->setCookie($sc) && ++$accepted === self::MAX_SET_COOKIE_FIELDS) {
break;
}
}
}
}
private function overlaysSecureCookie(SetCookie $cookie): bool
{
foreach ($this->cookies as $stored) {
if (self::isSecureCookieOverlay($cookie, $stored)) {
return true;
}
}
return false;
}
private static function isSecureCookieOverlay(SetCookie $cookie, SetCookie $stored): bool
{
if ($stored->getName() !== $cookie->getName() || !$stored->getSecure() || $stored->isExpired()) {
return false;
}
$domain = $cookie->getDomain();
$storedDomain = $stored->getDomain();
if ($domain === null || $storedDomain === null) {
return false;
}
if (!HostIdentity::cookieDomainMatches($storedDomain, $domain) && !HostIdentity::cookieDomainMatches($domain, $storedDomain)) {
return false;
}
return $stored->matchesPath($cookie->getPath());
}
/**
* Mirrors SetCookie::fromString()'s splitting because parsed cookies cannot
* distinguish an absent Path attribute from a defaulted path.
*/
private static function hasPathAttribute(string $header): bool
{
$parts = \explode(';', $header);
\array_shift($parts);
foreach ($parts as $part) {
$separator = \strpos($part, '=');
if ($separator !== false && Psr7\Utils::caselessEquals(\trim(\substr($part, 0, $separator), " \t"), 'Path')) {
return true;
}
}
return false;
}
/**
* Computes cookie path following RFC 6265 section 5.1.4
*
@ -267,12 +373,15 @@ class CookieJar implements CookieJarInterface
return \substr($uriPath, 0, $lastSlashPos);
}
public function withCookieHeader(RequestInterface $request): RequestInterface
{
public function withCookieHeader(
#[\SensitiveParameter]
RequestInterface $request
): RequestInterface {
$values = [];
$headerLength = 8;
$uri = $request->getUri();
$scheme = $uri->getScheme();
$host = $uri->getHost();
$host = HostIdentity::canonicalHost($uri->getHost());
$path = $uri->getPath() ?: '/';
foreach ($this->cookies as $cookie) {
@ -282,8 +391,19 @@ class CookieJar implements CookieJarInterface
&& !$cookie->isExpired()
&& (!$cookie->getSecure() || $scheme === 'https')
) {
$values[] = $cookie->getName().'='
.$cookie->getValue();
$name = (string) $cookie->getName();
$value = (string) $cookie->getValue();
$separatorLength = $values === [] ? 0 : 2;
$valueLength = \strlen($name) + 1 + \strlen($value);
if ($headerLength + $separatorLength + $valueLength > self::MAX_COOKIE_HEADER_LENGTH) {
break;
}
$values[] = $name.'='.$value;
$headerLength += $separatorLength + $valueLength;
if (\count($values) === self::MAX_REQUEST_COOKIES) {
break;
}
}
}
@ -300,11 +420,20 @@ class CookieJar implements CookieJarInterface
{
$cookieValue = $cookie->getValue();
if (($cookieValue === null || $cookieValue === '') && $cookie->getDomain() !== null) {
$this->clear(
$cookie->getDomain(),
$cookie->getPath(),
$cookie->getName()
);
$this->removeCookie($cookie);
}
}
private function removeCookie(SetCookie $cookie): void
{
$this->cookies = \array_filter(
$this->cookies,
static function (SetCookie $stored) use ($cookie): bool {
return !($stored->getName() === $cookie->getName()
&& $stored->getPath() === $cookie->getPath()
&& $stored->getDomain() === $cookie->getDomain()
&& $stored->getHostOnly() === $cookie->getHostOnly());
}
);
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use Psr\Http\Message\RequestInterface;

View file

@ -1,23 +1,37 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use GuzzleHttp\Utils;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7\DiagnosticValue;
/**
* Persists non-session cookies using a JSON formatted file
*/
class FileCookieJar extends CookieJar
{
use NonSerializableTrait;
/**
* @var string filename
*/
private $filename;
private string $filename;
/**
* @var bool Control whether to persist session cookies or not.
*/
private $storeSessionCookies;
private bool $storeSessionCookies;
/**
* @var bool Whether to save the cookie jar on destruction.
*
* Disabled by __wakeup() to prevent FileCookieJar from being used as a
* PHP object injection file-write gadget when an application unserializes
* attacker-controlled data.
*/
private bool $autoSave = false;
/**
* Create a new FileCookieJar object
@ -26,7 +40,7 @@ class FileCookieJar extends CookieJar
* @param bool $storeSessionCookies Set to true to store session cookies
* in the cookie jar.
*
* @throws \RuntimeException if the file cannot be found or created
* @throws \RuntimeException if the file cannot be loaded or is invalid
*/
public function __construct(string $cookieFile, bool $storeSessionCookies = false)
{
@ -37,6 +51,8 @@ class FileCookieJar extends CookieJar
if (\file_exists($cookieFile)) {
$this->load($cookieFile);
}
$this->autoSave = true;
}
/**
@ -44,7 +60,24 @@ class FileCookieJar extends CookieJar
*/
public function __destruct()
{
$this->save($this->filename);
if ($this->autoSave) {
$this->save($this->filename);
}
}
/**
* Disable automatic persistence after unserialization.
*/
public function __wakeup(): void
{
$this->autoSave = false;
}
public function __unserialize(array $data): void
{
$this->autoSave = false;
throw new \LogicException(static::class.' should never be unserialized');
}
/**
@ -52,7 +85,8 @@ class FileCookieJar extends CookieJar
*
* @param string $filename File to save
*
* @throws \RuntimeException if the file cannot be found or created
* @throws \RuntimeException if the cookie data cannot be encoded or the
* file cannot be written
*/
public function save(string $filename): void
{
@ -60,42 +94,75 @@ class FileCookieJar extends CookieJar
/** @var SetCookie $cookie */
foreach ($this as $cookie) {
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
$data = $cookie->toArray();
$data['HostOnly'] = $cookie->getHostOnly();
$json[] = $data;
}
}
$jsonStr = Utils::jsonEncode($json);
if (false === \file_put_contents($filename, $jsonStr, \LOCK_EX)) {
throw new \RuntimeException("Unable to save file {$filename}");
try {
$jsonStr = \json_encode($json, \JSON_HEX_TAG | \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException('Unable to encode cookie data', 0, $e);
}
if (false === \file_put_contents($filename, $jsonStr, \LOCK_EX)) {
throw new \RuntimeException(\sprintf('Unable to save file %s', DiagnosticValue::escape($filename)));
}
// Best-effort: restrict the cookie file to the owner so persisted
// cookies are not world-readable.
@\chmod($filename, 0600);
}
/**
* Load cookies from a JSON formatted file.
*
* Old cookies are kept unless overwritten by newly loaded ones.
* Cookie records are constructed before any are passed to setCookie().
*
* @param string $filename Cookie file to load.
*
* @throws \RuntimeException if the file cannot be loaded.
* @throws \RuntimeException if the file cannot be loaded or is invalid
*/
public function load(string $filename): void
{
$json = \file_get_contents($filename);
if (false === $json) {
throw new \RuntimeException("Unable to load file {$filename}");
throw new \RuntimeException(\sprintf('Unable to load file %s', DiagnosticValue::escape($filename)));
}
if ($json === '') {
return;
}
$data = Utils::jsonDecode($json, true);
if (\is_array($data)) {
foreach ($data as $cookie) {
$this->setCookie(new SetCookie($cookie));
$message = \sprintf('Invalid cookie file: %s', DiagnosticValue::escape($filename));
try {
$data = \json_decode($json, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException($message, 0, $e);
}
// Associative decoding turns JSON objects into arrays, so inspect the root syntax too.
if (!\is_array($data) || \substr($json, \strspn($json, " \t\n\r"), 1) !== '[') {
throw new \RuntimeException($message);
}
$cookies = [];
foreach ($data as $cookie) {
if (!\is_array($cookie) || !\array_key_exists('HostOnly', $cookie) || !\is_bool($cookie['HostOnly'])) {
throw new \RuntimeException($message);
}
} elseif (\is_scalar($data) && !empty($data)) {
throw new \RuntimeException("Invalid cookie file: {$filename}");
try {
$cookies[] = new SetCookie($cookie);
} catch (\InvalidArgumentException $e) {
throw new \RuntimeException($message, 0, $e);
}
}
foreach ($cookies as $cookie) {
$this->setCookie($cookie);
}
}
}

View file

@ -1,21 +1,36 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use GuzzleHttp\NonSerializableTrait;
/**
* Persists cookies in the client session
*/
class SessionCookieJar extends CookieJar
{
use NonSerializableTrait;
/**
* @var string session key
*/
private $sessionKey;
private string $sessionKey;
/**
* @var bool Control whether to persist session cookies or not.
*/
private $storeSessionCookies;
private bool $storeSessionCookies;
/**
* @var bool Whether to save the cookie jar on destruction.
*
* Disabled by __wakeup() to prevent SessionCookieJar from being used as a
* PHP object injection $_SESSION-write gadget when an application
* unserializes attacker-controlled data.
*/
private bool $autoSave = false;
/**
* Create a new SessionCookieJar object
@ -24,6 +39,8 @@ class SessionCookieJar extends CookieJar
* data in session
* @param bool $storeSessionCookies Set to true to store session cookies
* in the cookie jar.
*
* @throws \RuntimeException if the session contains invalid cookie data
*/
public function __construct(string $sessionKey, bool $storeSessionCookies = false)
{
@ -31,6 +48,7 @@ class SessionCookieJar extends CookieJar
$this->sessionKey = $sessionKey;
$this->storeSessionCookies = $storeSessionCookies;
$this->load();
$this->autoSave = true;
}
/**
@ -38,11 +56,30 @@ class SessionCookieJar extends CookieJar
*/
public function __destruct()
{
$this->save();
if ($this->autoSave) {
$this->save();
}
}
/**
* Save cookies to the client session
* Disable automatic persistence after unserialization.
*/
public function __wakeup(): void
{
$this->autoSave = false;
}
public function __unserialize(array $data): void
{
$this->autoSave = false;
throw new \LogicException(static::class.' should never be unserialized');
}
/**
* Save cookies to the client session.
*
* @throws \RuntimeException if the cookie data cannot be encoded
*/
public function save(): void
{
@ -50,20 +87,25 @@ class SessionCookieJar extends CookieJar
/** @var SetCookie $cookie */
foreach ($this as $cookie) {
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
$data = $cookie->toArray();
$data['HostOnly'] = $cookie->getHostOnly();
$json[] = $data;
}
}
$json = \json_encode($json);
if (false === $json) {
throw new \RuntimeException('Unable to encode cookie data');
try {
$json = \json_encode($json, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException('Unable to encode cookie data', 0, $e);
}
$_SESSION[$this->sessionKey] = $json;
}
/**
* Load the contents of the client session into the data array
* Load cookies from the client session.
*
* @throws \RuntimeException if the session contains invalid cookie data
*/
protected function load(): void
{
@ -71,22 +113,38 @@ class SessionCookieJar extends CookieJar
return;
}
$message = 'Invalid cookie data';
$json = $_SESSION[$this->sessionKey];
if (!\is_string($json)) {
throw new \RuntimeException('Invalid cookie data');
throw new \RuntimeException($message);
}
$data = \json_decode($json, true);
if (\is_array($data)) {
foreach ($data as $cookie) {
if (!\is_array($cookie)) {
throw new \RuntimeException('Invalid cookie data');
}
try {
$data = \json_decode($json, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException($message, 0, $e);
}
$this->setCookie(new SetCookie($cookie));
// Associative decoding turns JSON objects into arrays, so inspect the root syntax too.
if (!\is_array($data) || \substr($json, \strspn($json, " \t\n\r"), 1) !== '[') {
throw new \RuntimeException($message);
}
$cookies = [];
foreach ($data as $cookie) {
if (!\is_array($cookie) || !\array_key_exists('HostOnly', $cookie) || !\is_bool($cookie['HostOnly'])) {
throw new \RuntimeException($message);
}
} elseif (\is_scalar($data) && \strlen((string) $data)) {
throw new \RuntimeException('Invalid cookie data');
try {
$cookies[] = new SetCookie($cookie);
} catch (\InvalidArgumentException $e) {
throw new \RuntimeException($message, 0, $e);
}
}
foreach ($cookies as $cookie) {
$this->setCookie($cookie);
}
}
}

View file

@ -1,7 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use GuzzleHttp\HostIdentity;
use GuzzleHttp\Psr7;
/**
* Set-Cookie object
*/
@ -10,7 +15,7 @@ class SetCookie
/**
* @var array
*/
private static $defaults = [
private const DEFAULTS = [
'Name' => null,
'Value' => null,
'Domain' => null,
@ -25,19 +30,28 @@ class SetCookie
/**
* @var array Cookie data
*/
private $data;
private array $data;
/**
* @var bool Whether this cookie was set without a Domain attribute.
*/
private bool $hostOnly = false;
/**
* Create a new SetCookie object from a string.
*
* @param string $cookie Set-Cookie header string
*/
public static function fromString(string $cookie): self
{
public static function fromString(
#[\SensitiveParameter]
string $cookie
): self {
// Create the default return array
$data = self::$defaults;
$data = self::DEFAULTS;
// Explode the cookie string using a series of semicolons
$pieces = \array_filter(\array_map('trim', \explode(';', $cookie)));
$pieces = \array_filter(\array_map(static function (string $piece): string {
return \trim($piece, " \t");
}, \explode(';', $cookie)));
// The name of the cookie (first kvp) must exist and include an equal sign.
if (!isset($pieces[0]) || \strpos($pieces[0], '=') === false) {
return new self($data);
@ -46,9 +60,9 @@ class SetCookie
// Add the cookie pieces into the parsed data array
foreach ($pieces as $part) {
$cookieParts = \explode('=', $part, 2);
$key = \trim($cookieParts[0]);
$key = \trim($cookieParts[0], " \t");
$value = isset($cookieParts[1])
? \trim($cookieParts[1], " \n\r\t\0\x0B")
? \trim($cookieParts[1], " \t")
: true;
// Only check for non-cookies when cookies have been found
@ -56,22 +70,28 @@ class SetCookie
$data['Name'] = $key;
$data['Value'] = $value;
} else {
foreach (\array_keys(self::$defaults) as $search) {
if (!\strcasecmp($search, $key)) {
foreach (\array_keys(self::DEFAULTS) as $search) {
if (Psr7\Utils::caselessEquals($search, $key)) {
if ($search === 'Max-Age') {
if (is_numeric($value)) {
$data[$search] = (int) $value;
if (\is_string($value) && \preg_match('/^[+-]?[0-9]+$/D', $value) === 1) {
$maxAge = self::parseNumericInteger($value);
if ($maxAge !== null) {
$data[$search] = $maxAge;
}
}
} elseif ($search === 'Secure' || $search === 'Discard' || $search === 'HttpOnly') {
if ($value) {
$data[$search] = true;
}
} else {
} elseif (\is_string($value)) {
$data[$search] = $value;
}
continue 2;
}
}
if (Psr7\Utils::caselessEquals('HostOnly', $key)) {
continue;
}
$data[$key] = $value;
}
}
@ -82,9 +102,17 @@ class SetCookie
/**
* @param array $data Array of cookie data provided by a Cookie parser
*/
public function __construct(array $data = [])
{
$this->data = self::$defaults;
public function __construct(
#[\SensitiveParameter]
array $data = []
) {
$this->data = self::DEFAULTS;
self::validateFieldTypes($data);
if (\array_key_exists('HostOnly', $data)) {
$this->setHostOnly($data['HostOnly']);
unset($data['HostOnly']);
}
if (isset($data['Name'])) {
$this->setName($data['Name']);
@ -123,23 +151,24 @@ class SetCookie
}
// Set the remaining values that don't have extra validation logic
foreach (array_diff(array_keys($data), array_keys(self::$defaults)) as $key) {
foreach (array_diff(array_keys($data), array_keys(self::DEFAULTS)) as $key) {
$this->data[$key] = $data[$key];
}
// Extract the Expires value and turn it into a UNIX timestamp if needed
if (!$this->getExpires() && $this->getMaxAge()) {
// Calculate the Expires date
$this->setExpires(\time() + $this->getMaxAge());
} elseif (null !== ($expires = $this->getExpires()) && !\is_numeric($expires)) {
$this->setExpires($expires);
$maxAge = $this->getMaxAge();
if ($maxAge !== null) {
$this->setExpires(self::maxAgeToExpires($maxAge, \time()));
}
}
public function __toString()
public function __toString(): string
{
$str = $this->data['Name'].'='.($this->data['Value'] ?? '').'; ';
foreach ($this->data as $k => $v) {
if ($k === 'Domain' && $this->getHostOnly()) {
continue;
}
if ($k !== 'Name' && $k !== 'Value' && $v !== null && $v !== false) {
if ($k === 'Expires') {
$str .= 'Expires='.\gmdate('D, d M Y H:i:s \G\M\T', $v).'; ';
@ -154,15 +183,18 @@ class SetCookie
public function toArray(): array
{
return $this->data;
$data = $this->data;
if ($this->getHostOnly()) {
$data['HostOnly'] = true;
}
return $data;
}
/**
* Get the cookie name.
*
* @return string
*/
public function getName()
public function getName(): ?string
{
return $this->data['Name'];
}
@ -172,21 +204,15 @@ class SetCookie
*
* @param string $name Cookie name
*/
public function setName($name): void
public function setName(string $name): void
{
if (!is_string($name)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Name'] = (string) $name;
$this->data['Name'] = $name;
}
/**
* Get the cookie value.
*
* @return string|null
*/
public function getValue()
public function getValue(): ?string
{
return $this->data['Value'];
}
@ -196,21 +222,15 @@ class SetCookie
*
* @param string $value Cookie value
*/
public function setValue($value): void
public function setValue(string $value): void
{
if (!is_string($value)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Value'] = (string) $value;
$this->data['Value'] = $value;
}
/**
* Get the domain.
*
* @return string|null
*/
public function getDomain()
public function getDomain(): ?string
{
return $this->data['Domain'];
}
@ -218,23 +238,35 @@ class SetCookie
/**
* Set the domain of the cookie.
*
* @param string|null $domain
* @param string|null $domain Domain of the cookie
*/
public function setDomain($domain): void
public function setDomain(?string $domain): void
{
if (!is_string($domain) && null !== $domain) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Domain'] = null === $domain ? null : self::normalizeDomain($domain);
}
$this->data['Domain'] = null === $domain ? null : (string) $domain;
/**
* Get whether this cookie is scoped to the origin host only.
*/
public function getHostOnly(): bool
{
return $this->hostOnly;
}
/**
* Set whether this cookie is scoped to the origin host only.
*
* @param bool $hostOnly Set to true for host-only cookies
*/
public function setHostOnly(bool $hostOnly): void
{
$this->hostOnly = $hostOnly;
}
/**
* Get the path.
*
* @return string
*/
public function getPath()
public function getPath(): string
{
return $this->data['Path'];
}
@ -244,21 +276,15 @@ class SetCookie
*
* @param string $path Path of the cookie
*/
public function setPath($path): void
public function setPath(string $path): void
{
if (!is_string($path)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Path'] = (string) $path;
$this->data['Path'] = $path;
}
/**
* Maximum lifetime of the cookie in seconds.
*
* @return int|null
*/
public function getMaxAge()
public function getMaxAge(): ?int
{
return null === $this->data['Max-Age'] ? null : (int) $this->data['Max-Age'];
}
@ -268,21 +294,15 @@ class SetCookie
*
* @param int|null $maxAge Max age of the cookie in seconds
*/
public function setMaxAge($maxAge): void
public function setMaxAge(?int $maxAge): void
{
if (!is_int($maxAge) && null !== $maxAge) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Max-Age'] = $maxAge === null ? null : (int) $maxAge;
$this->data['Max-Age'] = $maxAge;
}
/**
* The UNIX timestamp when the cookie Expires.
*
* @return string|int|null
*/
public function getExpires()
public function getExpires(): ?int
{
return $this->data['Expires'];
}
@ -295,26 +315,36 @@ class SetCookie
public function setExpires($timestamp): void
{
if (!is_int($timestamp) && !is_string($timestamp) && null !== $timestamp) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int, string or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
// TODO: Move this to the parameter definition in 9.0.
throw new \TypeError(__METHOD__.'(): Argument #1 ($timestamp) must be of type int|string|null');
}
if (null === $timestamp) {
if ($timestamp === null) {
$this->data['Expires'] = null;
} elseif (\is_numeric($timestamp)) {
$this->data['Expires'] = (int) $timestamp;
} else {
// Store unparseable dates as session cookies, not as expired cookies.
$expires = \strtotime((string) $timestamp);
$this->data['Expires'] = $expires === false ? null : $expires;
return;
}
if (\is_string($timestamp)) {
if (\is_numeric($timestamp)) {
$this->data['Expires'] = self::parseNumericInteger($timestamp);
return;
}
} elseif (\is_int($timestamp)) {
$this->data['Expires'] = $timestamp;
return;
}
$expires = \strtotime($timestamp);
$this->data['Expires'] = $expires === false ? null : $expires;
}
/**
* Get whether or not this is a secure cookie.
*
* @return bool
*/
public function getSecure()
public function getSecure(): bool
{
return $this->data['Secure'];
}
@ -324,21 +354,15 @@ class SetCookie
*
* @param bool $secure Set to true or false if secure
*/
public function setSecure($secure): void
public function setSecure(bool $secure): void
{
if (!is_bool($secure)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Secure'] = (bool) $secure;
$this->data['Secure'] = $secure;
}
/**
* Get whether or not this is a session cookie.
*
* @return bool|null
*/
public function getDiscard()
public function getDiscard(): bool
{
return $this->data['Discard'];
}
@ -348,21 +372,15 @@ class SetCookie
*
* @param bool $discard Set to true or false if this is a session cookie
*/
public function setDiscard($discard): void
public function setDiscard(bool $discard): void
{
if (!is_bool($discard)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Discard'] = (bool) $discard;
$this->data['Discard'] = $discard;
}
/**
* Get whether or not this is an HTTP only cookie.
*
* @return bool
*/
public function getHttpOnly()
public function getHttpOnly(): bool
{
return $this->data['HttpOnly'];
}
@ -372,13 +390,9 @@ class SetCookie
*
* @param bool $httpOnly Set to true or false if this is HTTP only
*/
public function setHttpOnly($httpOnly): void
public function setHttpOnly(bool $httpOnly): void
{
if (!is_bool($httpOnly)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['HttpOnly'] = (bool) $httpOnly;
$this->data['HttpOnly'] = $httpOnly;
}
/**
@ -401,7 +415,7 @@ class SetCookie
$cookiePath = $this->getPath();
// Match on exact matches or when path is the default empty "/"
if ($cookiePath === '/' || $cookiePath == $requestPath) {
if ($cookiePath === '/' || $cookiePath === $requestPath) {
return true;
}
@ -427,28 +441,15 @@ class SetCookie
public function matchesDomain(string $domain): bool
{
$cookieDomain = $this->getDomain();
if (null === $cookieDomain) {
return true;
}
// Remove the leading '.' as per spec in RFC 6265.
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3
$cookieDomain = \ltrim(\strtolower($cookieDomain), '.');
$domain = \strtolower($domain);
// Domain not set or exact match.
if ('' === $cookieDomain || $domain === $cookieDomain) {
return true;
}
// Matching the subdomain according to RFC 6265.
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3
if (\filter_var($domain, \FILTER_VALIDATE_IP)) {
if (null === $cookieDomain || $cookieDomain === '') {
return false;
}
return (bool) \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/', $domain);
if ($this->getHostOnly()) {
return HostIdentity::canonicalCookieDomain($domain) === HostIdentity::canonicalCookieDomain($cookieDomain);
}
return HostIdentity::cookieDomainMatches($domain, $cookieDomain);
}
/**
@ -462,20 +463,17 @@ class SetCookie
/**
* Check if the cookie is valid according to RFC 6265.
*
* @return bool|string Returns true if valid or an error message if invalid
* @return string|true Returns true if valid or an error message if invalid
*/
public function validate()
{
$name = $this->getName();
if ($name === '') {
if ($name === null || $name === '') {
return 'The cookie name must not be empty';
}
// Check if any of the invalid characters are present in the cookie name
if (\preg_match(
'/[\x00-\x20\x22\x28-\x29\x2c\x2f\x3a-\x40\x5c\x7b\x7d\x7f]/',
$name
)) {
if (\preg_match('/[\x00-\x20\x22\x28-\x29\x2c\x2f\x3a-\x40\x5c\x7b\x7d\x7f]/', $name) !== 0) {
return 'Cookie name must not contain invalid characters: ASCII '
.'Control characters (0-31;127), space, tab and the '
.'following characters: ()<>@,;:\"/?={}';
@ -491,10 +489,108 @@ class SetCookie
// Domains must not be empty, but may be omitted. "0" is not a valid
// internet domain, but may be used as server name in a private network.
$domain = $this->getDomain();
if ($domain === '') {
if ($domain === '' || ($domain !== null && \ltrim(\trim($domain, " \n\r\t\0\x0B"), '.') === '')) {
return 'The cookie domain must not be empty';
}
if ($this->getHostOnly() && $domain === null) {
return 'Host-only cookies must have a domain';
}
return true;
}
private static function normalizeDomain(string $domain): string
{
$domain = Psr7\Utils::asciiToLower($domain);
// Treat trailing-dot domains as host-only, but keep pure-dot domains invalid.
if ($domain !== '' && \substr($domain, -1) === '.' && \trim($domain, '.') !== '') {
return '';
}
if ($domain !== '' && $domain !== '.' && $domain[0] === '.') {
$domain = \substr_replace($domain, '', 0, 1);
}
return HostIdentity::canonicalCookieDomain($domain);
}
private static function maxAgeToExpires(int $maxAge, int $now): int
{
if ($maxAge <= 0) {
return $now - 1;
}
// Clamp absurd Max-Age values so addition cannot promote to float
if ($maxAge > \PHP_INT_MAX - $now) {
return \PHP_INT_MAX;
}
return $now + $maxAge;
}
private static function parseNumericInteger(string $value): ?int
{
if (!\is_numeric($value)) {
return null;
}
if (\preg_match('/^[+-]?[0-9]+$/D', $value) === 1) {
$negative = $value[0] === '-';
$digits = \ltrim($value, '+-');
$digits = \ltrim($digits, '0');
$digits = $digits === '' ? '0' : $digits;
$limit = $negative ? \substr((string) \PHP_INT_MIN, 1) : (string) \PHP_INT_MAX;
if (\strlen($digits) > \strlen($limit) || (\strlen($digits) === \strlen($limit) && \strcmp($digits, $limit) > 0)) {
return null;
}
return (int) ($negative ? '-'.$digits : $digits);
}
$number = (float) $value;
if (!\is_finite($number) || $number < \PHP_INT_MIN || $number > \PHP_INT_MAX) {
return null;
}
if (\PHP_INT_SIZE === 8 && ($number <= (float) \PHP_INT_MIN || $number >= (float) \PHP_INT_MAX)) {
return null;
}
return (int) $number;
}
/**
* @param mixed[] $data
*/
private static function validateFieldTypes(
#[\SensitiveParameter]
array $data
): void {
foreach (['Name', 'Value', 'Domain', 'Path'] as $field) {
if (isset($data[$field]) && !\is_string($data[$field])) {
throw new \InvalidArgumentException(\sprintf('Cookie field "%s" must be a string', $field));
}
}
if (isset($data['Max-Age']) && !\is_int($data['Max-Age'])) {
throw new \InvalidArgumentException('Cookie field "Max-Age" must be an integer');
}
if (isset($data['Expires']) && !\is_int($data['Expires']) && !\is_string($data['Expires'])) {
throw new \InvalidArgumentException('Cookie field "Expires" must be an integer or string');
}
foreach (['Secure', 'Discard', 'HttpOnly'] as $field) {
if (isset($data[$field]) && !\is_bool($data[$field])) {
throw new \InvalidArgumentException(\sprintf('Cookie field "%s" must be a boolean', $field));
}
}
if (\array_key_exists('HostOnly', $data) && !\is_bool($data['HostOnly'])) {
throw new \InvalidArgumentException('Cookie field "HostOnly" must be a boolean');
}
}
}

33
vendor/guzzlehttp/guzzle/src/Env.php vendored Normal file
View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
/**
* Reads configuration from the process environment.
*
* Intentionally separate from Handler\ProxyEnv, which has different
* lookup semantics.
*
* @internal
*/
final class Env
{
private function __construct()
{
}
public static function get(string $name): ?string
{
if (isset($_SERVER[$name])) {
return (string) $_SERVER[$name];
}
if (\PHP_SAPI === 'cli' && ($value = \getenv($name)) !== false && $value !== null) {
return (string) $value;
}
return null;
}
}

View file

@ -1,39 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Exception when an HTTP error occurs (4xx or 5xx error)
* Exception thrown for HTTP responses with 4xx or 5xx status codes.
*/
class BadResponseException extends RequestException
class BadResponseException extends ResponseException
{
public function __construct(
string $message,
RequestInterface $request,
ResponseInterface $response,
?\Throwable $previous = null,
array $handlerContext = []
) {
parent::__construct($message, $request, $response, $previous, $handlerContext);
}
/**
* Current exception and the ones that extend it will always have a response.
*/
public function hasResponse(): bool
{
return true;
}
/**
* This function narrows the return type from the parent class and does not allow it to be nullable.
*/
public function getResponse(): ResponseInterface
{
/** @var ResponseInterface */
return parent::getResponse();
}
}

View file

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception when a client error is encountered (4xx codes)
* Exception thrown for HTTP responses with 4xx status codes.
*/
class ClientException extends BadResponseException
{

View file

@ -1,54 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
/**
* Exception thrown when a connection cannot be established.
* Exception thrown when connection establishment fails.
*/
class ConnectException extends TransferException implements NetworkExceptionInterface
class ConnectException extends NetworkException
{
/**
* @var RequestInterface
*/
private $request;
/**
* @var array
*/
private $handlerContext;
public function __construct(
string $message,
RequestInterface $request,
?\Throwable $previous = null,
array $handlerContext = []
) {
parent::__construct($message, 0, $previous);
$this->request = $request;
$this->handlerContext = $handlerContext;
}
/**
* Get the request that caused the exception
*/
public function getRequest(): RequestInterface
{
return $this->request;
}
/**
* Get contextual information about the error from the underlying handler.
*
* The contents of this array will vary depending on which handler you are
* using. It may also be just an empty array. Relying on this data will
* couple you to a specific handler, but can give more debug information
* when needed.
*/
public function getHandlerContext(): array
{
return $this->handlerContext;
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when connection establishment exceeds the time limit.
*/
class ConnectTimeoutException extends ConnectException
{
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Client\ClientExceptionInterface;

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when a handler is closed before a transfer completes.
*/
class HandlerClosedException extends TransferException
{
}

View file

@ -1,7 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when an invalid argument is supplied to Guzzle.
*/
final class InvalidArgumentException extends \InvalidArgumentException implements GuzzleException
{
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
/**
* Base exception for transfer failures without a response.
*/
class NetworkException extends TransferException implements NetworkExceptionInterface
{
public function __construct(
string $message,
RequestInterface $request,
?\Throwable $previous = null
) {
parent::__construct($message, $request, 0, $previous);
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when a transfer times out before response headers are
* received.
*/
class NetworkTimeoutException extends NetworkException
{
}

View file

@ -1,154 +1,91 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use GuzzleHttp\BodySummarizer;
use GuzzleHttp\BodySummarizerInterface;
use GuzzleHttp\Psr7\DiagnosticValue;
use Psr\Http\Client\RequestExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* HTTP Request exception
* Base exception for request failures associated with a request.
*/
class RequestException extends TransferException implements RequestExceptionInterface
{
/**
* @var RequestInterface
*/
private $request;
/**
* @var ResponseInterface|null
*/
private $response;
/**
* @var array
*/
private $handlerContext;
public function __construct(
string $message,
RequestInterface $request,
?ResponseInterface $response = null,
?\Throwable $previous = null,
array $handlerContext = []
int $code = 0,
?\Throwable $previous = null
) {
// Set the code of the exception if the response is set and not future.
$code = $response ? $response->getStatusCode() : 0;
parent::__construct($message, $code, $previous);
$this->request = $request;
$this->response = $response;
$this->handlerContext = $handlerContext;
}
/**
* Wrap non-RequestExceptions with a RequestException
*
* @deprecated since 7.11. Create a RequestException directly instead.
*/
public static function wrapException(RequestInterface $request, \Throwable $e): RequestException
{
\trigger_deprecation('guzzlehttp/guzzle', '7.11', '%s::wrapException() is deprecated and will be removed in 8.0. Create a %s directly instead.', self::class, self::class);
return $e instanceof RequestException ? $e : new RequestException($e->getMessage(), $request, null, $e);
parent::__construct($message, $request, $code, $previous);
}
/**
* Factory method to create a new exception with a normalized error message
*
* @param RequestInterface $request Request sent
* @param ResponseInterface $response Response received
* @param ResponseInterface|null $response Response received, if any
* @param \Throwable|null $previous Previous exception
* @param array $handlerContext Optional handler context
* @param BodySummarizerInterface|null $bodySummarizer Optional body summarizer
*/
public static function create(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
?ResponseInterface $response = null,
#[\SensitiveParameter]
?\Throwable $previous = null,
array $handlerContext = [],
?BodySummarizerInterface $bodySummarizer = null
): self {
if (!$response) {
return new self(
'Error completing request',
$request,
null,
$previous,
$handlerContext
0,
$previous
);
}
$level = (int) \floor($response->getStatusCode() / 100);
if ($level === 4) {
$label = 'Client error';
$className = ClientException::class;
} elseif ($level === 5) {
$label = 'Server error';
$className = ServerException::class;
} else {
$label = 'Unsuccessful request';
$className = __CLASS__;
}
$uri = \GuzzleHttp\Psr7\Utils::redactUserInfo($request->getUri());
// Client Error: `GET /` resulted in a `404 Not Found` response:
// <html> ... (truncated)
// Client error: `GET /` resulted in a `404 Not Found` response: <html> ... (truncated)
$message = \sprintf(
'%s: `%s %s` resulted in a `%s %s` response',
$label,
$request->getMethod(),
$uri->__toString(),
DiagnosticValue::escape($request->getMethod()),
DiagnosticValue::escape($uri->__toString()),
$response->getStatusCode(),
$response->getReasonPhrase()
DiagnosticValue::escape($response->getReasonPhrase())
);
$summary = ($bodySummarizer ?? new BodySummarizer())->summarize($response);
if ($summary !== null) {
$message .= ":\n{$summary}\n";
$message .= \sprintf(': %s', DiagnosticValue::escape($summary));
}
return new $className($message, $request, $response, $previous, $handlerContext);
}
if ($level === 4) {
return new ClientException($message, $request, $response, $previous);
}
/**
* Get the request that caused the exception
*/
public function getRequest(): RequestInterface
{
return $this->request;
}
if ($level === 5) {
return new ServerException($message, $request, $response, $previous);
}
/**
* Get the associated response
*/
public function getResponse(): ?ResponseInterface
{
return $this->response;
}
/**
* Check if a response was received
*/
public function hasResponse(): bool
{
return $this->response !== null;
}
/**
* Get contextual information about the error from the underlying handler.
*
* The contents of this array will vary depending on which handler you are
* using. It may also be just an empty array. Relying on this data will
* couple you to a specific handler, but can give more debug information
* when needed.
*/
public function getHandlerContext(): array
{
return $this->handlerContext;
return new ResponseException($message, $request, $response, $previous);
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Exception thrown when a request fails after response headers are received.
*/
class ResponseException extends RequestException
{
private ResponseInterface $response;
final public function __construct(
string $message,
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
?\Throwable $previous = null
) {
parent::__construct($message, $request, $response->getStatusCode(), $previous);
$this->response = $response;
}
/**
* Return a new exception instance with an updated response.
*
* @param \Throwable|null $previous Previous exception, or the current
* previous exception when omitted.
*
* @return static
*/
public function withResponse(
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
?\Throwable $previous = null
): self {
if ($response->getStatusCode() !== $this->response->getStatusCode()) {
throw new InvalidArgumentException('Cannot replace response with a different status code.');
}
return new static(
$this->getMessage(),
$this->getRequest(),
$response,
$previous ?? $this->getPrevious()
);
}
/**
* Get the associated response.
*/
public function getResponse(): ResponseInterface
{
return $this->response;
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when a transfer times out after response headers are
* received.
*/
class ResponseTimeoutException extends ResponseTransferException
{
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when a response transfer fails after response headers are
* received.
*/
class ResponseTransferException extends ResponseException
{
}

View file

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception when a server error is encountered (5xx codes)
* Exception thrown for HTTP responses with 5xx status codes.
*/
class ServerException extends BadResponseException
{

View file

@ -1,7 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
class TooManyRedirectsException extends RequestException
/**
* Exception thrown when redirect middleware reaches the redirect limit.
*/
class TooManyRedirectsException extends ResponseException
{
}

View file

@ -1,7 +1,33 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Message\RequestInterface;
/**
* Base exception for transfer failures.
*/
class TransferException extends \RuntimeException implements GuzzleException
{
private RequestInterface $request;
public function __construct(
string $message,
RequestInterface $request,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
$this->request = $request;
}
/**
* Get the request that caused the exception.
*/
public function getRequest(): RequestInterface
{
return $this->request;
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
/**
* @internal
*/
final class Clock
{
private function __construct()
{
}
/**
* Returns the current monotonic clock reading in seconds.
*/
public static function now(): float
{
return \hrtime(true) / 1e9;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use Psr\Http\Message\RequestInterface;
@ -9,6 +11,10 @@ interface CurlFactoryInterface
/**
* Creates a cURL handle resource.
*
* Implementations must return an EasyHandle whose public state properties,
* including the request and sink, contain values compatible with Guzzle's
* cURL handlers.
*
* @param RequestInterface $request Request
* @param array $options Transfer options
*

View file

@ -1,10 +1,16 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\DiagnosticValue;
use GuzzleHttp\TransportSharing;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* HTTP handler that uses cURL easy handles as a transport layer.
@ -12,20 +18,23 @@ use Psr\Http\Message\RequestInterface;
* When using the CurlHandler, custom curl options can be specified as an
* associative array of curl option constants mapping to values in the
* **curl** key of the "client" key of the request.
*
* @final
*/
class CurlHandler
final class CurlHandler
{
/**
* @var CurlFactoryInterface
*/
private $factory;
use NonSerializableTrait;
/**
* @var CurlShareHandleState|null
*/
private $shareHandleState;
private const KNOWN_CONSTRUCTOR_OPTIONS = [
'handle_factory' => true,
'transport_sharing' => true,
];
private CurlFactoryInterface $factory;
private bool $ownsFactory;
private bool $closed = false;
private ?CurlShareHandleState $shareHandleState;
/**
* Accepts an associative array of options:
@ -37,6 +46,12 @@ class CurlHandler
*/
public function __construct(array $options = [])
{
foreach ($options as $name => $_) {
if (!isset(self::KNOWN_CONSTRUCTOR_OPTIONS[$name])) {
throw new InvalidArgumentException(\sprintf('Invalid CurlHandler constructor option "%s".', DiagnosticValue::escape((string) $name)));
}
}
CurlShareHandleState::assertNoRequiredSharingCustomFactoryConflict($options, 'CurlHandler');
$transportSharing = $options['transport_sharing'] ?? null;
$sharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing');
@ -44,6 +59,7 @@ class CurlHandler
if (\array_key_exists('handle_factory', $options) && $options['handle_factory'] !== null) {
$this->shareHandleState = null;
$this->factory = $options['handle_factory'];
$this->ownsFactory = false;
return;
}
@ -53,20 +69,94 @@ class CurlHandler
: null;
$this->factory = $this->shareHandleState !== null
? new CurlFactory(3, $this->shareHandleState->mode, $this->shareHandleState->handle)
? new CurlFactory(3, $this->shareHandleState->mode, $this->shareHandleState)
: new CurlFactory(3);
$this->ownsFactory = true;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
$this->assertOpen();
HostValidator::assertRequestHost($request);
if (isset($options['delay'])) {
\usleep($options['delay'] * 1000);
\usleep((int) ($options['delay'] * 1000));
}
// A Multiplexing::NONE request option holds unconditionally here:
// the transfer runs alone during the blocking curl_exec(), and even
// under persistent transport sharing an in-use connection cannot be
// joined from another multi handle, so it never shares its
// connection with a concurrent transfer.
$easy = $this->factory->create($request, $options);
\curl_exec($easy->handle);
$easy->errno = \curl_errno($easy->handle);
return CurlFactory::finish($this, $easy, $this->factory);
}
/**
* Closes native cURL resources owned by this handler.
*
* After closing, the handler is terminal and must not be reused.
*/
public function close(): void
{
$this->doClose(true);
}
public function __destruct()
{
try {
$this->doClose(false);
} catch (\Throwable $e) {
// Destructors must not throw.
}
}
public function __unserialize(array $data): void
{
$this->closed = true;
throw new \LogicException(static::class.' should never be unserialized');
}
private function assertOpen(): void
{
if ($this->closed) {
// Programmer misuse (reusing a closed handler), not a transfer failure;
// intentionally a LogicException outside the GuzzleException hierarchy.
throw new \BadMethodCallException('Cannot use the cURL handler after it has been closed.');
}
}
private function doClose(bool $explicit): void
{
if ($this->closed) {
return;
}
$this->closed = true;
try {
if ($this->ownsFactory && $this->factory instanceof CurlFactory) {
$this->factory->close();
}
} catch (\Throwable $e) {
if ($explicit) {
throw $e;
}
} finally {
$this->shareHandleState = null;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,27 +1,29 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\TransportSharing;
use GuzzleHttp\Utils;
/**
* @internal
*/
final class CurlShareHandleState
{
use NonSerializableTrait;
/**
* @var resource|\CurlShareHandle|null
* @var resource|\CurlShareHandle|\CurlSharePersistentHandle|null
*/
public $handle;
/**
* @var string
*/
public $mode;
public string $mode;
/**
* @param resource|\CurlShareHandle|null $handle
* @param resource|\CurlShareHandle|\CurlSharePersistentHandle|null $handle
*/
private function __construct(string $mode, $handle)
{
@ -47,7 +49,15 @@ final class CurlShareHandleState
return self::createHandlerShareOrNull($mode);
}
return self::createHandlerShare($mode);
if ($mode === TransportSharing::HANDLER_REQUIRE) {
return self::createHandlerShare($mode);
}
if ($mode === TransportSharing::PERSISTENT_PREFER) {
return self::createPersistentShareOrFallback();
}
return self::createPersistentShare($mode);
}
/**
@ -63,14 +73,19 @@ final class CurlShareHandleState
return TransportSharing::NONE;
}
if ($sharing === TransportSharing::HANDLER_PREFER || $sharing === TransportSharing::HANDLER_REQUIRE) {
if (
$sharing === TransportSharing::HANDLER_PREFER
|| $sharing === TransportSharing::HANDLER_REQUIRE
|| $sharing === TransportSharing::PERSISTENT_PREFER
|| $sharing === TransportSharing::PERSISTENT_REQUIRE
) {
return $sharing;
}
throw new \InvalidArgumentException(\sprintf(
throw new InvalidArgumentException(\sprintf(
'The "%s" option must be null or a GuzzleHttp\\TransportSharing::* constant; received %s.',
$option,
Utils::describeType($sharing)
\get_debug_type($sharing)
));
}
@ -81,11 +96,11 @@ final class CurlShareHandleState
}
$mode = self::normalizeMode($options['transport_sharing'] ?? null, 'transport_sharing');
if ($mode !== TransportSharing::HANDLER_REQUIRE) {
if (!\in_array($mode, [TransportSharing::HANDLER_REQUIRE, TransportSharing::PERSISTENT_REQUIRE], true)) {
return;
}
throw new \InvalidArgumentException(\sprintf(
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" %s option cannot require sharing with a custom "handle_factory" because Guzzle cannot ensure that the custom factory applies CURLOPT_SHARE.',
$handlerName
));
@ -103,12 +118,12 @@ final class CurlShareHandleState
private static function createHandlerShare(string $mode): self
{
if (!\function_exists('curl_share_init') || !\function_exists('curl_share_setopt')) {
throw new \InvalidArgumentException('The "transport_sharing" option requires cURL share support.');
throw new InvalidArgumentException('The "transport_sharing" option requires cURL share support.');
}
self::requireCurlConstant('CURLOPT_SHARE');
$shareOption = self::requireCurlConstant('CURLSHOPT_SHARE');
$locks = self::handlerLocks();
$locks = self::handlerLocks($mode);
$handle = curl_share_init();
try {
@ -116,11 +131,11 @@ final class CurlShareHandleState
try {
$success = curl_share_setopt($handle, $shareOption, $lock);
} catch (\Throwable $e) {
throw new \InvalidArgumentException('Unable to configure cURL share handle: '.$e->getMessage(), 0, $e);
throw new InvalidArgumentException('Unable to configure cURL share handle: '.$e->getMessage(), 0, $e);
}
if (!$success) {
throw new \InvalidArgumentException(\sprintf('Unable to configure cURL share handle with lock data %d.', $lock));
throw new InvalidArgumentException(\sprintf('Unable to configure cURL share handle with lock data %d.', $lock));
}
}
} catch (\Throwable $e) {
@ -132,13 +147,84 @@ final class CurlShareHandleState
return new self($mode, $handle);
}
private static function createPersistentShareOrFallback(): ?self
{
if (self::supportsPersistentShare()) {
try {
return self::createPersistentShare(TransportSharing::PERSISTENT_PREFER);
} catch (\Throwable $e) {
// Fall back to handler-lifetime best effort below.
}
}
return self::createHandlerShareOrNull(TransportSharing::HANDLER_PREFER);
}
private static function createPersistentShare(string $mode): self
{
CurlVersion::ensureConnectionSharingSupported();
CurlVersion::ensureSslSessionSharingSupported();
if (!self::supportsPersistentShare()) {
throw new InvalidArgumentException('The "transport_sharing" option requires persistent cURL share handle support.');
}
self::requireCurlConstant('CURLOPT_SHARE');
try {
$handle = curl_share_init_persistent(self::persistentLocks());
} catch (\Throwable $e) {
throw new InvalidArgumentException(
'Unable to create persistent cURL share handle: '.$e->getMessage(),
0,
$e
);
}
return new self($mode, $handle);
}
private static function supportsPersistentShare(): bool
{
return CurlVersion::supportsConnectionSharing()
&& CurlVersion::supportsSslSessionSharing()
&& \function_exists('curl_share_init_persistent')
&& \class_exists('CurlSharePersistentHandle')
&& \defined('CURL_LOCK_DATA_DNS')
&& \defined('CURL_LOCK_DATA_CONNECT')
&& \defined('CURL_LOCK_DATA_SSL_SESSION');
}
/**
* @return int[]
*/
private static function handlerLocks(): array
private static function handlerLocks(string $mode): array
{
CurlVersion::ensureHandlerSharingSupported();
if ($mode === TransportSharing::HANDLER_REQUIRE) {
CurlVersion::ensureSslSessionSharingSupported();
}
$locks = [
self::requireCurlConstant('CURL_LOCK_DATA_DNS'),
];
if (CurlVersion::supportsSslSessionSharing()) {
$locks[] = self::requireCurlConstant('CURL_LOCK_DATA_SSL_SESSION');
}
return $locks;
}
/**
* @return int[]
*/
private static function persistentLocks(): array
{
return [
self::requireCurlConstant('CURL_LOCK_DATA_DNS'),
self::requireCurlConstant('CURL_LOCK_DATA_CONNECT'),
self::requireCurlConstant('CURL_LOCK_DATA_SSL_SESSION'),
];
}
@ -146,7 +232,7 @@ final class CurlShareHandleState
private static function requireCurlConstant(string $constant): int
{
if (!\defined($constant)) {
throw new \InvalidArgumentException(\sprintf(
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires %s, but it is not available in the installed PHP cURL extension.',
$constant
));
@ -154,7 +240,7 @@ final class CurlShareHandleState
$value = \constant($constant);
if (!\is_int($value)) {
throw new \InvalidArgumentException(\sprintf('The cURL constant %s must resolve to an integer.', $constant));
throw new InvalidArgumentException(\sprintf('The cURL constant %s must resolve to an integer.', $constant));
}
return $value;

View file

@ -0,0 +1,392 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\InvalidArgumentException;
use Psr\Http\Message\RequestInterface;
/**
* @internal
*/
final class CurlVersion
{
private const MIN_VERSION = '7.34.0';
private const TLS_13_VERSION = '7.52.0';
// CURLOPT_PIPEWAIT exists since libcurl 7.43.0, and multi handles have
// multiplexed by default since 7.62.0 - but a 7.65.0-7.65.1 regression
// dropped that default, which 7.65.2 restored, so 7.65.2 is the floor at
// which PIPEWAIT is reliably effective.
private const MULTIPLEX_VERSION = '7.65.2';
// libcurl's connection matcher refuses to hand a transfer wanting
// HTTP/1.x a pooled connection that already negotiated HTTP/2 or newer
// from 7.77.0: ConnectionExists() in lib/url.c gained the check between
// the 7.76.0 and 7.77.0 releases. The HTTP/2 branch of the check
// regressed to a debug log in 8.11.0 (curl commit 433d730) and was
// restored in 8.13.0 via the negotiation mask (curl commit db72b8d), so
// 8.11.0 through 8.12.1 are vulnerable again.
private const HTTP_VERSION_REUSE_MATCH_VERSION = '7.77.0';
private const HTTP_VERSION_REUSE_MATCH_REGRESSION = '8.11.0';
private const HTTP_VERSION_REUSE_MATCH_RESTORED = '8.13.0';
// CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE restricts the ALPN offer to h2 only
// since libcurl 8.10.0, and connection reuse matching stopped handing
// lower-version connections to prior-knowledge transfers in 8.14.0; below
// that, a required HTTP/2 request could silently be sent over a reused
// HTTP/1.1 connection.
private const REQUIRED_HTTP2_MULTIPLEX_VERSION = '8.14.0';
// Version-aware connection reuse matching arrived in libcurl 8.13.0 with
// an HTTP/3-only mask for CURL_HTTP_VERSION_3ONLY transfers; below that,
// a required HTTP/3 request could silently ride a reused HTTP/2
// connection.
private const REQUIRED_HTTP3_MULTIPLEX_VERSION = '8.13.0';
// curl 7.52.0 introduced HTTPS proxy support, advertised by a feature bit
// (a build can meet the version yet lack the feature). Earlier libcurl
// mishandles an https:// proxy: before 7.50.2 it silently downgrades to a
// plaintext HTTP proxy, and 7.50.2 through 7.51 reject it at connect time.
// The 7.52.0 TLS rework also shipped verification flaws in exactly this
// path (CVE-2017-2629, CVE-2017-7468); the latter affects 7.52.0-7.53.1
// and was fixed in 7.54.0, so TLS to a proxy is only trusted from there.
private const HTTPS_PROXY_VERSION = '7.54.0';
// HTTP/3 arrived in libcurl 7.66.0, but CURL_HTTP_VERSION_3ONLY only
// exists from 7.88.0; requiring it keeps every HTTP/3-capable runtime
// able to pin HTTP/3 with no downgrade.
private const HTTP_3_VERSION = '7.88.0';
private const PROTOCOLS_STR_VERSION = '7.85.0';
private const HANDLER_SHARING_VERSION = '7.35.0';
private const SSL_SESSION_SHARING_VERSION = '8.6.0';
private const CONNECTION_SHARING_VERSION = '8.12.0';
// curl 7.57.0 added share-handle connection caches through
// CURL_LOCK_DATA_CONNECT; older share objects can only hold DNS, TLS
// session, and cookie data, never connections. This is libcurl's raw
// capability floor for the opaque share safeguards; Guzzle-managed
// connection sharing is separately gated by CONNECTION_SHARING_VERSION.
private const SHARE_CONNECTION_CACHE_VERSION = '7.57.0';
// curl 7.83.1 added proxy TLS-SRP to the connection-reuse match
// (CVE-2022-27782); the proxy client certificate was matched from 7.52.0,
// so proxy TLS credentials are trusted from 7.83.1 onwards.
private const PROXY_TLS_CREDENTIAL_REUSE_VERSION = '7.83.1';
// curl 8.19.0 fixed proxy tunnel reuse after credential changes
// (CVE-2026-3784), but related proxy credential leak flaws were only
// fixed in 8.20.0, so connection reuse is trusted from 8.20.0 onwards.
private const PROXY_CREDENTIAL_REUSE_VERSION = '8.20.0';
// curl 7.69.0 started comparing SOCKS proxy credentials when matching
// connections for reuse (curl #4835); older libcurl matches a SOCKS proxy
// by type, host, and port only.
private const SOCKS_PROXY_CREDENTIAL_REUSE_VERSION = '7.69.0';
private const PROXY_HEADER_SEPARATION_VERSION = '7.37.0';
// CURLOPT_SUPPRESS_CONNECT_HEADERS arrived in curl 7.54.0; proxy CONNECT
// tunneling is gated on it so the proxy's interim reply can never surface
// as a phantom response on any build that can tunnel.
private const PROXY_TUNNEL_VERSION = '7.54.0';
/**
* @var array{version: string, features: int}|false|null
*/
private static $versionInfo;
private function __construct()
{
}
public static function supportsCurlHandler(): bool
{
$versionInfo = self::getVersionInfo();
return \defined('CURL_VERSION_SSL')
&& \defined('CURL_SSLVERSION_TLSv1_2')
&& \defined('CURLMOPT_MAX_HOST_CONNECTIONS')
&& \defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')
&& null !== $versionInfo
&& version_compare($versionInfo['version'], self::MIN_VERSION, '>=')
&& 0 !== (\CURL_VERSION_SSL & $versionInfo['features']);
}
public static function supportsTls13(): bool
{
$version = self::get();
return \defined('CURL_SSLVERSION_TLSv1_3')
&& null !== $version
&& version_compare($version, self::TLS_13_VERSION, '>=');
}
public static function supportsMultiplex(): bool
{
$version = self::get();
return \defined('CURLOPT_PIPEWAIT')
&& null !== $version
&& version_compare($version, self::MULTIPLEX_VERSION, '>=');
}
public static function supportsHttpVersionReuseMatching(): bool
{
$version = self::get();
if (null === $version || version_compare($version, self::HTTP_VERSION_REUSE_MATCH_VERSION, '<')) {
return false;
}
return version_compare($version, self::HTTP_VERSION_REUSE_MATCH_REGRESSION, '<')
|| version_compare($version, self::HTTP_VERSION_REUSE_MATCH_RESTORED, '>=');
}
public static function supportsRequiredHttp2Multiplex(): bool
{
$version = self::get();
return \defined('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE')
&& null !== $version
&& self::supportsHttp2()
&& version_compare($version, self::REQUIRED_HTTP2_MULTIPLEX_VERSION, '>=');
}
public static function supportsHttp2(): bool
{
$versionInfo = self::getVersionInfo();
// Requiring dependable CURLOPT_PIPEWAIT support keeps waiting from
// ever being silently unavailable where HTTP/2 works.
return \defined('CURL_VERSION_HTTP2')
&& null !== $versionInfo
&& self::supportsMultiplex()
&& 0 !== (\CURL_VERSION_HTTP2 & $versionInfo['features']);
}
public static function supportsHttp3(): bool
{
if (!\defined('CURL_VERSION_HTTP3') || !\defined('CURL_HTTP_VERSION_3') || !\defined('CURL_HTTP_VERSION_3ONLY')) {
return false;
}
$versionInfo = self::getVersionInfo();
if (null === $versionInfo || version_compare($versionInfo['version'], self::HTTP_3_VERSION, '<')) {
return false;
}
return 0 !== ((int) \constant('CURL_VERSION_HTTP3') & $versionInfo['features']);
}
public static function supportsRequiredHttp3Multiplex(): bool
{
$version = self::get();
return self::supportsHttp3()
&& null !== $version
&& version_compare($version, self::REQUIRED_HTTP3_MULTIPLEX_VERSION, '>=');
}
public static function supportsHttpsProxy(): bool
{
$versionInfo = self::getVersionInfo();
return \defined('CURL_VERSION_HTTPS_PROXY')
&& null !== $versionInfo
&& version_compare($versionInfo['version'], self::HTTPS_PROXY_VERSION, '>=')
&& 0 !== (\CURL_VERSION_HTTPS_PROXY & $versionInfo['features']);
}
public static function supportsHandlerSharing(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::HANDLER_SHARING_VERSION, '>=');
}
public static function ensureHandlerSharingSupported(): void
{
if (!self::supportsHandlerSharing()) {
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires libcurl %s or higher for cURL share handles.',
self::HANDLER_SHARING_VERSION
));
}
}
public static function supportsSslSessionSharing(): bool
{
$versionInfo = self::getVersionInfo();
return \defined('CURL_VERSION_SSL')
&& null !== $versionInfo
&& version_compare($versionInfo['version'], self::SSL_SESSION_SHARING_VERSION, '>=')
&& 0 !== (\CURL_VERSION_SSL & $versionInfo['features']);
}
public static function ensureSslSessionSharingSupported(): void
{
if (!self::supportsSslSessionSharing()) {
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires libcurl %s or higher with SSL support for SSL session sharing.',
self::SSL_SESSION_SHARING_VERSION
));
}
}
public static function supportsConnectionSharing(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::CONNECTION_SHARING_VERSION, '>=');
}
public static function ensureConnectionSharingSupported(): void
{
if (!self::supportsConnectionSharing()) {
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires libcurl %s or higher for persistent connection sharing.',
self::CONNECTION_SHARING_VERSION
));
}
}
public static function supportsShareConnectionCaches(): bool
{
$version = self::get();
// An undetectable libcurl version is treated as capable so the
// opaque share safeguards fail closed.
return null === $version
|| version_compare($version, self::SHARE_CONNECTION_CACHE_VERSION, '>=');
}
public static function supportsProxyTlsCredentialAwareConnectionReuse(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::PROXY_TLS_CREDENTIAL_REUSE_VERSION, '>=');
}
public static function supportsProxyCredentialAwareConnectionReuse(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::PROXY_CREDENTIAL_REUSE_VERSION, '>=');
}
public static function supportsSocksProxyCredentialAwareConnectionReuse(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::SOCKS_PROXY_CREDENTIAL_REUSE_VERSION, '>=');
}
public static function supportsProxyHeaderSeparation(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::PROXY_HEADER_SEPARATION_VERSION, '>=')
&& \defined('CURLOPT_PROXYHEADER')
&& \defined('CURLOPT_HEADEROPT')
&& \defined('CURLHEADER_SEPARATE');
}
public static function supportsProtocolsStr(): bool
{
$version = self::get();
return \defined('CURLOPT_PROTOCOLS_STR')
&& null !== $version
&& version_compare($version, self::PROTOCOLS_STR_VERSION, '>=');
}
public static function supportsProxyTunneling(): bool
{
$version = self::get();
return \defined('CURLOPT_SUPPRESS_CONNECT_HEADERS')
&& null !== $version
&& version_compare($version, self::PROXY_TUNNEL_VERSION, '>=');
}
public static function ensureSupported(
#[\SensitiveParameter]
RequestInterface $request
): void {
if (self::supportsCurlHandler()) {
return;
}
$version = self::get();
if (null === $version || version_compare($version, self::MIN_VERSION, '<')) {
throw new ConnectException(\sprintf(
'cURL %s or higher is required by the cURL handler; %s is installed.',
self::MIN_VERSION,
$version ?? 'an unknown version'
), $request);
}
if (!\defined('CURL_SSLVERSION_TLSv1_2')) {
throw new ConnectException(\sprintf(
'The PHP cURL extension must be built against cURL %s or higher to use the cURL handler.',
self::MIN_VERSION
), $request);
}
if (!\defined('CURLMOPT_MAX_HOST_CONNECTIONS') || !\defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')) {
throw new ConnectException('The PHP cURL extension must expose CURLMOPT_MAX_HOST_CONNECTIONS and CURLMOPT_MAX_TOTAL_CONNECTIONS to use the cURL handler.', $request);
}
throw new ConnectException('The cURL handler requires libcurl SSL support.', $request);
}
private static function get(): ?string
{
$versionInfo = self::getVersionInfo();
return null === $versionInfo ? null : $versionInfo['version'];
}
/**
* @return array{version: string, features: int}|null
*/
private static function getVersionInfo(): ?array
{
if (null === self::$versionInfo) {
if (!\function_exists('curl_version')) {
self::$versionInfo = false;
} else {
$versionInfo = \curl_version();
self::$versionInfo = \is_array($versionInfo)
&& isset($versionInfo['version'], $versionInfo['features'])
&& \is_string($versionInfo['version'])
&& \is_int($versionInfo['features'])
? [
'version' => $versionInfo['version'],
'features' => $versionInfo['features'],
]
: false;
}
}
return false === self::$versionInfo ? null : self::$versionInfo;
}
}

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
/**
* Decorates the transport stream of a buffered response so every read
* observes the wall-clock deadline of the "timeout" request option.
*
* The transport resource must be in non-blocking mode: PHP's filtered
* stream reads (chunked and compressed responses) otherwise block until
* the full requested length arrives, so a deadline check between reads
* never runs while a server keeps sending small pieces of data. Reads
* return whatever bytes are available and poll briefly for more, so the
* deadline is checked between packets rather than between buffers. The
* wait is a bounded sleep because stream_select() cannot watch a stream
* that carries a filter.
*
* @internal
*/
final class DeadlineSourceStream implements StreamInterface
{
use NonSerializableTrait;
use StreamDecoratorTrait;
private const POLL_INTERVAL = 0.05;
private StreamInterface $stream;
private float $deadline;
private ?float $readTimeout;
private bool $timedOut = false;
/**
* @param StreamInterface $stream Transport stream in non-blocking mode.
* @param float $deadline Deadline based on Clock::now().
* @param float|null $readTimeout Optional idle timeout per read, in seconds.
*/
public function __construct(StreamInterface $stream, float $deadline, ?float $readTimeout)
{
$this->stream = $stream;
$this->deadline = $deadline;
$this->readTimeout = $readTimeout;
}
public function read(int $length): string
{
$idleStart = null;
while (true) {
$remaining = $this->deadline - Clock::now();
if ($remaining <= 0) {
$this->timedOut = true;
throw new TimeoutException('Unable to read from stream: timed out');
}
$data = $this->stream->read($length);
if ($data !== '') {
return $data;
}
if ($this->stream->eof()) {
return '';
}
$now = Clock::now();
if ($idleStart === null) {
$idleStart = $now;
}
if ($this->readTimeout !== null && $now - $idleStart >= $this->readTimeout) {
$this->timedOut = true;
throw new TimeoutException('Unable to read from stream: timed out');
}
$wait = \min($remaining, self::POLL_INTERVAL);
if ($this->readTimeout !== null) {
$wait = \min($wait, $this->readTimeout - ($now - $idleStart));
}
\usleep((int) \ceil($wait * 1e6));
}
}
/**
* Reports the deadline timeout through the timed_out metadata: reads on
* a decoding layer above this stream surface as a generic read failure,
* and its timeout recovery consults this stream's metadata to classify
* the failure. The transport itself never reports timed_out while in
* non-blocking mode.
*
* @return mixed
*/
public function getMetadata(?string $key = null)
{
if ($key === 'timed_out') {
return $this->timedOut || $this->stream->getMetadata($key) === true;
}
$metadata = $this->stream->getMetadata($key);
if ($key === null && \is_array($metadata) && $this->timedOut) {
$metadata['timed_out'] = true;
}
return $metadata;
}
}

View file

@ -1,10 +1,20 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\Exception\ResponseTransferException;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7\DiagnosticValue;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\RequestOptions;
use GuzzleHttp\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
@ -15,50 +25,118 @@ use Psr\Http\Message\StreamInterface;
*/
final class EasyHandle
{
use NonSerializableTrait;
/**
* @var resource|\CurlHandle cURL resource
*/
public $handle;
/**
* @var StreamInterface Where data is being written
*/
public $sink;
public StreamInterface $sink;
public RequestInterface $request;
/**
* @var array Received HTTP headers so far
* @var list<string> Received HTTP headers so far
*/
public $headers = [];
public array $headers = [];
/**
* @var list<string> Valid trailer lines, retained only when an
* on_trailers callback is configured
*/
public array $trailers = [];
/**
* @var bool Whether this handle was configured with CURLOPT_PIPEWAIT
*/
public bool $usesPipewait = false;
/**
* @var ResponseInterface|null Received response (if any)
*/
public $response;
/**
* @var RequestInterface Request being sent
*/
public $request;
public ?ResponseInterface $response = null;
/**
* @var array Request options
*/
public $options = [];
public array $options = [];
/**
* @var int cURL error number (if any)
*/
public $errno = 0;
public int $errno = 0;
/**
* @var string|null Effective CURLOPT_PROXY value the handle was created with (if any)
*/
public ?string $effectiveProxy = null;
/**
* Proxy tunnel or SOCKS proxy section signature for connection-reuse
* isolation, or null when the request does not require sectioning.
*/
public ?string $proxyTunnelSignature = null;
/**
* @var \Throwable|null Exception during on_headers (if any)
*/
public $onHeadersException;
public ?\Throwable $onHeadersException = null;
/**
* @var \Throwable|null Exception during progress callback (if any)
*/
public ?\Throwable $progressException = null;
/**
* @var bool Whether the progress callback requested abort
*/
public bool $progressAborted = false;
/**
* @var \Throwable|null Exception during createResponse (if any)
*/
public $createResponseException;
public ?\Throwable $createResponseException = null;
/**
* @var ResponseException|null Response header failure, if any.
*/
public ?ResponseException $responseHeaderException = null;
/**
* @var TimeoutException|null Exception during request body read timeout.
*/
public ?TimeoutException $bodyReadTimeoutException = null;
/**
* @var \Throwable|null Exception during request body read.
*/
public ?\Throwable $bodyReadException = null;
/**
* @var TimeoutException|null Exception during response sink write timeout.
*/
public ?TimeoutException $sinkWriteTimeoutException = null;
/**
* @var \Throwable|null Exception during response sink write.
*/
public ?\Throwable $sinkWriteException = null;
/**
* @var bool Whether the response sink accepted a different byte count.
*/
public bool $sinkWriteIncomplete = false;
/**
* @var int Number of response body bytes accepted by the sink.
*/
public int $responseBodyBytes = 0;
/**
* @var \OverflowException|null Unrepresentable response body size or byte count.
*/
public ?\OverflowException $responseBodySizeException = null;
/**
* Attach a response to the easy handle based on the received headers.
@ -69,46 +147,99 @@ final class EasyHandle
public function createResponse(): void
{
$this->response = null;
$this->responseBodyBytes = 0;
$this->responseBodySizeException = null;
$this->responseHeaderException = null;
[$ver, $status, $reason, $headers] = HeaderProcessor::parseHeaders($this->headers);
$normalizedKeys = Utils::normalizeHeaderKeys($headers);
// Non-101 informational responses precede the final response. Do not
// expose them as the response for a transfer that ends before the final
// response arrives. 101 switches protocol and is kept as terminal.
if ($status < 200 && $status !== 101) {
return;
}
if (!empty($this->options['decode_content']) && isset($normalizedKeys['content-encoding'])) {
$framingFailure = null;
try {
$declaredLength = HeaderProcessor::validateResponseFraming($this->request->getMethod(), $status, $headers);
HeaderProcessor::assertContentLengthWithinPlatformLimit($declaredLength);
} catch (\RuntimeException $e) {
$framingFailure = $e;
}
$normalizedKeys = Utils::normalizeHeaderKeys($headers);
$decodeContent = $this->options['decode_content'] ?? false;
if ($framingFailure === null && $decodeContent !== false && isset($normalizedKeys['content-encoding'])) {
$headers['x-encoded-content-encoding'] = $headers[$normalizedKeys['content-encoding']];
unset($headers[$normalizedKeys['content-encoding']]);
if (isset($normalizedKeys['content-length'])) {
$headers['x-encoded-content-length'] = $headers[$normalizedKeys['content-length']];
$encodedContentLength = HeaderProcessor::removeHeader('Content-Length', $headers);
if ($encodedContentLength !== []) {
$headers['x-encoded-content-length'] = $encodedContentLength;
$bodyLength = (int) $this->sink->getSize();
try {
$bodyLength = $this->sink->getSize();
} catch (\Exception $e) {
$bodyLength = null;
}
if ($bodyLength) {
$headers[$normalizedKeys['content-length']] = [(string) $bodyLength];
} else {
unset($headers[$normalizedKeys['content-length']]);
$headers['Content-Length'] = [(string) $bodyLength];
}
}
}
// Attach a response to the easy handle with the parsed headers.
$this->response = new Response(
$status,
$headers,
$this->sink,
$ver,
$reason
);
// Attach a response to the easy handle with the parsed headers. Any
// exception propagates to the caller (CurlFactory), which records it as
// the createResponseException — do not catch it here.
$responseFactory = self::requireResponseFactory($this->options[RequestOptions::RESPONSE_FACTORY] ?? new HttpFactory());
$response = $responseFactory->createResponse($status, $reason ?? '')->withProtocolVersion($ver);
foreach ($headers as $name => $value) {
$response = $response->withAddedHeader((string) $name, $value);
}
$this->response = $response->withBody($this->sink);
if ($framingFailure instanceof \OverflowException) {
$this->responseHeaderException = new ResponseException(
$framingFailure->getMessage(),
$this->request,
$this->response,
$framingFailure
);
} elseif ($framingFailure !== null) {
$this->responseHeaderException = new ResponseTransferException(
$framingFailure->getMessage(),
$this->request,
$this->response,
$framingFailure
);
}
}
/**
* @param mixed $factory
*/
private static function requireResponseFactory($factory): ResponseFactoryInterface
{
if (!$factory instanceof ResponseFactoryInterface) {
throw new InvalidArgumentException(\sprintf(
'%s must be an instance of %s',
RequestOptions::RESPONSE_FACTORY,
ResponseFactoryInterface::class
));
}
return $factory;
}
/**
* @param string $name
*
* @return void
*
* @throws \BadMethodCallException
*/
public function __get($name)
public function __get(string $name): void
{
$msg = $name === 'handle' ? 'The EasyHandle has been released' : 'Invalid property: '.$name;
$msg = $name === 'handle'
? 'The EasyHandle has been released'
: \sprintf('Invalid property: %s', DiagnosticValue::escape($name));
throw new \BadMethodCallException($msg);
}
}

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
/**
* Tracks encoded response bytes beneath a content-decoding stream.
*
* Reads are bounded by a positive, representable Content-Length so a decoder
* cannot consume bytes beyond the response message boundary.
*
* @internal
*/
final class EncodedBodyStream implements StreamInterface
{
use NonSerializableTrait;
use StreamDecoratorTrait;
private StreamInterface $stream;
private ?int $limit;
private int $bytesRead = 0;
public function __construct(StreamInterface $stream, string $declaredLength)
{
$this->stream = $stream;
$limit = HeaderProcessor::contentLengthToInt($declaredLength);
$this->limit = $limit !== null && $limit > 0 ? $limit : null;
}
public function eof(): bool
{
return ($this->limit !== null && $this->bytesRead >= $this->limit)
|| $this->stream->eof();
}
public function isSeekable(): bool
{
return false;
}
public function seek(int $offset, int $whence = \SEEK_SET): void
{
throw new \RuntimeException('Cannot seek a stream while tracking encoded response bytes');
}
public function read(int $length): string
{
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
if ($this->limit !== null) {
$remaining = $this->limit - $this->bytesRead;
if ($remaining === 0) {
return '';
}
$length = \min($length, $remaining);
}
$data = $this->stream->read($length);
$this->bytesRead = TransferByteCounter::add(
$this->bytesRead,
\strlen($data),
'Response body exceeds the maximum integer size supported on this platform'
);
return $data;
}
public function getBytesRead(): int
{
return $this->bytesRead;
}
}

View file

@ -1,7 +1,10 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Psr7;
use GuzzleHttp\Utils;
/**
@ -9,6 +12,10 @@ use GuzzleHttp\Utils;
*/
final class HeaderProcessor
{
private function __construct()
{
}
/**
* Returns the HTTP version, status code, reason phrase, and headers.
*
@ -32,29 +39,193 @@ final class HeaderProcessor
}
$parts = \explode(' ', $statusLine, 3);
$version = \explode('/', $parts[0])[1] ?? null;
$protocol = $parts[0];
if ($version === null) {
if (!Psr7\Utils::caselessEquals(\substr($protocol, 0, 5), 'HTTP/')) {
throw new \RuntimeException('HTTP version missing from header data');
}
$version = \substr($protocol, 5);
if (!\preg_match('/^\d+(?:\.\d+)?$/D', $version)) {
throw new \RuntimeException('HTTP version is invalid');
}
$status = $parts[1] ?? null;
if ($status === null) {
throw new \RuntimeException('HTTP status code missing from header data');
}
if (!\preg_match('/^\d{3}$/', $status)) {
if (!\preg_match('/^[1-5]\d{2}$/D', $status)) {
throw new \RuntimeException('HTTP status code is invalid');
}
$reason = $parts[2] ?? null;
if ($reason !== null && !\preg_match('/^[\x09\x20-\x7E\x80-\xFF]*$/D', $reason)) {
throw new \RuntimeException('HTTP reason phrase is invalid');
}
foreach ($headers as $header) {
if (\strpos($header, ':') === false) {
throw new \RuntimeException('HTTP header line is invalid');
}
}
return [$version, (int) $status, $parts[2] ?? null, Utils::headersFromLines($headers)];
return [$version, (int) $status, $reason, Utils::headersFromLines($headers)];
}
public static function isStatusLineCandidate(string $line): bool
{
return \preg_match('/^HTTP\/[0-9]+(?:\.[0-9]+)? [0-9]{3}(?: [^\r\n]*)?(?:\r\n|\r|\n)?$/iD', $line) === 1;
}
public static function isValidHeaderFieldLine(string $line): bool
{
$parts = \explode(':', $line, 2);
if (!isset($parts[1])) {
return false;
}
if (!\preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $parts[0])) {
return false;
}
return \preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*(?:\r\n|\r|\n)?$/D', \trim($parts[1], " \t")) === 1;
}
/**
* Returns a normalized decimal Content-Length, or null when absent.
*
* @param string[] $values
*
* @throws \RuntimeException when Content-Length is malformed or conflicting.
*/
public static function parseContentLength(array $values): ?string
{
$length = null;
foreach ($values as $value) {
foreach (\explode(',', $value) as $part) {
$part = \trim($part, " \t");
if (\preg_match('/^[0-9]+$/D', $part) !== 1) {
throw new \RuntimeException('value is not a non-negative decimal integer');
}
$part = \ltrim($part, '0');
$part = $part === '' ? '0' : $part;
if ($length !== null && $part !== $length) {
throw new \RuntimeException('values conflict');
}
$length = $part;
}
}
if ($length === null) {
return null;
}
return $length;
}
public static function contentLengthToInt(?string $length): ?int
{
if ($length === null) {
return null;
}
$max = (string) \PHP_INT_MAX;
if (
\strlen($length) > \strlen($max)
|| (\strlen($length) === \strlen($max) && \strcmp($length, $max) > 0)
) {
return null;
}
return (int) $length;
}
public static function assertContentLengthWithinPlatformLimit(?string $length): void
{
if ($length === null || self::contentLengthToInt($length) !== null) {
return;
}
throw new \OverflowException('Content-Length exceeds the maximum integer size supported on this platform');
}
/**
* Validates response framing and returns its normalized Content-Length.
* Returns null when absent or when ordinary body framing does not apply.
*
* @param array<string, string[]> $headers
*
* @throws \RuntimeException when Content-Length is malformed, conflicting,
* or combined with Transfer-Encoding
*/
public static function validateResponseFraming(
string $method,
int $status,
array $headers
): ?string {
if (!self::responseCanHaveBody($method, $status)) {
return null;
}
$normalizedKeys = Utils::normalizeHeaderKeys($headers);
$contentLength = self::removeHeader('Content-Length', $headers);
try {
$length = self::parseContentLength($contentLength);
} catch (\RuntimeException $e) {
throw new \RuntimeException('Invalid Content-Length response header: '.$e->getMessage(), 0, $e);
}
if ($length !== null && isset($normalizedKeys['transfer-encoding'])) {
throw new \RuntimeException('A response must not contain both Content-Length and Transfer-Encoding');
}
return $length;
}
/**
* Removes every case-insensitive occurrence of a header and returns all
* removed values in their original field order.
*
* @param array<string, string[]> $headers
*
* @return string[] Removed values across all header-name casings
*/
public static function removeHeader(string $name, array &$headers): array
{
$values = [];
foreach ($headers as $key => $headerValues) {
if (Psr7\Utils::caselessEquals((string) $key, $name)) {
\array_push($values, ...$headerValues);
unset($headers[$key]);
}
}
return $values;
}
/**
* Whether a response uses ordinary body framing. A response to HEAD, a
* response with a 1xx, 204, or 304 status code, or a 2xx response to
* CONNECT never has a body, whatever its framing headers claim. A 205
* remains subject to framing even though its semantics require no content.
*/
public static function responseCanHaveBody(string $method, int $status): bool
{
return $method !== 'HEAD'
&& !($method === 'CONNECT' && $status >= 200 && $status < 300)
&& $status >= 200
&& $status !== 204
&& $status !== 304;
}
/**
@ -67,7 +238,7 @@ final class HeaderProcessor
$lastStatusLine = 0;
foreach ($headers as $index => $line) {
if (\preg_match('/^HTTP\/\S+\s+/i', $line)) {
if (self::isStatusLineCandidate($line)) {
$lastStatusLine = $index;
}
}

View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7;
use Psr\Http\Message\RequestInterface;
/**
* Rejects request hosts that a handler could resolve differently from the host
* the request names.
*
* @internal
*/
final class HostValidator
{
private function __construct()
{
}
/**
* Asserts that a request names one unambiguous network host.
*
* A handler reparses the URI but sends the Host header as given. The URI
* host must therefore be printable ASCII, free of percent escapes, a valid
* RFC 3986 host, and not numeric-looking parts followed by trailing dots.
* Every Host header value must be printable ASCII.
*
* @throws RequestException
*/
public static function assertRequestHost(
#[\SensitiveParameter]
RequestInterface $request
): void {
self::assertUriHost($request->getUri()->getHost(), $request);
foreach ($request->getHeader('Host') as $value) {
self::assertPrintableAscii((string) $value, 'The request Host header "%s" must contain only printable ASCII characters, because an intermediary or an origin server can otherwise read it as an authority that differs from the one the request names. An internationalized host name has an A-label form that this rule accepts.', $request);
}
}
/**
* Only the URI host is reparsed for the connection. A Host header is sent
* verbatim and may carry a port, brackets, and an RFC 6874 zone identifier.
*
* @throws RequestException
*/
private static function assertUriHost(
string $host,
#[\SensitiveParameter]
RequestInterface $request
): void {
self::assertPrintableAscii($host, 'The request URI host "%s" must contain only printable ASCII characters, because a handler can otherwise connect to a host that differs from the one the request names. An internationalized host name has an A-label form that this rule accepts.', $request);
if (\strpos($host, '%') !== false) {
throw new RequestException(\sprintf('The request URI host "%s" must not contain a percent escape, because a handler decodes it and can then connect to a host that differs from the one the request names.', self::escape($host)), $request);
}
// GuzzleHttp\Psr7\Uri already enforces this predicate, so it only
// rejects invalid third-party UriInterface values.
if (!Psr7\Rfc3986::isValidHost($host)) {
throw new RequestException(\sprintf('The request URI host "%s" must be a valid RFC 3986 host, because a handler reparses the URI and can then connect to a host that differs from the one the request names.', self::escape($host)), $request);
}
// libcurl 8.21.0 drops a trailing dot from inet_aton-style numeric
// hosts before connecting, while other readers treat the input as a
// name. Test the shape rather than the range so malformed numeric
// values fail closed. rtrim() also covers multiple trailing dots if
// libcurl later relaxes its current guard. Plain shorthand stays
// accepted. HostIdentity uses a broader grammar for cookie matching.
//
// @see \GuzzleHttp\HostIdentity::canonicalHost()
if (\str_ends_with($host, '.') && self::isNumericIpv4Host(\rtrim($host, '.'))) {
throw new RequestException(\sprintf('The request URI host "%s" must not be written as one to four decimal, octal or hexadecimal parts followed by one or more trailing dots, because a handler can read that spelling as an IPv4 address and connect to that address while the rest of the process reads a name.', self::escape($host)), $request);
}
}
/**
* Reports whether a host has libcurl's inet_aton-style shape: one to four
* decimal, 0-prefixed octal, or 0x-prefixed hexadecimal parts.
*
* Range and 32-bit overflow checks are deliberately omitted. This may
* reject a trailing-dot spelling the transport reads as a name, but avoids
* missing one it resolves as an address.
*/
public static function isNumericIpv4Host(string $host): bool
{
if ($host === '') {
return false;
}
$parts = \explode('.', $host);
if (\count($parts) > 4) {
return false;
}
foreach ($parts as $part) {
if (!self::isNumericIpv4Part($part)) {
return false;
}
}
return true;
}
private static function isNumericIpv4Part(string $part): bool
{
if ($part === '') {
return false;
}
if ($part[0] === '0' && isset($part[1]) && ($part[1] === 'x' || $part[1] === 'X')) {
return \strlen($part) > 2 && \strspn($part, '0123456789abcdefABCDEF', 2) === \strlen($part) - 2;
}
$digits = $part[0] === '0' ? '01234567' : '0123456789';
return \strspn($part, $digits) === \strlen($part);
}
/**
* @throws RequestException
*/
private static function assertPrintableAscii(
string $value,
string $message,
#[\SensitiveParameter]
RequestInterface $request
): void {
// Match positively so a PCRE failure rejects.
if (\preg_match('/\A[\x21-\x7E]*\z/D', $value) !== 1) {
throw new RequestException(\sprintf($message, self::escape($value)), $request);
}
}
/**
* Escapes non-printable bytes as uppercase \xNN for safe diagnostics.
*
* Psr7\DiagnosticValue is not used because it preserves valid non-ASCII
* UTF-8, including invisible characters rejected here.
*/
private static function escape(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

@ -1,46 +1,46 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\TransferStats;
use GuzzleHttp\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
/**
* Handler that returns responses or throw exceptions from a queue.
*
* @final
* Handler that returns responses or rejection reasons from a queue.
*/
class MockHandler implements \Countable
final class MockHandler implements \Countable
{
/**
* @var array
*/
private $queue = [];
use NonSerializableTrait;
/**
* @var RequestInterface|null
* @var list<ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>|callable(RequestInterface, array<array-key, mixed>): (ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>)>
*/
private $lastRequest;
private array $queue = [];
private ?RequestInterface $lastRequest = null;
/**
* @var array
* @var array<array-key, mixed>
*/
private $lastOptions = [];
private array $lastOptions = [];
/**
* @var callable|null
* @var (callable(ResponseInterface|null): mixed)|null
*/
private $onFulfilled;
/**
* @var callable|null
* @var (callable(mixed): mixed)|null
*/
private $onRejected;
@ -48,26 +48,35 @@ class MockHandler implements \Countable
* Creates a new MockHandler that uses the default handler stack list of
* middlewares.
*
* @param array|null $queue Array of responses, callables, or exceptions.
* @param callable|null $onFulfilled Callback to invoke when the return value is fulfilled.
* @param callable|null $onRejected Callback to invoke when the return value is rejected.
* @param array<array-key, ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>|callable(RequestInterface, array<array-key, mixed>): (ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>)>|null $queue Array of responses, promises, callables, or throwables.
* @param (callable(ResponseInterface|null): mixed)|null $onFulfilled Callback to invoke when the return value is fulfilled.
* @param (callable(mixed): mixed)|null $onRejected Callback to invoke when the return value is rejected.
*
* @return HandlerStack<callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>>
*/
public static function createWithMiddleware(?array $queue = null, ?callable $onFulfilled = null, ?callable $onRejected = null): HandlerStack
{
public static function createWithMiddleware(
#[\SensitiveParameter]
?array $queue = null,
?callable $onFulfilled = null,
?callable $onRejected = null
): HandlerStack {
return HandlerStack::create(new self($queue, $onFulfilled, $onRejected));
}
/**
* The passed in value must be an array of
* {@see ResponseInterface} objects, Exceptions,
* callables, or Promises.
* {@see ResponseInterface} objects, throwables, callables, or promises.
*
* @param array<int, mixed>|null $queue The parameters to be passed to the append function, as an indexed array.
* @param callable|null $onFulfilled Callback to invoke when the return value is fulfilled.
* @param callable|null $onRejected Callback to invoke when the return value is rejected.
* @param array<array-key, ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>|callable(RequestInterface, array<array-key, mixed>): (ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>)>|null $queue The parameters to be passed to the append function, as an indexed array.
* @param (callable(ResponseInterface|null): mixed)|null $onFulfilled Callback to invoke when the return value is fulfilled.
* @param (callable(mixed): mixed)|null $onRejected Callback to invoke when the return value is rejected.
*/
public function __construct(?array $queue = null, ?callable $onFulfilled = null, ?callable $onRejected = null)
{
public function __construct(
#[\SensitiveParameter]
?array $queue = null,
?callable $onFulfilled = null,
?callable $onRejected = null
) {
$this->onFulfilled = $onFulfilled;
$this->onRejected = $onRejected;
@ -77,30 +86,41 @@ class MockHandler implements \Countable
}
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
if (!$this->queue) {
// Test-setup error (more requests made than responses queued);
// intentionally a bare SPL exception, not a GuzzleException.
throw new \OutOfBoundsException('Mock queue is empty');
}
if (isset($options['delay']) && \is_numeric($options['delay'])) {
\usleep((int) $options['delay'] * 1000);
\usleep((int) ($options['delay'] * 1000));
}
if (isset($options['on_stats']) && !\is_callable($options['on_stats'])) {
throw new InvalidArgumentException('on_stats must be callable');
}
$this->lastRequest = $request;
$this->lastOptions = $options;
$response = \array_shift($this->queue);
$onHeaders = null;
$onHeadersResponse = null;
if (isset($options['on_headers'])) {
if (!\is_callable($options['on_headers'])) {
throw new \InvalidArgumentException('on_headers must be callable');
}
try {
$options['on_headers']($response);
} catch (\Exception $e) {
$msg = 'An error was encountered during the on_headers event';
$response = new RequestException($msg, $request, $response, $e);
throw new InvalidArgumentException('on_headers must be callable');
}
$onHeaders = $options['on_headers'];
}
if (\is_callable($response)) {
@ -111,8 +131,36 @@ class MockHandler implements \Countable
? P\Create::rejectionFor($response)
: P\Create::promiseFor($response);
return $response->then(
function (?ResponseInterface $value) use ($request, $options) {
if (\is_callable($onHeaders)) {
$response = $response->then(
static function (
#[\SensitiveParameter]
$value
) use ($onHeaders, $request, &$onHeadersResponse) {
if (!$value instanceof ResponseInterface) {
return $value;
}
try {
$onHeaders($value, $request);
} catch (\Throwable $e) {
$msg = 'An error was encountered during the on_headers event';
$onHeadersResponse = $value;
throw new ResponseException($msg, $request, $value, $e);
}
return $value;
}
);
}
$promise = $response->then(
function (
#[\SensitiveParameter]
$value
) use ($request, $options): ?ResponseInterface {
/** @var ResponseInterface|null $value */
$this->invokeStats($request, $options, $value);
if ($this->onFulfilled) {
($this->onFulfilled)($value);
@ -133,8 +181,11 @@ class MockHandler implements \Countable
return $value;
},
function ($reason) use ($request, $options) {
$this->invokeStats($request, $options, null, $reason);
function (
#[\SensitiveParameter]
$reason
) use ($request, $options, &$onHeadersResponse): PromiseInterface {
$this->invokeStats($request, $options, $onHeadersResponse, $reason);
if ($this->onRejected) {
($this->onRejected)($reason);
}
@ -142,16 +193,21 @@ class MockHandler implements \Countable
return P\Create::rejectionFor($reason);
}
);
/** @var PromiseInterface<ResponseInterface, mixed> $promise */
return $promise;
}
/**
* Adds one or more variadic requests, exceptions, callables, or promises
* to the queue.
*
* @param mixed ...$values
* @param mixed ...$values Responses, promises, throwables, or request-aware callables.
*/
public function append(...$values): void
{
public function append(
#[\SensitiveParameter]
...$values
): void {
foreach ($values as $value) {
if ($value instanceof ResponseInterface
|| $value instanceof \Throwable
@ -160,7 +216,7 @@ class MockHandler implements \Countable
) {
$this->queue[] = $value;
} else {
throw new \TypeError('Expected a Response, Promise, Throwable or callable. Found '.Utils::describeType($value));
throw new \TypeError('Expected a Response, Promise, Throwable or callable. Found '.\get_debug_type($value));
}
}
}
@ -198,14 +254,22 @@ class MockHandler implements \Countable
* @param mixed $reason Promise or reason.
*/
private function invokeStats(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
?ResponseInterface $response = null,
#[\SensitiveParameter]
$reason = null
): void {
if (isset($options['on_stats'])) {
$transferTime = $options['transfer_time'] ?? 0;
$stats = new TransferStats($request, $response, $transferTime, $reason);
$transferTime = $options['transfer_time'] ?? 0.0;
if (!\is_int($transferTime) && !\is_float($transferTime) && (!\is_string($transferTime) || !\is_numeric($transferTime))) {
throw new InvalidArgumentException('transfer_time must be a number of seconds');
}
$stats = new TransferStats($request, $response, (float) $transferTime, $reason);
($options['on_stats'])($stats);
}
}

View file

@ -1,30 +1,40 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Provides basic proxies for handlers.
*
* @final
*/
class Proxy
final class Proxy
{
private function __construct()
{
}
/**
* Sends synchronous requests to a specific handler while sending all other
* requests to another handler.
*
* @param callable(RequestInterface, array): PromiseInterface $default Handler used for normal responses
* @param callable(RequestInterface, array): PromiseInterface $sync Handler used for synchronous responses.
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $default Handler used for normal responses
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $sync Handler used for synchronous responses.
*
* @return callable(RequestInterface, array): PromiseInterface Returns the composed handler.
* @return callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> Returns the composed handler.
*/
public static function wrapSync(callable $default, callable $sync): callable
{
return static function (RequestInterface $request, array $options) use ($default, $sync): PromiseInterface {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($default, $sync): PromiseInterface {
return empty($options[RequestOptions::SYNCHRONOUS]) ? $default($request, $options) : $sync($request, $options);
};
}
@ -37,14 +47,19 @@ class Proxy
* performance benefits of curl while still supporting true streaming
* through the StreamHandler.
*
* @param callable(RequestInterface, array): PromiseInterface $default Handler used for non-streaming responses
* @param callable(RequestInterface, array): PromiseInterface $streaming Handler used for streaming responses
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $default Handler used for non-streaming responses
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $streaming Handler used for streaming responses
*
* @return callable(RequestInterface, array): PromiseInterface Returns the composed handler.
* @return callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> Returns the composed handler.
*/
public static function wrapStreaming(callable $default, callable $streaming): callable
{
return static function (RequestInterface $request, array $options) use ($default, $streaming): PromiseInterface {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($default, $streaming): PromiseInterface {
return empty($options['stream']) ? $default($request, $options) : $streaming($request, $options);
};
}

View file

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\ProxyOptions;
use GuzzleHttp\ProxySelection;
use GuzzleHttp\Psr7;
use Psr\Http\Message\UriInterface;
/**
* Resolves proxy configuration from the process environment with the same
* semantics libcurl applies, shared by the built-in handlers: the cURL
* handlers pin CURLOPT_PROXY and CURLOPT_NOPROXY explicitly so libcurl never
* reads the environment itself, and the stream handler resolves the same way.
*
* @internal
*/
final class ProxyEnv
{
private function __construct()
{
}
/**
* Resolves the proxy selection for a request, falling back to the proxy
* environment variables when the proxy request option makes no decision.
*
* The environment no_proxy list is tokenized the way libcurl tokenizes
* it and matched here with the same rules as the proxy option's "no"
* list, so behavior does not depend on the installed libcurl's matcher.
*
* @param mixed $proxyOption
*/
public static function resolveProxySelection(
UriInterface $uri,
#[\SensitiveParameter]
$proxyOption
): ProxySelection {
$selection = ProxyOptions::resolve($uri, $proxyOption);
// Any option decision (proxy, bypassed, or disabled) is final; only
// a none() selection leaves room for the environment.
if ($selection->hasProxy() || $selection->shouldDisableProxy()) {
return $selection;
}
$envProxy = self::getProxyForScheme($uri->getScheme());
if ($envProxy === null) {
return $selection;
}
$noProxy = self::getNoProxy();
if ($noProxy !== null && ProxyOptions::isUriInNoProxy($uri, self::splitNoProxy($noProxy))) {
return ProxySelection::bypassed();
}
// $envProxy is never '' (empty env values are treated as unset).
return ProxySelection::proxy($envProxy);
}
/**
* Resolves the proxy to use for the given request scheme.
*
* The lookup mirrors libcurl for the http and https schemes the handlers
* accept: the lowercase scheme-specific variable first, its uppercase
* variant next (except for "http", where uppercase HTTP_PROXY is never
* read), then all_proxy/ALL_PROXY.
*
* @return string|null The proxy to use; null when the environment
* configures none.
*/
public static function getProxyForScheme(string $scheme): ?string
{
$scheme = Psr7\Utils::asciiToLower($scheme);
$candidates = [$scheme.'_proxy'];
if ($scheme !== 'http') {
// Uppercase HTTP_PROXY is deliberately never consulted: a CGI
// request header "Proxy:" becomes HTTP_PROXY in the environment.
// See https://httpoxy.org for more information.
$candidates[] = Psr7\Utils::asciiToUpper($scheme).'_PROXY';
}
$candidates[] = 'all_proxy';
$candidates[] = 'ALL_PROXY';
foreach ($candidates as $name) {
$value = self::getenv($name);
if ($value !== null) {
return $value;
}
}
return null;
}
/**
* @return string|null The no-proxy list; null when nothing is set.
*/
public static function getNoProxy(): ?string
{
foreach (['no_proxy', 'NO_PROXY'] as $name) {
$value = self::getenv($name);
if ($value !== null) {
return $value;
}
}
return null;
}
/**
* Splits a no_proxy environment value into matchable entries.
*
* Entries may be separated by commas or blanks, the way libcurl tokenizes
* the value.
*
* @return string[]
*/
public static function splitNoProxy(string $noProxy): array
{
$entries = [];
$split = \preg_split('/[\s,]+/', $noProxy);
if ($split === false) {
throw new \RuntimeException('Unable to split the no_proxy value: '.\preg_last_error_msg());
}
foreach ($split as $entry) {
if ($entry !== '') {
$entries[] = $entry;
}
}
return $entries;
}
private static function getenv(string $name): ?string
{
// Windows environment variables are case-insensitive, so the
// lowercase-only httpoxy defence does not hold there. Outside the
// CLI SAPI on Windows, environment proxies are not resolved at all
// (a safe-side divergence from libcurl).
if (\PHP_OS_FAMILY === 'Windows' && \PHP_SAPI !== 'cli') {
return null;
}
// local_only: the OS environment and putenv() only - the same
// environ(7) libcurl reads. SAPI request environments such as
// fastcgi_param or SetEnv are deliberately excluded.
$value = \getenv($name, true);
// libcurl's GetEnv (lib/getenv.c) treats variables set to an empty
// string as unset on every version, so the lookup falls through to
// the next candidate.
return $value === false || $value === '' ? null : $value;
}
}

View file

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\RequestInterface;
/**
* Validates and normalizes request framing before a handler performs I/O.
*
* Body size describes the bytes available under handler rewind semantics.
* Content-Length is the selected transport boundary and may remain unknown.
*
* @internal
*/
final class RequestFraming
{
use NonSerializableTrait;
public RequestInterface $request;
/**
* Number of bytes available after applying handler rewind semantics, if
* known.
*/
public ?int $bodySize;
/** Canonical Content-Length boundary selected for transport, if any. */
public ?int $contentLength;
private function __construct(RequestInterface $request, ?int $bodySize, ?int $contentLength)
{
$this->request = $request;
$this->bodySize = $bodySize;
$this->contentLength = $contentLength;
}
/**
* Finalizes request framing for a handler dispatch.
*
* When $sendBody is false, body-dependent validation and size probing are
* skipped. Content-Length is still canonicalized and Transfer-Encoding is
* removed.
*
* @throws RequestException when framing or body metadata is unsafe
*/
public static function analyze(
#[\SensitiveParameter]
RequestInterface $request,
bool $sendBody = true
): self {
try {
$length = HeaderProcessor::parseContentLength($request->getHeader('Content-Length'));
} catch (\RuntimeException $e) {
throw new RequestException('Invalid Content-Length request header: '.$e->getMessage(), $request, 0, $e);
}
try {
HeaderProcessor::assertContentLengthWithinPlatformLimit($length);
} catch (\OverflowException $e) {
throw new RequestException($e->getMessage(), $request, 0, $e);
}
$contentLength = HeaderProcessor::contentLengthToInt($length);
if ($length !== null) {
$request = $request->withHeader('Content-Length', $length);
}
if (!$sendBody) {
return new self($request->withoutHeader('Transfer-Encoding'), null, $contentLength);
}
$transferEncoding = $request->getHeader('Transfer-Encoding');
if ($length !== null && $transferEncoding !== []) {
throw new RequestException(
'A request must not contain both Content-Length and Transfer-Encoding',
$request
);
}
if ($transferEncoding !== []) {
$tokens = [];
foreach ($transferEncoding as $value) {
foreach (\explode(',', $value) as $part) {
$tokens[] = \trim($part, " \t");
}
}
if (\count($tokens) !== 1 || !Psr7\Utils::caselessEquals($tokens[0], 'chunked') || $request->getProtocolVersion() !== '1.1') {
throw new RequestException('Unsupported Transfer-Encoding request header', $request);
}
}
$bodySize = self::bodySize($request);
if ($bodySize !== null && $contentLength !== null && $bodySize !== $contentLength) {
throw new RequestException('Content-Length does not match the request body size', $request);
}
if ($bodySize === null && $contentLength === null && $request->getProtocolVersion() === '1.0') {
throw new RequestException('An unknown-size HTTP/1.0 request body requires Content-Length', $request);
}
if ($length === null && $bodySize !== null && ($bodySize > 0 || \in_array($request->getMethod(), ['PUT', 'POST'], true))) {
$contentLength = $bodySize;
$request = $request->withHeader('Content-Length', (string) $bodySize);
}
return new self($request->withoutHeader('Transfer-Encoding'), $bodySize, $contentLength);
}
/**
* Returns the number of bytes available for dispatch, or null when unknown.
*
* Seekable bodies use their total size because handlers rewind them.
* Positioned non-seekable bodies use the total size minus their current
* position.
*
* @throws RequestException when body metadata cannot be read safely
*/
public static function bodySize(
#[\SensitiveParameter]
RequestInterface $request
): ?int {
$body = $request->getBody();
try {
$size = $body->getSize();
if ($size === null) {
return null;
}
if ($size < 0) {
throw new \RuntimeException('Request body size must not be negative');
}
$seekable = $body->isSeekable();
} catch (\Exception $e) {
throw self::bodyException(
$request,
$e,
'Timed out while determining the request body size',
'Failed to determine the request body size'
);
}
if ($seekable) {
return $size;
}
try {
$position = $body->tell();
} catch (\RuntimeException $e) {
return null;
}
if ($position < 0 || $position > $size) {
throw new RequestException('The request body position is outside the stream size', $request);
}
return $size - $position;
}
/**
* Materializes the body, stopping at the selected Content-Length when
* present.
*
* @throws RequestException when the body cannot be fully rewound or read
*/
public function materialize(): string
{
$body = $this->request->getBody();
if ($this->contentLength === null) {
try {
return (string) $body;
} catch (\Exception $e) {
throw self::bodyException(
$this->request,
$e,
'Timed out while reading the request body',
'Failed to read the request body'
);
}
}
if ($this->contentLength === 0) {
return '';
}
try {
if ($body->isSeekable()) {
$body->rewind();
}
} catch (\Exception $e) {
throw self::bodyException(
$this->request,
$e,
'Timed out while rewinding the request body',
'Failed to rewind the request body'
);
}
$contents = '';
$remaining = $this->contentLength;
while ($remaining > 0) {
$limit = \min(8192, $remaining);
try {
$chunk = $body->read($limit);
} catch (\Exception $e) {
throw self::bodyException(
$this->request,
$e,
'Timed out while reading the request body',
'Failed to read the request body'
);
}
if ($chunk === '') {
throw new RequestException(
'Request body ended before the declared Content-Length was reached',
$this->request
);
}
if (\strlen($chunk) > $limit) {
throw new RequestException('Request body stream returned more bytes than requested', $this->request);
}
$contents .= $chunk;
$remaining -= \strlen($chunk);
}
return $contents;
}
private static function bodyException(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
\Exception $exception,
string $timeoutMessage,
string $fallbackMessage
): RequestException {
$message = $exception instanceof TimeoutException
? $timeoutMessage
: ($exception->getMessage() !== '' ? $exception->getMessage() : $fallbackMessage);
return new RequestException($message, $request, 0, $exception);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
/**
* @internal
*/
final class Timeout
{
private function __construct()
{
}
/**
* Converts a request timeout option to integer milliseconds.
*
* @param mixed $value
*/
public static function toMilliseconds($value, string $option): int
{
if (!\is_int($value) && !\is_float($value) && (!\is_string($value) || !\is_numeric($value))) {
throw new InvalidArgumentException($option.' must be a number of seconds');
}
$seconds = (float) $value;
if (!\is_finite($seconds) || $seconds < 0) {
throw new InvalidArgumentException($option.' must be 0 or greater than or equal to 0.001 seconds');
}
$milliseconds = (int) ($seconds * 1000);
if ($seconds > 0 && $milliseconds === 0) {
throw new InvalidArgumentException($option.' must be 0 or greater than or equal to 0.001 seconds');
}
return $milliseconds;
}
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
/**
* @internal
*/
final class TlsVersion
{
/**
* @param mixed $value
*/
public static function ordinal(string $option, $value): int
{
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT) {
return 10;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT) {
return 11;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT) {
return 12;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT) {
return 13;
}
throw new InvalidArgumentException(\sprintf('Invalid %s request option: unknown version provided', $option));
}
/**
* @param mixed $min
* @param mixed $max
*/
public static function assertRange($min, $max): void
{
if ($min === null || $max === null) {
return;
}
if (self::ordinal('crypto_method_max', $max) < self::ordinal('crypto_method', $min)) {
throw new InvalidArgumentException('Invalid crypto_method_max request option: maximum TLS version must be greater than or equal to crypto_method');
}
}
/**
* @param mixed $value
*/
public static function streamProtocolVersion(string $option, $value): int
{
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT) {
return \STREAM_CRYPTO_PROTO_TLSv1_0;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT) {
return \STREAM_CRYPTO_PROTO_TLSv1_1;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT) {
return \STREAM_CRYPTO_PROTO_TLSv1_2;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT) {
return \STREAM_CRYPTO_PROTO_TLSv1_3;
}
throw new InvalidArgumentException(\sprintf('Invalid %s request option: unknown version provided', $option));
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
/**
* @internal
*/
final class TransferByteCounter
{
private function __construct()
{
}
/**
* @param mixed $value
*/
public static function progressValueToInt($value): int
{
if (\is_int($value)) {
if ($value < 0) {
throw new \OverflowException('Progress byte count exceeds the maximum integer size supported on this platform');
}
return $value;
}
if (!\is_float($value)) {
throw new \UnexpectedValueException('Progress byte count must be an integer or float');
}
if (
!\is_finite($value)
|| $value < 0
|| $value > \PHP_INT_MAX
|| (\PHP_INT_SIZE === 8 && $value >= (float) \PHP_INT_MAX)
) {
throw new \OverflowException('Progress byte count exceeds the maximum integer size supported on this platform');
}
$intValue = (int) $value;
if ($intValue < 0) {
throw new \OverflowException('Progress byte count exceeds the maximum integer size supported on this platform');
}
return $intValue;
}
public static function add(int $current, int $delta, string $message): int
{
if ($current < 0 || $delta < 0) {
throw new \OverflowException($message);
}
if ($delta > \PHP_INT_MAX - $current) {
throw new \OverflowException($message);
}
return $current + $delta;
}
}

View file

@ -1,8 +1,11 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\DiagnosticValue;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
@ -10,22 +13,26 @@ use Psr\Http\Message\ResponseInterface;
* Creates a composed Guzzle handler function by stacking middlewares on top of
* an HTTP handler function.
*
* @template THandler
*
* @final
*/
class HandlerStack
{
use NonSerializableTrait;
/**
* @var (callable(RequestInterface, array): PromiseInterface)|null
* @var (callable&THandler)|null
*/
private $handler;
/**
* @var array{(callable(callable(RequestInterface, array): PromiseInterface): callable), (string|null)}[]
* @var array<int, array{0: callable(callable&THandler): (callable&THandler), 1: string|null}>
*/
private $stack = [];
private array $stack = [];
/**
* @var (callable(RequestInterface, array): PromiseInterface)|null
* @var (callable&THandler)|null
*/
private $cached;
@ -33,22 +40,25 @@ class HandlerStack
* Creates a default handler stack that can be used by clients.
*
* The returned handler will wrap the provided handler or use the most
* appropriate default handler for your system. The returned HandlerStack has
* support for cookies, redirects, HTTP error exceptions, and preparing a body
* before sending.
* appropriate default handler for your system. The returned HandlerStack
* has support for authentication, cookies, redirects, HTTP error
* exceptions, and preparing a body before sending.
*
* The returned handler stack can be passed to a client in the "handler"
* option.
*
* @param (callable(RequestInterface, array): PromiseInterface)|null $handler HTTP handler function to use with the stack. If no
* handler is provided, the best handler for your
* system will be utilized.
* @param (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)|null $handler HTTP handler function to use with the stack. If no
* handler is provided, the best handler for your
* system will be utilized.
*
* @return self<callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>>
*/
public static function create(?callable $handler = null): self
{
$stack = new self($handler ?: Utils::chooseHandler());
$stack->push(Middleware::httpErrors(), 'http_errors');
$stack->push(Middleware::redirect(), 'allow_redirects');
$stack->push(Middleware::auth(), 'auth');
$stack->push(Middleware::cookies(), 'cookies');
$stack->push(Middleware::prepareBody(), 'prepare_body');
@ -56,7 +66,7 @@ class HandlerStack
}
/**
* @param (callable(RequestInterface, array): PromiseInterface)|null $handler Underlying HTTP handler.
* @param (callable&THandler)|null $handler Underlying handler.
*/
public function __construct(?callable $handler = null)
{
@ -66,50 +76,23 @@ class HandlerStack
/**
* Invokes the handler stack as a composed handler
*
* @return ResponseInterface|PromiseInterface
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(RequestInterface $request, array $options)
{
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) {
$handler = $this->resolve();
return $handler($request, $options);
}
/**
* Dumps a string representation of the stack.
*
* @return string
*/
public function __toString()
{
$depth = 0;
$stack = [];
if ($this->handler !== null) {
$stack[] = '0) Handler: '.$this->debugCallable($this->handler);
}
$result = '';
foreach (\array_reverse($this->stack) as $tuple) {
++$depth;
$str = "{$depth}) Name: '{$tuple[1]}', ";
$str .= 'Function: '.$this->debugCallable($tuple[0]);
$result = "> {$str}\n{$result}";
$stack[] = $str;
}
foreach (\array_keys($stack) as $k) {
$result .= "< {$stack[$k]}\n";
}
return $result;
}
/**
* Set the HTTP handler that actually returns a promise.
*
* @param callable(RequestInterface, array): PromiseInterface $handler Accepts a request and array of options and
* returns a Promise.
* @param callable&THandler $handler Accepts a request and array of options and returns a value expected by the stack.
*/
public function setHandler(callable $handler): void
{
@ -128,8 +111,8 @@ class HandlerStack
/**
* Unshift a middleware to the bottom of the stack.
*
* @param callable(callable): callable $middleware Middleware function
* @param string $name Name to register for this middleware.
* @param callable(callable&THandler): (callable&THandler) $middleware Middleware function
* @param string $name Name to register for this middleware.
*/
public function unshift(callable $middleware, ?string $name = null): void
{
@ -140,8 +123,8 @@ class HandlerStack
/**
* Push a middleware to the top of the stack.
*
* @param callable(callable): callable $middleware Middleware function
* @param string $name Name to register for this middleware.
* @param callable(callable&THandler): (callable&THandler) $middleware Middleware function
* @param string $name Name to register for this middleware.
*/
public function push(callable $middleware, string $name = ''): void
{
@ -152,9 +135,9 @@ class HandlerStack
/**
* Add a middleware before another middleware by name.
*
* @param string $findName Middleware to find
* @param callable(callable): callable $middleware Middleware function
* @param string $withName Name to register for this middleware.
* @param string $findName Middleware to find
* @param callable(callable&THandler): (callable&THandler) $middleware Middleware function
* @param string $withName Name to register for this middleware.
*/
public function before(string $findName, callable $middleware, string $withName = ''): void
{
@ -164,9 +147,9 @@ class HandlerStack
/**
* Add a middleware after another middleware by name.
*
* @param string $findName Middleware to find
* @param callable(callable): callable $middleware Middleware function
* @param string $withName Name to register for this middleware.
* @param string $findName Middleware to find
* @param callable(callable&THandler): (callable&THandler) $middleware Middleware function
* @param string $withName Name to register for this middleware.
*/
public function after(string $findName, callable $middleware, string $withName = ''): void
{
@ -176,12 +159,13 @@ class HandlerStack
/**
* Remove a middleware by instance or name from the stack.
*
* @param callable|string $remove Middleware to remove by instance or name.
* @param (callable(callable&THandler): (callable&THandler))|string $remove Middleware to remove by instance or name.
*/
public function remove($remove): void
{
if (!is_string($remove) && !is_callable($remove)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a callable or string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
if (!\is_string($remove) && !\is_callable($remove)) {
// TODO: Move this to the parameter definition in 9.0.
throw new \TypeError(__METHOD__.'(): Argument #1 ($remove) must be of type callable|string');
}
$this->cached = null;
@ -190,7 +174,7 @@ class HandlerStack
$count = \count($this->stack);
$this->stack = \array_values(\array_filter(
$this->stack,
static function ($tuple) use ($remove) {
static function (array $tuple) use ($remove): bool {
return $tuple[1] !== $remove;
}
));
@ -202,7 +186,7 @@ class HandlerStack
$this->stack = \array_values(\array_filter(
$this->stack,
static function ($tuple) use ($remove) {
static function (array $tuple) use ($remove): bool {
return $tuple[0] !== $remove;
}
));
@ -211,7 +195,7 @@ class HandlerStack
/**
* Compose the middleware and handler into a single callable function.
*
* @return callable(RequestInterface, array): PromiseInterface
* @return callable&THandler
*/
public function resolve(): callable
{
@ -220,9 +204,20 @@ class HandlerStack
throw new \LogicException('No handler has been specified');
}
if (!\is_callable($prev)) {
throw new \LogicException('Handler must be callable');
}
foreach (\array_reverse($this->stack) as $fn) {
/** @var callable(RequestInterface, array): PromiseInterface $prev */
if (!\is_array($fn) || !\array_key_exists(0, $fn) || !\is_callable($fn[0])) {
throw new \LogicException('Middleware must be callable');
}
$prev = $fn[0]($prev);
if (!\is_callable($prev)) {
throw new \LogicException('Middleware must return a callable');
}
}
$this->cached = $prev;
@ -231,6 +226,15 @@ class HandlerStack
return $this->cached;
}
public function __unserialize(array $data): void
{
$this->handler = null;
$this->stack = [];
$this->cached = null;
throw new \LogicException(static::class.' should never be unserialized');
}
private function findByName(string $name): int
{
foreach ($this->stack as $k => $v) {
@ -239,11 +243,13 @@ class HandlerStack
}
}
throw new \InvalidArgumentException("Middleware not found: $name");
throw new \InvalidArgumentException(\sprintf('Middleware not found: %s', DiagnosticValue::escape($name)));
}
/**
* Splices a function into the middleware list at a specific position.
*
* @param callable(callable&THandler): (callable&THandler) $middleware
*/
private function splice(string $findName, string $withName, callable $middleware, bool $before): void
{
@ -265,25 +271,4 @@ class HandlerStack
\array_splice($this->stack, $idx, 1, $replacement);
}
}
/**
* Provides a debug string for a given callable.
*
* @param callable|string $fn Function to write as a string.
*/
private function debugCallable($fn): string
{
if (\is_string($fn)) {
return "callable({$fn})";
}
if (\is_array($fn)) {
return \is_string($fn[0])
? "callable({$fn[0]}::{$fn[1]})"
: "callable(['".\get_class($fn[0])."', '{$fn[1]}'])";
}
/** @var object $fn */
return 'callable('.\spl_object_hash($fn).')';
}
}

View file

@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
/**
* Shared host identity normalization for features that key state on a
* logical host.
*
* @internal
*/
final class HostIdentity
{
/**
* Returns the identity form of a URI host. Valid bracketed IPv6 literals
* are canonicalized to their RFC 5952 form so equivalent spellings of one
* address share a single identity; IPvFuture literals, zone-bearing
* values, and invalid bracketed text fall back to ASCII case folding.
*/
public static function canonicalHost(string $host): string
{
if (\str_starts_with($host, '[') && \str_ends_with($host, ']')) {
try {
return '['.Psr7\Rfc3986::canonicalizeIpv6(\substr($host, 1, -1)).']';
} catch (\InvalidArgumentException $e) {
// Fall back to case folding below.
}
}
return Psr7\Utils::asciiToLower($host);
}
/**
* Returns the identity form of a cookie domain. In addition to the
* canonicalHost() rules, valid bare IPv6 addresses, which the cookie API
* permissively accepts even though a URI host requires brackets, are
* canonicalized to their RFC 5952 form without gaining brackets, so bare
* and bracketed forms remain distinct identities.
*/
public static function canonicalCookieDomain(string $domain): string
{
if (!\str_starts_with($domain, '[') && \strpos($domain, ':') !== false) {
try {
return Psr7\Rfc3986::canonicalizeIpv6($domain);
} catch (\InvalidArgumentException $e) {
return Psr7\Utils::asciiToLower($domain);
}
}
return self::canonicalHost($domain);
}
/**
* Whether a request domain matches a cookie domain according to RFC 6265.
*
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3
*/
public static function cookieDomainMatches(string $domain, string $cookieDomain): bool
{
$domain = self::canonicalCookieDomain($domain);
$cookieDomain = self::canonicalCookieDomain($cookieDomain);
if ($domain === $cookieDomain) {
return true;
}
// A percent-escaped cookie domain can decode to another host spelling.
// Keep it exact-match-only; decoding a request host cannot create a
// suffix match that its original text lacked.
if (\strpos($cookieDomain, '%') !== false) {
return false;
}
if (!self::isDnsSuffixEligible($domain) || !self::isDnsSuffixEligible($cookieDomain)) {
return false;
}
return \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/D', $domain) === 1;
}
/**
* Returns the identity form of a Host header value. A single bracketed
* `host[:port]` authority, where any explicit port must be a valid decimal
* port number, has its IPv6 literal canonicalized like canonicalHost() with
* the port text retained; every other value, including malformed and
* multiple values, falls back to ASCII case folding of the raw text.
*/
public static function canonicalHostHeader(string $header): string
{
if (\str_starts_with($header, '[')) {
$end = \strpos($header, ']');
if ($end !== false) {
$rest = \substr($header, $end + 1);
if ($rest === '' || (\str_starts_with($rest, ':') && Psr7\Rfc3986::isValidPort(\substr($rest, 1)))) {
return self::canonicalHost(\substr($header, 0, $end + 1)).$rest;
}
}
}
return Psr7\Utils::asciiToLower($header);
}
/**
* Whether the host may participate in RFC 6265 DNS suffix matching. IP
* literals, IP addresses, numeric hosts, literal-like text containing a
* raw bracket or colon, and invalid host text are exact-match-only.
*/
private static function isDnsSuffixEligible(string $host): bool
{
if (\strpbrk($host, '[]:') !== false) {
return false;
}
if (!Psr7\Rfc3986::isValidHost($host)) {
return false;
}
return !self::isIpAddressOrNumericHost($host);
}
private static function isIpAddressOrNumericHost(string $host): bool
{
// Strip one root dot before detection so trailing-dot numeric hosts
// still cannot be matched by subdomains.
if ($host !== '' && \str_ends_with($host, '.')) {
$host = \substr($host, 0, -1);
}
if (\str_starts_with($host, '[') && \str_ends_with($host, ']')) {
$host = \substr($host, 1, -1);
}
if (\filter_var($host, \FILTER_VALIDATE_IP) !== false) {
return true;
}
// Public DNS names do not have an all-numeric rightmost label; treat
// those private/internal hosts as exact-match-only too.
$labels = \explode('.', $host);
$last = (string) \end($labels);
if ($last !== '' && \ctype_digit($last)) {
return true;
}
// libcurl also parses a 0x-prefixed hexadecimal rightmost label as a
// numerical IPv4 address, such as 0x7f000001 for 127.0.0.1.
return \str_starts_with($last, '0x') && \strlen($last) > 2 && \ctype_xdigit(\substr($last, 2));
}
/**
* Returns the four-byte binary form of a host that a transport reads as a
* numeric IPv4 address, or null when it reads it as a name.
*
* The shape test is Handler\HostValidator::isNumericIpv4Host(); this
* method adds the range checks that predicate omits: every part but the
* last must fit one octet, and the last must fit the octets the earlier
* parts left. A trailing root dot is not swallowed, unlike libcurl 8.21.0
* and later, because assertRequestHost() rejects that spelling first.
*/
public static function numericIpv4ToBinary(string $host): ?string
{
if (!Handler\HostValidator::isNumericIpv4Host($host)) {
return null;
}
$values = [];
foreach (\explode('.', $host) as $part) {
$values[] = self::numericIpv4PartValue($part);
}
// Every accepted value is a whole number no larger than 0xFFFFFFFF,
// which a float holds exactly, so the arithmetic below is correct on a
// 32-bit build too, where the widest part overflows an integer.
$address = (float) \array_pop($values);
$packed = '';
foreach ($values as $value) {
if ($value > 255.0) {
return null;
}
$packed .= \chr((int) $value);
}
$width = 4 - \count($values);
if ($address >= 256.0 ** $width) {
return null;
}
for ($shift = $width - 1; $shift >= 0; --$shift) {
$packed .= \chr((int) \fmod(\floor($address / 256.0 ** $shift), 256.0));
}
return $packed;
}
/**
* Returns the value of one accepted part as a float, so a part filling
* all four octets such as 2130706433 stays exact on every integer width.
*/
private static function numericIpv4PartValue(string $part): float
{
if ($part[0] === '0' && isset($part[1]) && ($part[1] === 'x' || $part[1] === 'X')) {
return (float) \hexdec((string) \substr($part, 2));
}
if ($part[0] === '0') {
return (float) \octdec($part);
}
return (float) $part;
}
private function __construct()
{
// cannot be instantiated
}
}

87
vendor/guzzlehttp/guzzle/src/Idn.php vendored Normal file
View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Exception\InvalidArgumentException;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
final class Idn
{
private function __construct()
{
}
/**
* @param mixed $value
*/
public static function normalizeConversionOption($value): ?int
{
if ($value === null || $value === false) {
return null;
}
if ($value === true) {
return \IDNA_DEFAULT;
}
if (\is_int($value)) {
return $value;
}
throw new InvalidArgumentException('idn_conversion must be true, false, null, or an integer IDNA_* bitmask');
}
/**
* @throws InvalidArgumentException
*/
public static function convertUri(UriInterface $uri, int $options = 0): UriInterface
{
if ($uri->getHost()) {
$asciiHost = self::idnToAsci($uri->getHost(), $options, $info);
if ($asciiHost === false) {
$errorBitSet = $info['errors'] ?? 0;
$errorConstants = array_filter(array_keys(get_defined_constants()), static function (string $name): bool {
return substr($name, 0, 11) === 'IDNA_ERROR_';
});
$errors = [];
foreach ($errorConstants as $errorConstant) {
if ($errorBitSet & constant($errorConstant)) {
$errors[] = $errorConstant;
}
}
$errorMessage = 'IDN conversion failed';
if ($errors) {
$errorMessage .= ' (errors: '.implode(', ', $errors).')';
}
throw new InvalidArgumentException($errorMessage);
}
if ($uri->getHost() !== $asciiHost) {
// Replace URI only if the ASCII version is different
$uri = $uri->withHost($asciiHost);
}
}
return $uri;
}
/**
* @return string|false
*/
private static function idnToAsci(string $domain, int $options, ?array &$info = [])
{
if (\function_exists('idn_to_ascii') && \defined('INTL_IDNA_VARIANT_UTS46')) {
return \idn_to_ascii($domain, $options, \INTL_IDNA_VARIANT_UTS46, $info);
}
throw new \Error('ext-idn or symfony/polyfill-intl-idn not loaded or too old');
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\MessageInterface;
@ -26,16 +28,16 @@ use Psr\Http\Message\ResponseInterface;
* - {code}: Status code of the response (if available)
* - {phrase}: Reason phrase of the response (if available)
* - {error}: Any error messages (if available)
* - {req_header_*}: Replace `*` with the lowercased name of a request header to add to the message
* - {res_header_*}: Replace `*` with the lowercased name of a response header to add to the message
* - {req_header_*}: Replace `*` with the lowercased name of a request header
* to add to the message
* - {res_header_*}: Replace `*` with the lowercased name of a response header
* to add to the message
* - {req_headers}: Request headers
* - {res_headers}: Response headers
* - {req_body}: Request body
* - {res_body}: Response body
*
* @final
*/
class MessageFormatter implements MessageFormatterInterface
final class MessageFormatter implements MessageFormatterInterface
{
/**
* Apache Common Log Format.
@ -51,7 +53,7 @@ class MessageFormatter implements MessageFormatterInterface
/**
* @var string Template used to format log messages
*/
private $template;
private string $template;
/**
* @param string $template Log message template
@ -72,10 +74,9 @@ class MessageFormatter implements MessageFormatterInterface
{
$cache = [];
/** @var string */
return \preg_replace_callback(
$result = \preg_replace_callback(
'/{\s*([A-Za-z_\-\.0-9]+)\s*}/',
function (array $matches) use ($request, $response, $error, &$cache) {
function (array $matches) use ($request, $response, $error, &$cache): string {
if (isset($cache[$matches[1]])) {
return $cache[$matches[1]];
}
@ -90,7 +91,7 @@ class MessageFormatter implements MessageFormatterInterface
break;
case 'req_headers':
$result = \trim($request->getMethod()
.' '.$request->getRequestTarget())
.' '.$request->getRequestTarget(), " \n\r\t\0\x0B")
.' HTTP/'.$request->getProtocolVersion()."\r\n"
.$this->headers($request);
break;
@ -176,12 +177,19 @@ class MessageFormatter implements MessageFormatterInterface
}
}
$result = (string) $result;
$cache[$matches[1]] = $result;
return $result;
},
$this->template
);
if ($result === null) {
throw new \RuntimeException('Unable to format message: '.\preg_last_error_msg());
}
return $result;
}
/**
@ -194,6 +202,6 @@ class MessageFormatter implements MessageFormatterInterface
$result .= $name.': '.\implode(', ', $values)."\r\n";
}
return \trim($result);
return \trim($result, " \n\r\t\0\x0B");
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\RequestInterface;

View file

@ -1,9 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
@ -15,18 +18,42 @@ use Psr\Log\LoggerInterface;
*/
final class Middleware
{
private function __construct()
{
}
/**
* Middleware that applies built-in Basic authentication and handles Digest
* authentication challenges when the "auth" request option is set.
*
* @param bool $reuseChallenges Whether Digest challenges may be reused preemptively.
*
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function auth(bool $reuseChallenges = true): callable
{
return static function (callable $handler) use ($reuseChallenges): AuthMiddleware {
return new AuthMiddleware($handler, null, $reuseChallenges);
};
}
/**
* Middleware that adds cookies to requests.
*
* The options array must be set to a CookieJarInterface in order to use
* cookies. This is typically handled for you by a client.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function cookies(): callable
{
return static function (callable $handler): callable {
return static function ($request, array $options) use ($handler) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler): PromiseInterface {
if (empty($options['cookies'])) {
return $handler($request, $options);
} elseif (!$options['cookies'] instanceof CookieJarInterface) {
@ -37,7 +64,10 @@ final class Middleware
return $handler($request, $options)
->then(
static function (ResponseInterface $response) use ($cookieJar, $request): ResponseInterface {
static function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($cookieJar, $request): ResponseInterface {
$cookieJar->extractCookies($request, $response);
return $response;
@ -53,23 +83,31 @@ final class Middleware
*
* @param BodySummarizerInterface|null $bodySummarizer The body summarizer to use in exception messages.
*
* @return callable(callable): callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function httpErrors(?BodySummarizerInterface $bodySummarizer = null): callable
{
return static function (callable $handler) use ($bodySummarizer): callable {
return static function ($request, array $options) use ($handler, $bodySummarizer) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, $bodySummarizer): PromiseInterface {
if (empty($options['http_errors'])) {
return $handler($request, $options);
}
return $handler($request, $options)->then(
static function (ResponseInterface $response) use ($request, $bodySummarizer) {
static function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($request, $bodySummarizer): ResponseInterface {
$code = $response->getStatusCode();
if ($code < 400) {
return $response;
}
throw RequestException::create($request, $response, null, [], $bodySummarizer);
throw RequestException::create($request, $response, null, $bodySummarizer);
}
);
};
@ -79,9 +117,9 @@ final class Middleware
/**
* Middleware that pushes history data to an ArrayAccess container.
*
* @param array|\ArrayAccess<int, array> $container Container to hold the history (by reference).
* @param array<array-key, array{request: RequestInterface, response: ResponseInterface|null, error: mixed, options: array<array-key, mixed>}>|\ArrayAccess<int, array{request: RequestInterface, response: ResponseInterface|null, error: mixed, options: array<array-key, mixed>}> $container Container to hold the history (by reference).
*
* @return callable(callable): callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*
* @throws \InvalidArgumentException if container is not an array or ArrayAccess.
*/
@ -92,9 +130,17 @@ final class Middleware
}
return static function (callable $handler) use (&$container): callable {
return static function (RequestInterface $request, array $options) use ($handler, &$container) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, &$container): PromiseInterface {
return $handler($request, $options)->then(
static function ($value) use ($request, &$container, $options) {
static function (
#[\SensitiveParameter]
ResponseInterface $value
) use ($request, &$container, $options): ResponseInterface {
$container[] = [
'request' => $request,
'response' => $value,
@ -104,7 +150,10 @@ final class Middleware
return $value;
},
static function ($reason) use ($request, &$container, $options) {
static function (
#[\SensitiveParameter]
$reason
) use ($request, &$container, $options): PromiseInterface {
$container[] = [
'request' => $request,
'response' => null,
@ -120,22 +169,29 @@ final class Middleware
}
/**
* Middleware that invokes a callback before and after sending a request.
* Middleware that observes requests and responses as they flow through the
* stack without modifying them. This is useful for metrics, tracing, and
* debugging.
*
* The provided listener cannot modify or alter the response. It simply
* "taps" into the chain to be notified before returning the promise. The
* before listener accepts a request and options array, and the after
* listener accepts a request, options array, and response promise.
*
* @param callable $before Function to invoke before forwarding the request.
* @param callable $after Function invoked after forwarding.
* @param (callable(RequestInterface, array<array-key, mixed>): mixed)|null $before Function to invoke before forwarding the request.
* @param (callable(RequestInterface, array<array-key, mixed>, PromiseInterface<ResponseInterface, mixed>): mixed)|null $after Function invoked after forwarding.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function tap(?callable $before = null, ?callable $after = null): callable
{
return static function (callable $handler) use ($before, $after): callable {
return static function (RequestInterface $request, array $options) use ($handler, $before, $after) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, $before, $after): PromiseInterface {
if ($before) {
$before($request, $options);
}
@ -152,7 +208,7 @@ final class Middleware
/**
* Middleware that handles request redirects.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function redirect(): callable
{
@ -168,13 +224,14 @@ final class Middleware
* If no delay function is provided, a simple implementation of exponential
* backoff will be utilized.
*
* @param callable $decider Function that accepts the number of retries,
* a request, [response], and [exception] and
* returns true if the request is to be retried.
* @param callable $delay Function that accepts the number of retries and
* returns the number of milliseconds to delay.
* @param callable(int, RequestInterface, ResponseInterface|null, mixed): bool $decider Function that accepts the number of retries,
* a request, [response], and [rejection reason]
* and returns true if the request is to be retried.
* @param (callable(int, ResponseInterface|null, RequestInterface): int)|null $delay Function that accepts the number of retries,
* [response], and request, and returns the
* number of milliseconds to delay.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function retry(callable $decider, ?callable $delay = null): callable
{
@ -187,32 +244,39 @@ final class Middleware
* Middleware that logs requests, responses, and errors using a message
* formatter.
*
* @param LoggerInterface $logger Logs messages.
* @param MessageFormatterInterface|MessageFormatter $formatter Formatter used to create message strings.
* @param string $logLevel Level at which to log requests.
* @param LoggerInterface $logger Logs messages.
* @param MessageFormatterInterface $formatter Formatter used to create message strings.
* @param string $logLevel Level at which to log requests.
*
* @phpstan-param \Psr\Log\LogLevel::* $logLevel Level at which to log requests.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function log(LoggerInterface $logger, $formatter, string $logLevel = 'info'): callable
public static function log(LoggerInterface $logger, MessageFormatterInterface $formatter, string $logLevel = 'info'): callable
{
// To be compatible with Guzzle 7.1.x we need to allow users to pass a MessageFormatter
if (!$formatter instanceof MessageFormatter && !$formatter instanceof MessageFormatterInterface) {
throw new \LogicException(sprintf('Argument 2 to %s::log() must be of type %s', self::class, MessageFormatterInterface::class));
}
return static function (callable $handler) use ($logger, $formatter, $logLevel): callable {
return static function (RequestInterface $request, array $options = []) use ($handler, $logger, $formatter, $logLevel) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options = []
) use ($handler, $logger, $formatter, $logLevel): PromiseInterface {
return $handler($request, $options)->then(
static function ($response) use ($logger, $request, $formatter, $logLevel): ResponseInterface {
static function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($logger, $request, $formatter, $logLevel): ResponseInterface {
$message = $formatter->format($request, $response);
$logger->log($logLevel, $message);
return $response;
},
static function ($reason) use ($logger, $request, $formatter): PromiseInterface {
$response = $reason instanceof RequestException ? $reason->getResponse() : null;
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
static function (
#[\SensitiveParameter]
$reason
) use ($logger, $request, $formatter): PromiseInterface {
$response = $reason instanceof ResponseException ? $reason->getResponse() : null;
$message = $formatter->format($request, $response, P\Create::exceptionFor($reason));
$logger->error($message);
@ -226,6 +290,8 @@ final class Middleware
/**
* This middleware adds a default content-type if possible, a default
* content-length or transfer-encoding header, and the expect header.
*
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function prepareBody(): callable
{
@ -238,13 +304,20 @@ final class Middleware
* Middleware that applies a map function to the request before passing to
* the next handler.
*
* @param callable $fn Function that accepts a RequestInterface and returns
* a RequestInterface.
* @param callable(RequestInterface): RequestInterface $fn Function that accepts a RequestInterface and returns
* a RequestInterface.
*
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function mapRequest(callable $fn): callable
{
return static function (callable $handler) use ($fn): callable {
return static function (RequestInterface $request, array $options) use ($handler, $fn) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, $fn): PromiseInterface {
return $handler($fn($request), $options);
};
};
@ -254,13 +327,20 @@ final class Middleware
* Middleware that applies a map function to the resolved promise's
* response.
*
* @param callable $fn Function that accepts a ResponseInterface and
* returns a ResponseInterface.
* @param callable(ResponseInterface): ResponseInterface $fn Function that accepts a ResponseInterface and
* returns a ResponseInterface.
*
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function mapResponse(callable $fn): callable
{
return static function (callable $handler) use ($fn): callable {
return static function (RequestInterface $request, array $options) use ($handler, $fn) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, $fn): PromiseInterface {
return $handler($request, $options)->then($fn);
};
};

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
/**
* Multiplexing modes for the "multiplex" request option.
*
* NONE disables multiplexing for a whole handler when passed as the
* "multiplex" client configuration option or, when constructing a handler
* directly, as the CurlMultiHandler "multiplex" constructor option. As a
* request option value it guarantees the transfer does not share its
* connection with any concurrent transfer, and is accepted only where that
* guarantee holds.
*/
final class Multiplexing
{
public const NONE = 'none';
public const EAGER = 'eager';
public const WAIT = 'wait';
public const REQUIRE_EAGER = 'require_eager';
public const REQUIRE_WAIT = 'require_wait';
private function __construct()
{
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
/**
* @internal
*/
trait NonSerializableTrait
{
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

@ -1,45 +1,138 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Promise\EachPromise;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\PromisorInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
/**
* Sends an iterator of requests concurrently using a capped pool size.
*
* The pool will read from an iterator until it is cancelled or until the
* iterator is consumed. When a request is yielded, the request is sent after
* applying the "request_options" request options (if provided in the ctor).
* applying the "options" request options (if provided in the ctor). Any
* observer callbacks in "options" (on_headers, on_trailers, on_stats,
* progress, and allow_redirects.on_redirect) also receive the request's
* iterable key as a trailing argument.
*
* When a function is yielded by the iterator, the function is provided the
* "request_options" array that should be merged on top of any existing
* options, and the function MUST then return a wait-able promise.
* "options" array that should be merged on top of any existing options, and
* the function MUST then return a response or a wait-able response promise.
*
* @final
*
* @implements PromisorInterface<mixed, mixed>
*/
class Pool implements PromisorInterface
{
/**
* @var EachPromise
*/
private $each;
use NonSerializableTrait;
/**
* @param ClientInterface $client Client used to send the requests.
* @param array|\Iterator $requests Requests or functions that return
* requests to send concurrently.
* @param array $config Associative array of options
* - concurrency: (int) Maximum number of requests to send concurrently
* - options: Array of request options to apply to each request.
* - fulfilled: (callable) Function to invoke when a request completes.
* - rejected: (callable) Function to invoke when a request is rejected.
* @var EachPromise<array-key, ResponseInterface, mixed>
*/
public function __construct(ClientInterface $client, $requests, array $config = [])
{
private EachPromise $each;
/**
* @param ClientInterface $client Client used to send the requests.
* @param iterable<array-key, RequestInterface|callable(array<array-key, mixed>): (ResponseInterface|PromiseInterface<ResponseInterface, mixed>)> $requests Requests or functions that return responses or response promises.
* @param array{
* concurrency?: int|(callable(int): int),
* options?: array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface, int|string): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface, int|string): mixed,
* on_stats?: callable(TransferStats, int|string): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface, int|string): mixed,
* progress?: callable(int, int, int, int, int|string): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* },
* fulfilled?: callable(ResponseInterface, int|string, PromiseInterface<mixed, mixed>): mixed,
* rejected?: callable(mixed, int|string, PromiseInterface<mixed, mixed>): mixed
* } $config Pool configuration.
*/
public function __construct(
ClientInterface $client,
iterable $requests,
#[\SensitiveParameter]
array $config = []
) {
if (!isset($config['concurrency'])) {
$config['concurrency'] = 25;
}
@ -51,36 +144,27 @@ class Pool implements PromisorInterface
$opts = [];
}
if (!\is_iterable($requests)) {
\trigger_deprecation(
'guzzlehttp/guzzle',
'7.11',
'Passing a non-iterable request collection to %s::__construct() or %s::batch() is deprecated; guzzlehttp/guzzle 8.0 will require an iterable.',
__CLASS__,
__CLASS__
);
$requestGenerator = static function () use ($requests, $client, $opts): \Generator {
foreach ($requests as $key => $rfn) {
$keyedOpts = self::keyedRequestOptions($opts, $key);
$requests = [$requests];
}
$iterable = P\Create::iterFor($requests);
$requests = static function () use ($iterable, $client, $opts) {
foreach ($iterable as $key => $rfn) {
if ($rfn instanceof RequestInterface) {
yield $key => $client->sendAsync($rfn, $opts);
yield $key => $client->sendAsync($rfn, $keyedOpts);
} elseif (\is_callable($rfn)) {
yield $key => $rfn($opts);
yield $key => $rfn($keyedOpts);
} else {
throw new \InvalidArgumentException('Each value yielded by the iterator must be a Psr7\Http\Message\RequestInterface or a callable that returns a promise that fulfills with a Psr7\Message\Http\ResponseInterface object.');
throw new \InvalidArgumentException('Each value yielded by the iterator must be a Psr\Http\Message\RequestInterface or a callable that returns a promise that fulfills with a Psr\Http\Message\ResponseInterface object.');
}
}
};
$this->each = new EachPromise($requests(), $config);
$this->each = new EachPromise($requestGenerator(), $config);
}
/**
* Get promise
*
* @return PromiseInterface<mixed, mixed>
*/
public function promise(): PromiseInterface
{
@ -95,18 +179,98 @@ class Pool implements PromisorInterface
* as such, is NOT recommended when sending a large number or an
* indeterminate number of requests concurrently.
*
* @param ClientInterface $client Client used to send the requests
* @param array|\Iterator $requests Requests to send concurrently.
* @param array $options Passes through the options available in
* {@see Pool::__construct}
* @param ClientInterface $client Client used to send the requests
* @param iterable<array-key, RequestInterface|callable(array<array-key, mixed>): (ResponseInterface|PromiseInterface<ResponseInterface, mixed>)> $requests Requests or functions that return responses or response promises.
* @param array{
* concurrency?: int|(callable(int): int),
* options?: array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface, int|string): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface, int|string): mixed,
* on_stats?: callable(TransferStats, int|string): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface, int|string): mixed,
* progress?: callable(int, int, int, int, int|string): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* },
* fulfilled?: callable(ResponseInterface, int|string): mixed,
* rejected?: callable(mixed, int|string): mixed
* } $options Passes through the options available in {@see Pool::__construct}.
*
* @return array Returns an array containing the response or an exception
* in the same order that the requests were sent.
* @return array<array-key, mixed> Returns an array containing the response or rejection reason in the same order that the requests were sent.
*
* @throws \InvalidArgumentException if the event format is incorrect.
*/
public static function batch(ClientInterface $client, $requests, array $options = []): array
{
public static function batch(
ClientInterface $client,
iterable $requests,
#[\SensitiveParameter]
array $options = []
): array {
$res = [];
self::cmpCallback($options, 'fulfilled', $res);
self::cmpCallback($options, 'rejected', $res);
@ -123,15 +287,238 @@ class Pool implements PromisorInterface
private static function cmpCallback(array &$options, string $name, array &$results): void
{
if (!isset($options[$name])) {
$options[$name] = static function ($v, $k) use (&$results) {
$options[$name] = static function (
#[\SensitiveParameter]
$v,
$k
) use (&$results): void {
$results[$k] = $v;
};
} else {
$currentFn = $options[$name];
$options[$name] = static function ($v, $k) use (&$results, $currentFn) {
$options[$name] = static function (
#[\SensitiveParameter]
$v,
$k
) use (&$results, $currentFn): void {
$currentFn($v, $k);
$results[$k] = $v;
};
}
}
/**
* Returns the request options with any observer callbacks wrapped so that
* they also receive the request's iterable key as a trailing argument.
*
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface, int|string): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface, int|string): mixed,
* on_stats?: callable(TransferStats, int|string): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface, int|string): mixed,
* progress?: callable(int, int, int, int, int|string): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options
* @param int|string $key
*
* @return array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* }
*/
private static function keyedRequestOptions(array $options, $key): array
{
if (\is_array($options['allow_redirects'] ?? null)
&& \is_callable($options['allow_redirects']['on_redirect'] ?? null)
) {
$onRedirect = $options['allow_redirects']['on_redirect'];
$options['allow_redirects']['on_redirect'] = static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
UriInterface $uri
) use ($onRedirect, $key): void {
$onRedirect($request, $response, $uri, $key);
};
}
if (\is_callable($options['on_headers'] ?? null)) {
$onHeaders = $options['on_headers'];
$options['on_headers'] = static function (
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
RequestInterface $request
) use ($onHeaders, $key): void {
$onHeaders($response, $request, $key);
};
}
if (\is_callable($options['on_stats'] ?? null)) {
$onStats = $options['on_stats'];
$options['on_stats'] = static function (TransferStats $stats) use ($onStats, $key): void {
$onStats($stats, $key);
};
}
if (\is_callable($options['on_trailers'] ?? null)) {
$onTrailers = $options['on_trailers'];
$options['on_trailers'] = static function (
array $trailers,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
RequestInterface $request
) use ($onTrailers, $key): void {
$onTrailers($trailers, $response, $request, $key);
};
}
if (\is_callable($options['progress'] ?? null)) {
$progress = $options['progress'];
$options['progress'] = static function (int $downloadTotal, int $downloadedBytes, int $uploadTotal, int $uploadedBytes) use ($progress, $key) {
return $progress($downloadTotal, $downloadedBytes, $uploadTotal, $uploadedBytes, $key);
};
}
return $options;
}
}

View file

@ -1,9 +1,13 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Handler\RequestFraming;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Prepares requests that contain a body, adding the Content-Length,
@ -13,25 +17,35 @@ use Psr\Http\Message\RequestInterface;
*/
class PrepareBodyMiddleware
{
use NonSerializableTrait;
/**
* @var callable(RequestInterface, array): PromiseInterface
* @var callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private $nextHandler;
/**
* @param callable(RequestInterface, array): PromiseInterface $nextHandler Next handler to invoke.
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $nextHandler Next handler to invoke.
*/
public function __construct(callable $nextHandler)
{
$this->nextHandler = $nextHandler;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
$fn = $this->nextHandler;
$bodySize = RequestFraming::bodySize($request);
// Don't do anything if the request has no body.
if ($request->getBody()->getSize() === 0) {
if ($bodySize === 0) {
return $fn($request, $options);
}
@ -50,16 +64,15 @@ class PrepareBodyMiddleware
if (!$request->hasHeader('Content-Length')
&& !$request->hasHeader('Transfer-Encoding')
) {
$size = $request->getBody()->getSize();
if ($size !== null) {
$modify['set_headers']['Content-Length'] = (string) $size;
} else {
if ($bodySize !== null) {
$modify['set_headers']['Content-Length'] = (string) $bodySize;
} elseif ($request->getProtocolVersion() === '1.1') {
$modify['set_headers']['Transfer-Encoding'] = 'chunked';
}
}
// Add the expect header if needed.
$this->addExpectHeader($request, $options, $modify);
$this->addExpectHeader($request, $options, $modify, $bodySize);
return $fn(Psr7\Utils::modifyRequest($request, $modify), $options);
}
@ -67,8 +80,14 @@ class PrepareBodyMiddleware
/**
* Add expect header
*/
private function addExpectHeader(RequestInterface $request, array $options, array &$modify): void
{
private function addExpectHeader(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
array &$modify,
?int $bodySize
): void {
// Determine if the Expect header should be used
if ($request->hasHeader('Expect')) {
return;
@ -76,8 +95,8 @@ class PrepareBodyMiddleware
$expect = $options['expect'] ?? null;
// Return if disabled or using HTTP/1.0
if ($expect === false || $request->getProtocolVersion() === '1.0') {
// Return if disabled or not using HTTP/1.1.
if ($expect === false || '1.1' !== $request->getProtocolVersion()) {
return;
}
@ -96,9 +115,8 @@ class PrepareBodyMiddleware
// Always add if the body cannot be rewound, the size cannot be
// determined, or the size is greater than the cutoff threshold
$body = $request->getBody();
$size = $body->getSize();
if ($size === null || $size >= (int) $expect || !$body->isSeekable()) {
if ($bodySize === null || $bodySize >= (int) $expect || !$body->isSeekable()) {
$modify['set_headers']['Expect'] = '100-Continue';
}
}

View file

@ -0,0 +1,603 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Psr7\Rfc3986;
use Psr\Http\Message\UriInterface;
final class ProxyOptions
{
private function __construct()
{
}
/**
* Resolve Guzzle's documented proxy request option for a request URI.
*
* @param mixed $proxy Proxy option as passed via request transfer options.
*
* @throws InvalidArgumentException
*/
public static function resolve(
UriInterface $uri,
#[\SensitiveParameter]
$proxy
): ProxySelection {
if ($proxy === null) {
return ProxySelection::none();
}
if (!\is_array($proxy)) {
if (!\is_string($proxy)) {
throw new InvalidArgumentException('proxy must be a string or array');
}
return ProxySelection::proxy($proxy);
}
$schemeProxy = $proxy[$uri->getScheme()] ?? null;
if ($schemeProxy !== null && !\is_string($schemeProxy)) {
throw new InvalidArgumentException('proxy values must be strings');
}
// A matching "no" entry is always a final decision, even when the
// array selects no proxy for the request scheme. Without this, an
// option-level bypass could fall through to handler-level environment
// fallback and route through a proxy the user excluded.
$noProxy = isset($proxy['no']) ? self::normalizeNoProxy($proxy['no']) : [];
if ($noProxy !== [] && self::isUriInNoProxy($uri, $noProxy)) {
return ProxySelection::bypassed();
}
if ($schemeProxy === null) {
return ProxySelection::none();
}
return ProxySelection::proxy($schemeProxy);
}
/**
* Validate a proxy URL and return its lowercased scheme.
*
* A proxy is an authority ([userinfo@]host[:port]) with an optional scheme;
* a scheme-less value is an HTTP proxy. The scheme is matched anchored at
* the start, so leading junk before it is rejected as malformed, and the
* host and port grammar is delegated to Psr7\Rfc3986. The whole string is
* validated up front so a malformed proxy fails the same way on every
* handler, but the original value is what callers pin, so no normalization
* reaches the wire. The error message never includes the proxy, which may
* carry credentials.
*
* @throws InvalidArgumentException on a malformed proxy URL
*/
public static function proxyScheme(
#[\SensitiveParameter]
string $proxy
): string {
$parts = \explode('://', $proxy, 2);
if (\count($parts) === 1) {
$scheme = 'http';
$authority = $proxy;
} else {
[$scheme, $authority] = $parts;
$scheme = Psr7\Utils::asciiToLower($scheme);
if ($scheme === '' || !Rfc3986::isValidScheme($scheme)) {
throw new InvalidArgumentException('Invalid proxy URL.');
}
}
if (!self::isValidProxyAuthority($authority)) {
throw new InvalidArgumentException('Invalid proxy URL.');
}
return $scheme;
}
/**
* Whether the string is a valid proxy authority: [userinfo@]host[:port].
*
* A single trailing slash is tolerated; anything else after the authority
* (a path, query, or fragment) is rejected. Userinfo is not policed here
* (the proxy handles its own credentials); host and port grammar are
* delegated to Psr7\Rfc3986, where a zero port is accepted and left for the
* transport to handle as it did before.
*/
private static function isValidProxyAuthority(string $authority): bool
{
// A single trailing slash is tolerated; once removed, a proxy authority
// ([userinfo@]host[:port]) cannot contain a path, query, or fragment
// delimiter anywhere — including one before a later '@'.
if (\str_ends_with($authority, '/')) {
$authority = \substr($authority, 0, \strlen($authority) - 1);
}
if ($authority === '' || \strpbrk($authority, '/?#') !== false) {
return false;
}
// The host cannot contain '@', so userinfo is everything before the
// last one. It is not validated here. A value that is only userinfo,
// with nothing after the last '@', has no host and is rejected.
$segments = \explode('@', $authority);
$authority = $segments[\count($segments) - 1];
if ($authority === '') {
return false;
}
$port = null;
if (\str_starts_with($authority, '[')) {
$parts = \explode(']', $authority, 2);
if (\count($parts) !== 2) {
return false;
}
$host = $parts[0].']';
$remainder = $parts[1];
if ($remainder !== '') {
if (!\str_starts_with($remainder, ':')) {
return false;
}
[, $port] = \explode(':', $remainder, 2);
}
} elseif (\strpos($authority, ':') !== false) {
[$host, $port] = \explode(':', $authority, 2);
} else {
$host = $authority;
}
// A dangling "host:" carries no port, matching the previous behavior.
if ($port === '') {
$port = null;
}
if ($host === '' || !Rfc3986::isValidHost($host)) {
return false;
}
return $port === null || Rfc3986::isValidPort($port);
}
/**
* Normalize a no-proxy list from request options or NO_PROXY.
*
* @param mixed $noProxy No-proxy value as passed via request transfer options.
*
* @return string[]
*
* @throws InvalidArgumentException
*/
public static function normalizeNoProxy($noProxy): array
{
if ($noProxy === null) {
return [];
}
if (\is_string($noProxy)) {
// Entries may be separated by whitespace as well as commas,
// matching the no_proxy environment variable conventions.
$noProxy = \preg_split('/[\s,]+/', $noProxy);
if ($noProxy === false) {
throw new \RuntimeException('Unable to split the proxy no list: '.\preg_last_error_msg());
}
} elseif (!\is_array($noProxy)) {
throw new InvalidArgumentException('proxy no list must be null, a string, or an array of strings');
}
$result = [];
foreach ($noProxy as $area) {
if (!\is_string($area)) {
throw new InvalidArgumentException('proxy no list must be null, a string, or an array of strings');
}
$area = \trim($area, " \n\r\t\0\x0B");
if ($area !== '') {
$result[] = $area;
}
}
return $result;
}
/**
* Returns true if the provided URI matches any of the no-proxy areas.
*
* @param string[] $noProxy An array of host, host-and-port, or CIDR patterns.
*
* @throws InvalidArgumentException
*/
public static function isUriInNoProxy(UriInterface $uri, array $noProxy): bool
{
self::assertNoProxyList($noProxy);
$target = self::parseNoProxyTarget($uri);
if ($target === null) {
return false;
}
foreach ($noProxy as $area) {
$area = \trim($area, " \n\r\t\0\x0B");
if ($area === '*') {
return true;
}
$rule = self::parseNoProxyRule($area);
if ($rule !== null && self::noProxyRuleMatches($target, $rule)) {
return true;
}
}
return false;
}
/**
* Returns true if the provided host matches any of the no-proxy areas.
*
* 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.
*
* Areas are matched in the following cases:
* 1. "*" (without quotes) always matches any hosts.
* 2. An exact domain or IP literal match.
* 3. A bare domain or a leading-dot domain matches itself and its
* subdomains. e.g. 'mit.edu' and '.mit.edu' both match 'mit.edu'
* and 'foo.mit.edu'.
* 4. IP CIDR entries match IP literal hosts. e.g. '192.168.0.0/16' will
* match '192.168.1.10' and 'fd00::/8' will match '[fd00::1]'.
*
* @param string $host Host to check against the patterns.
* @param string[] $noProxy An array of host or CIDR patterns.
*
* @throws InvalidArgumentException
*/
public static function isHostInNoProxy(string $host, array $noProxy): bool
{
if ($host === '') {
throw new InvalidArgumentException('Empty host provided');
}
self::assertNoProxyList($noProxy);
$target = self::parseNoProxyHostString($host);
if ($target === null) {
return false;
}
foreach ($noProxy as $area) {
$area = \trim($area, " \n\r\t\0\x0B");
if ($area === '*') {
return true;
}
$rule = self::parseNoProxyRule($area);
if ($rule !== null && self::noProxyRuleMatches($target, $rule)) {
return true;
}
}
return false;
}
/**
* @param array<array-key, mixed> $noProxy
*
* @throws InvalidArgumentException
*/
private static function assertNoProxyList(array $noProxy): void
{
foreach ($noProxy as $area) {
if (!\is_string($area)) {
throw new InvalidArgumentException('proxy no list must be null, a string, or an array of strings');
}
}
}
/**
* @return array{type: string, value: string, port: int|null}|null
*/
private static function parseNoProxyTarget(UriInterface $uri): ?array
{
$host = $uri->getHost();
if ($host === '') {
return null;
}
return self::parseNoProxyHost($host, $uri->getPort() ?? self::getDefaultPort($uri->getScheme()));
}
/**
* @return array{type: string, value: string, port: int|null}|null
*/
private static function parseNoProxyHostString(string $host): ?array
{
$hostAndPort = self::splitNoProxyHostAndPort($host);
if ($hostAndPort === null) {
return null;
}
[$host] = $hostAndPort;
return self::parseNoProxyHost($host, null);
}
/**
* @return array{type: string, value: string, port: int|null}|array{type: string, value: string, prefix: int}|null
*/
private static function parseNoProxyRule(string $area): ?array
{
$area = \trim($area, " \n\r\t\0\x0B");
if ($area === '' || $area === '*') {
return null;
}
// A single leading dot is ignored: ".example.com" matches
// example.com and its subdomains exactly like a bare domain,
// consistent with every libcurl era. The strip runs before the
// CIDR check so ".10.0.0.0/8" is a live rule on every path.
if ($area[0] === '.') {
$area = \substr($area, 1);
if ($area === '') {
return null;
}
}
if (\strpos($area, '/') !== false) {
return self::parseNoProxyCidrRule($area);
}
$hostAndPort = self::splitNoProxyHostAndPort($area);
if ($hostAndPort === null) {
return null;
}
[$host, $port] = $hostAndPort;
if ($host === '*') {
return [
'type' => 'wildcard',
'value' => '*',
'port' => $port,
];
}
return self::parseNoProxyHost($host, $port);
}
/**
* @return array{type: string, value: string, port: int|null}|null
*/
private static function parseNoProxyHost(string $host, ?int $port): ?array
{
if ($host !== '' && $host[0] === '[') {
if (\substr($host, -1) !== ']') {
return null;
}
$address = \substr($host, 1, -1);
if (!\filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return null;
}
$host = $address;
}
$packedIp = self::packIpAddress($host);
if ($packedIp !== false) {
return [
'type' => 'ip',
'value' => $packedIp,
'port' => $port,
];
}
if ($host === '' || \strpos($host, ':') !== false) {
return null;
}
// Normalize a single DNS root dot for no-proxy domain matching.
if (\substr($host, -1) === '.') {
$host = \substr($host, 0, -1);
if ($host === '') {
return null;
}
}
return [
'type' => 'domain',
'value' => Psr7\Utils::asciiToLower($host),
'port' => $port,
];
}
/**
* @return array{0: string, 1: int|null}|null
*/
private static function splitNoProxyHostAndPort(string $area): ?array
{
if ($area !== '' && $area[0] === '[') {
$closingBracket = \strpos($area, ']');
if ($closingBracket === false) {
return null;
}
$host = \substr($area, 0, $closingBracket + 1);
$tail = \substr($area, $closingBracket + 1);
if ($tail === '') {
return [$host, null];
}
if ($tail[0] !== ':') {
return null;
}
$port = self::parseNoProxyPort(\substr($tail, 1));
return $port === null ? null : [$host, $port];
}
if (self::packIpAddress($area) !== false) {
return [$area, null];
}
$colon = \strrpos($area, ':');
if ($colon === false) {
return [$area, null];
}
$port = self::parseNoProxyPort(\substr($area, $colon + 1));
if ($port === null) {
return null;
}
return [\substr($area, 0, $colon), $port];
}
private static function parseNoProxyPort(string $port): ?int
{
return self::parseBoundedUnsignedInteger($port, 65535);
}
private static function getDefaultPort(string $scheme): ?int
{
if ($scheme === 'http') {
return 80;
}
if ($scheme === 'https') {
return 443;
}
return null;
}
/**
* @return array{type: string, value: string, prefix: int}|null
*/
private static function parseNoProxyCidrRule(string $area): ?array
{
$slash = \strpos($area, '/');
if ($slash === false) {
return null;
}
$prefix = \substr($area, $slash + 1);
$network = \substr($area, 0, $slash);
if ($network !== '' && $network[0] === '[' && \substr($network, -1) === ']') {
$network = \substr($network, 1, -1);
}
$network = self::packIpAddress($network);
if ($network === false) {
return null;
}
$prefix = self::parseBoundedUnsignedInteger($prefix, \strlen($network) * 8);
if ($prefix === null) {
return null;
}
return [
'type' => 'cidr',
'value' => $network,
'prefix' => $prefix,
];
}
private static function parseBoundedUnsignedInteger(string $value, int $max): ?int
{
if ($value === '' || !\ctype_digit($value)) {
return null;
}
$normalized = \ltrim($value, '0');
$normalized = $normalized === '' ? '0' : $normalized;
$limit = (string) $max;
if (\strlen($normalized) > \strlen($limit) || (\strlen($normalized) === \strlen($limit) && \strcmp($normalized, $limit) > 0)) {
return null;
}
return (int) $normalized;
}
/**
* @param array{type: string, value: string, port: int|null} $target
* @param array{type: string, value: string, port?: int|null, prefix?: int|null} $rule
*/
private static function noProxyRuleMatches(array $target, array $rule): bool
{
if ($rule['type'] === 'wildcard') {
return ($rule['port'] ?? null) === null || $rule['port'] === $target['port'];
}
if ($rule['type'] === 'cidr') {
if ($target['type'] !== 'ip' || !isset($rule['prefix'])) {
return false;
}
if (\strlen($target['value']) !== \strlen($rule['value'])) {
return false;
}
return self::ipMatchesPrefix($target['value'], $rule['value'], $rule['prefix']);
}
if (($rule['port'] ?? null) !== null && $rule['port'] !== $target['port']) {
return false;
}
if ($rule['type'] !== $target['type']) {
return false;
}
if ($rule['type'] === 'ip') {
return $rule['value'] === $target['value'];
}
if ($target['value'] === $rule['value']) {
return true;
}
$suffix = '.'.$rule['value'];
return \substr($target['value'], -\strlen($suffix)) === $suffix;
}
/**
* @return string|false
*/
private static function packIpAddress(string $ip)
{
if (!\filter_var($ip, \FILTER_VALIDATE_IP)) {
return false;
}
return \inet_pton($ip);
}
private static function ipMatchesPrefix(string $address, string $network, int $prefix): bool
{
$fullBytes = \intdiv($prefix, 8);
$remainingBits = $prefix % 8;
if ($fullBytes > 0 && \substr($address, 0, $fullBytes) !== \substr($network, 0, $fullBytes)) {
return false;
}
if ($remainingBits === 0) {
return true;
}
$mask = (0xFF << (8 - $remainingBits)) & 0xFF;
return (\ord($address[$fullBytes]) & $mask) === (\ord($network[$fullBytes]) & $mask);
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
final class ProxySelection
{
private ?string $proxy;
private bool $bypassed;
private bool $disabled;
private function __construct(?string $proxy, bool $bypassed, bool $disabled)
{
$this->proxy = $proxy;
$this->bypassed = $bypassed;
$this->disabled = $disabled;
}
public static function none(): self
{
return new self(null, false, false);
}
public static function proxy(string $proxy): self
{
return $proxy === '' ? self::disabled() : new self($proxy, false, false);
}
public static function bypassed(): self
{
return new self(null, true, false);
}
public static function disabled(): self
{
return new self(null, false, true);
}
public function getProxy(): ?string
{
return $this->proxy;
}
public function hasProxy(): bool
{
return $this->proxy !== null;
}
public function isBypassed(): bool
{
return $this->bypassed;
}
public function isDisabled(): bool
{
return $this->disabled;
}
public function shouldDisableProxy(): bool
{
return $this->bypassed || $this->disabled;
}
}

View file

@ -1,12 +1,20 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\Exception\TooManyRedirectsException;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\HttpFactory;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
/**
@ -19,6 +27,8 @@ use Psr\Http\Message\UriInterface;
*/
class RedirectMiddleware
{
use NonSerializableTrait;
public const HISTORY_HEADER = 'X-Guzzle-Redirect-History';
public const STATUS_HISTORY_HEADER = 'X-Guzzle-Redirect-Status-History';
@ -26,7 +36,7 @@ class RedirectMiddleware
/**
* @var array
*/
public static $defaultSettings = [
public const DEFAULT_SETTINGS = [
'max' => 5,
'protocols' => ['http', 'https'],
'strict' => false,
@ -35,20 +45,27 @@ class RedirectMiddleware
];
/**
* @var callable(RequestInterface, array): PromiseInterface
* @var callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private $nextHandler;
/**
* @param callable(RequestInterface, array): PromiseInterface $nextHandler Next handler to invoke.
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $nextHandler Next handler to invoke.
*/
public function __construct(callable $nextHandler)
{
$this->nextHandler = $nextHandler;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
$fn = $this->nextHandler;
if (empty($options['allow_redirects'])) {
@ -56,12 +73,12 @@ class RedirectMiddleware
}
if ($options['allow_redirects'] === true) {
$options['allow_redirects'] = self::$defaultSettings;
$options['allow_redirects'] = self::DEFAULT_SETTINGS;
} elseif (!\is_array($options['allow_redirects'])) {
throw new \InvalidArgumentException('allow_redirects must be true, false, or array');
throw new InvalidArgumentException('allow_redirects must be true, false, or array');
} else {
// Merge the default settings with the provided settings
$options['allow_redirects'] += self::$defaultSettings;
$options['allow_redirects'] += self::DEFAULT_SETTINGS;
}
if (empty($options['allow_redirects']['max'])) {
@ -69,17 +86,26 @@ class RedirectMiddleware
}
return $fn($request, $options)
->then(function (ResponseInterface $response) use ($request, $options) {
->then(function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($request, $options) {
return $this->checkRedirect($request, $options, $response);
});
}
/**
* @return ResponseInterface|PromiseInterface
* @return ResponseInterface|PromiseInterface<ResponseInterface, mixed>
*/
public function checkRedirect(RequestInterface $request, array $options, ResponseInterface $response)
{
if (\strpos((string) $response->getStatusCode(), '3') !== 0
public function checkRedirect(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
ResponseInterface $response
) {
if (!self::isRedirectStatusCode($response->getStatusCode())
|| !$response->hasHeader('Location')
) {
return $response;
@ -88,12 +114,16 @@ class RedirectMiddleware
$this->guardMax($request, $response, $options);
$nextRequest = $this->modifyRequest($request, $options, $response);
// If authorization is handled by curl, unset it if URI is cross-origin.
if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $nextRequest->getUri()) && defined('\CURLOPT_HTTPAUTH')) {
unset(
$options['curl'][\CURLOPT_HTTPAUTH],
$options['curl'][\CURLOPT_USERPWD]
);
// Remove HTTP origin credentials if URI is cross-origin.
if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $nextRequest->getUri())) {
unset($options['auth'], $options['__guzzle_digest_retries']);
if (defined('\CURLOPT_HTTPAUTH') && defined('\CURLOPT_USERPWD')) {
unset(
$options['curl'][\CURLOPT_HTTPAUTH],
$options['curl'][\CURLOPT_USERPWD]
);
}
}
if (isset($options['allow_redirects']['on_redirect'])) {
@ -104,6 +134,10 @@ class RedirectMiddleware
);
}
// The caller's delay applies once, before the initial request, not
// before each followed redirect.
unset($options['delay']);
$promise = $this($nextRequest, $options);
// Add headers to be able to track history of redirects.
@ -120,11 +154,22 @@ class RedirectMiddleware
/**
* Enable tracking on promise.
*
* @param PromiseInterface<ResponseInterface, mixed> $promise
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function withTracking(PromiseInterface $promise, string $uri, int $statusCode): PromiseInterface
{
private function withTracking(
PromiseInterface $promise,
#[\SensitiveParameter]
string $uri,
int $statusCode
): PromiseInterface {
return $promise->then(
static function (ResponseInterface $response) use ($uri, $statusCode) {
static function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($uri, $statusCode): ResponseInterface {
// Note that we are pushing to the front of the list as this
// would be an earlier response than what is currently present
// in the history header.
@ -144,8 +189,14 @@ class RedirectMiddleware
*
* @throws TooManyRedirectsException Too many redirects.
*/
private function guardMax(RequestInterface $request, ResponseInterface $response, array &$options): void
{
private function guardMax(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
array &$options
): void {
$current = $options['__redirect_count']
?? 0;
$options['__redirect_count'] = $current + 1;
@ -156,48 +207,67 @@ class RedirectMiddleware
}
}
public function modifyRequest(RequestInterface $request, array $options, ResponseInterface $response): RequestInterface
{
// Request modifications to apply.
$modify = [];
public function modifyRequest(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
ResponseInterface $response
): RequestInterface {
$modify = self::getRedirectRequestModifiers($request, $options, $response);
$protocols = $options['allow_redirects']['protocols'];
// Use a GET request if this is an entity enclosing request and we are
// not forcing RFC compliance, but rather emulating what all browsers
// would do.
$statusCode = $response->getStatusCode();
if ($statusCode == 303
|| ($statusCode <= 302 && !$options['allow_redirects']['strict'])
) {
$safeMethods = ['GET', 'HEAD', 'OPTIONS'];
$requestMethod = $request->getMethod();
$modify['method'] = in_array($requestMethod, $safeMethods) ? $requestMethod : 'GET';
$modify['body'] = '';
$uriFactory = $options[RequestOptions::URI_FACTORY] ?? new HttpFactory();
if (!$uriFactory instanceof UriFactoryInterface) {
throw new InvalidArgumentException(\sprintf(
'%s must be an instance of %s',
RequestOptions::URI_FACTORY,
UriFactoryInterface::class
));
}
$uri = self::redirectUri($request, $response, $protocols);
$idnOptions = Utils::normalizeIdnConversionOption($options['idn_conversion'] ?? null);
$uri = self::redirectUri($uriFactory, $request, $response, $protocols);
$idnOptions = Idn::normalizeConversionOption($options['idn_conversion'] ?? null);
if ($idnOptions !== null) {
$uri = Utils::idnUriConvert($uri, $idnOptions);
$uri = Idn::convertUri($uri, $idnOptions);
}
$modify['uri'] = $uri;
Psr7\Message::rewindBody($request);
// Add the Referer header if it is told to do so and only
// add the header if we are not redirecting from https to http.
// The body only needs to be rewound when the next request reuses it.
if (!isset($modify['body'])) {
try {
Psr7\Message::rewindBody($request);
} catch (\Exception $e) {
throw new ResponseException(
'Redirect failed because the request body could not be rewound',
$request,
$response,
$e
);
}
}
$crossOrigin = Psr7\UriComparator::isCrossOrigin($request->getUri(), $modify['uri']);
// Add a Referer only when the scheme is unchanged, and reduce it to the
// origin on cross-origin redirects so a secret-bearing path or query is
// not leaked (strict-origin-when-cross-origin, like modern browsers).
if ($options['allow_redirects']['referer']
&& $modify['uri']->getScheme() === $request->getUri()->getScheme()
) {
$uri = $request->getUri()->withUserInfo('');
$modify['set_headers']['Referer'] = (string) $uri;
$referer = $request->getUri()->withUserInfo('')->withFragment('');
if ($crossOrigin) {
$referer = $referer->withPath('/')->withQuery('');
}
$modify['set_headers']['Referer'] = (string) $referer;
} else {
$modify['remove_headers'][] = 'Referer';
}
// Remove Authorization and Cookie headers if URI is cross-origin.
if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $modify['uri'])) {
if ($crossOrigin) {
$modify['remove_headers'][] = 'Authorization';
$modify['remove_headers'][] = 'Cookie';
}
@ -205,24 +275,90 @@ class RedirectMiddleware
return Psr7\Utils::modifyRequest($request, $modify);
}
private static function isRedirectStatusCode(int $statusCode): bool
{
return \in_array($statusCode, [301, 302, 303, 307, 308], true);
}
/**
* @return array{
* method?: string,
* body?: StreamInterface,
* remove_headers?: list<string>
* }
*/
private static function getRedirectRequestModifiers(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
ResponseInterface $response
): array {
$statusCode = $response->getStatusCode();
if ($statusCode !== 303
&& ($statusCode > 302 || $options['allow_redirects']['strict'])
) {
return [];
}
$requestMethod = $request->getMethod();
if ($requestMethod === 'QUERY' && \in_array($statusCode, [301, 302], true)) {
return [];
}
$streamFactory = $options[RequestOptions::STREAM_FACTORY] ?? new HttpFactory();
if (!$streamFactory instanceof StreamFactoryInterface) {
throw new InvalidArgumentException(\sprintf(
'%s must be an instance of %s',
RequestOptions::STREAM_FACTORY,
StreamFactoryInterface::class
));
}
return [
'method' => \in_array($requestMethod, ['GET', 'HEAD', 'OPTIONS'], true) ? $requestMethod : 'GET',
'body' => $streamFactory->createStream(''),
'remove_headers' => ['Content-Length', 'Transfer-Encoding'],
];
}
/**
* Set the appropriate URL on the request based on the location header.
*/
private static function redirectUri(
UriFactoryInterface $uriFactory,
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
array $protocols
): UriInterface {
$location = Psr7\UriResolver::resolve(
$request->getUri(),
new Psr7\Uri($response->getHeaderLine('Location'))
);
$location = $response->getHeaderLine('Location');
// Ensure that the redirect URI is allowed based on the protocols.
if (!\in_array($location->getScheme(), $protocols)) {
throw new BadResponseException(\sprintf('Redirect URI, %s, does not use one of the allowed redirect protocols: %s', $location, \implode(', ', $protocols)), $request, $response);
try {
$locationUri = $uriFactory->createUri($location);
$resolvedUri = Psr7\UriResolver::resolve(
$request->getUri(),
$locationUri
);
if (!$uriFactory instanceof HttpFactory
&& $locationUri->getScheme() === ''
&& $locationUri->getAuthority() === ''
) {
$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);
}
return $location;
// 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);
}
return $resolvedUri;
}
}

View file

@ -1,35 +1,53 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* This class contains a list of built-in Guzzle request options.
*
* @see https://github.com/guzzle/guzzle/blob/7.11/docs/request-options.md
* @see https://github.com/guzzle/guzzle/blob/8.0/docs/request-options.md
*/
final class RequestOptions
{
private function __construct()
{
}
/**
* allow_redirects: (bool|array) Controls redirect behavior. Pass false
* to disable redirects, pass true to enable redirects, pass an
* associative to provide custom redirect settings. Defaults to "false".
* to disable redirects, pass true to enable redirects, or pass an
* associative array to provide custom redirect settings. Clients enable
* redirects by default when the default redirect middleware is present.
* This option only works if your handler has the RedirectMiddleware. When
* passing an associative array, you can provide the following key value
* pairs:
* pairs. Only 301, 302, 303, 307, and 308 responses with a Location header
* are followed.
*
* - max: (int, default=5) maximum number of allowed redirects.
* - strict: (bool, default=false) Set to true to use strict redirects
* meaning redirect POST requests with POST requests vs. doing what most
* browsers do which is redirect POST requests with GET requests
* browsers do which is redirect POST requests with GET requests. The
* QUERY method keeps its method and body across non-strict 301 and 302
* redirects, and a 303 redirect is followed with a body-less GET.
* - referer: (bool, default=false) Set to true to enable the Referer
* header.
* - protocols: (non-empty-array<array-key, string>, default=['http', 'https'])
* Allowed redirect protocols. Redirect matching is case-sensitive; use
* "http" and "https".
* - on_redirect: (callable) PHP callable that is invoked when a redirect
* is encountered. The callable is invoked with the request, the redirect
* response that was received, and the effective URI. Any return value
* from the on_redirect function is ignored.
* - protocols: (non-empty-array<array-key, 'http'|'https'>,
* default=['http', 'https']) Allowed redirect protocols. Values are
* case-sensitive; only "http" and "https" are accepted.
* - on_redirect: (callable(RequestInterface, ResponseInterface, UriInterface): mixed)
* PHP callable that is invoked when a redirect is encountered. The
* callable is invoked with the request, the redirect response that was
* received, and the effective URI. Any return value is ignored. When
* this callback is supplied via GuzzleHttp\Pool's "options"
* configuration, the pool appends the request's iterable key as a
* fourth argument.
* - track_redirects: (bool, default=false) Track redirected URI and status
* history in response headers.
*/
@ -41,14 +59,22 @@ final class RequestOptions
* The array must contain the username in index [0], the password in index
* [1], and you can optionally provide a built-in authentication type in
* index [2]. Pass false or null to disable authentication for a request.
* String values are passed through for custom handlers.
* Built-in Basic and Digest authentication are applied by the auth
* middleware, normally from HandlerStack::create(). String values are
* passed through for custom handlers. Built-in Basic usernames must not
* contain a colon, and neither Basic usernames nor passwords may contain
* ASCII control characters. Basic passwords may contain colons.
*/
public const AUTH = 'auth';
/**
* body: (resource|string|null|int|float|bool|\Psr\Http\Message\StreamInterface|(callable&object)|\Iterator|\Stringable)
* Body to send in the request. Callable arrays are arrays, and arrays are
* not valid body values in Guzzle.
* body: (resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable)
* Body to send in the request. Resource and stringable object values are
* converted using the configured stream_factory. Callable and iterator
* bodies use Guzzle's existing stream handling. Strings are used as literal
* body contents, even when they name a callable. Callable bodies may be
* closures or invokable objects; arrays, int, float, bool, and generic
* objects without __toString() are not valid body values.
*/
public const BODY = 'body';
@ -60,6 +86,8 @@ final class RequestOptions
* the certificate password in the second array element. A null password is
* treated the same as omitting it. Use cert_type to specify another
* supported certificate format.
*
* @see self::CERT_TYPE
*/
public const CERT = 'cert';
@ -78,28 +106,51 @@ final class RequestOptions
public const COOKIES = 'cookies';
/**
* connect_timeout: (int|float, default=0) Number of seconds to wait while
* trying to connect to a server. Use 0 to wait 300 seconds (the default
* behavior).
* connect_timeout: (int|float, default=60) Number of seconds to wait
* while trying to connect to a server. Use 0 to disable the connect
* timeout. Positive values below 0.001 seconds are rejected by the
* built-in cURL handler.
*/
public const CONNECT_TIMEOUT = 'connect_timeout';
/**
* crypto_method: (int) A value describing the minimum TLS protocol
* version to use.
* version to use. The built-in cURL and stream handlers default HTTPS
* requests to TLS 1.2 or newer.
*
* This setting must be set to one of the
* ``STREAM_CRYPTO_METHOD_TLS*_CLIENT`` constants. PHP 7.4 or higher is
* required in order to use TLS 1.3, and cURL 7.34.0 or higher is required
* in order to specify a crypto method, with cURL 7.52.0 or higher being
* required to use TLS 1.3.
* ``STREAM_CRYPTO_METHOD_TLS*_CLIENT`` constants. cURL 7.52.0 or higher
* is required to use TLS 1.3 with the cURL handler.
*/
public const CRYPTO_METHOD = 'crypto_method';
/**
* crypto_method_max: (int) A value describing the maximum TLS protocol
* version to use. The built-in cURL and stream handlers default HTTPS
* requests to TLS 1.2 or newer, so a lower maximum requires explicitly
* lowering crypto_method too.
*
* This setting must be set to one of the
* ``STREAM_CRYPTO_METHOD_TLS*_CLIENT`` constants. cURL 7.54.0 or higher
* is required in order to specify a maximum TLS version with the cURL
* handler.
*/
public const CRYPTO_METHOD_MAX = 'crypto_method_max';
/**
* curl: (array) Raw cURL options to apply when using a built-in cURL
* handler.
*/
public const CURL = 'curl';
/**
* debug: (bool|resource) Set to true or set to a PHP stream returned by
* fopen() enable debug output with the HTTP handler used to send a
* request.
* fopen() to enable debug output with the handler used to send a request.
* For example, when using cURL to transfer requests, cURL's verbose of
* CURLOPT_VERBOSE will be emitted. When using the PHP stream wrapper,
* stream wrapper notifications will be emitted. If set to true, the output
* is written to PHP's STDOUT. If a PHP stream is provided, output is
* written to the stream.
*/
public const DEBUG = 'debug';
@ -111,8 +162,8 @@ final class RequestOptions
public const DECODE_CONTENT = 'decode_content';
/**
* delay: (int|float) The amount of time to delay before sending in
* milliseconds.
* delay: (int|float) The finite, non-negative amount of time to delay
* before sending in milliseconds.
*/
public const DELAY = 'delay';
@ -144,14 +195,14 @@ final class RequestOptions
/**
* headers: (array<array-key, string|non-empty-array<array-key, string>>|null)
* Associative array of HTTP headers. Each value MUST be a string or non-empty
* array of strings.
* Associative array of HTTP headers. Each value MUST be a string or
* non-empty array of strings.
*/
public const HEADERS = 'headers';
/**
* http_errors: (bool, default=true) Set to false to disable exceptions
* when a non- successful HTTP response is received. By default,
* when a non-successful HTTP response is received. By default,
* exceptions will be thrown for 4xx and 5xx responses. This option only
* works if your handler has the `httpErrors` middleware.
*/
@ -160,7 +211,7 @@ final class RequestOptions
/**
* idn_conversion: (bool|int|null, default=false) A combination of IDNA_*
* constants for PHP's idn_to_ascii() function. Set to false or null to
* disable IDN support, or to true to use the default configuration
* disable IDN support, or true to use the default configuration
* (IDNA_DEFAULT constant).
*/
public const IDN_CONVERSION = 'idn_conversion';
@ -168,7 +219,8 @@ final class RequestOptions
/**
* json: (mixed) Adds JSON data to a request. The provided value is JSON
* encoded and a Content-Type header of application/json will be added to
* the request if no Content-Type header is already present.
* the request if no Content-Type header is already present. An Accept
* header is not added automatically.
*/
public const JSON = 'json';
@ -184,45 +236,158 @@ final class RequestOptions
public const MULTIPART = 'multipart';
/**
* on_headers: (callable) A callable that is invoked when the HTTP headers
* of the response have been received but the body has not yet begun to
* download.
* multiplex: (string, default=GuzzleHttp\Multiplexing::WAIT) Controls how a
* request sent through a built-in cURL handler relates to shared,
* multiplexed connections: how an HTTP/2 or HTTP/3 request pursues one, or,
* with Multiplexing::NONE, whether the transfer may share its connection at
* all. Use Multiplexing::EAGER to avoid waiting for pending connections,
* Multiplexing::WAIT to wait on libcurl-eligible pending connections with
* CURLOPT_PIPEWAIT, normally to the same origin,
* Multiplexing::REQUIRE_EAGER to fail unless a multiplexed protocol is
* guaranteed while dialing eagerly, or Multiplexing::REQUIRE_WAIT for the
* same guarantee while also waiting on pending connections. The required
* modes require a handler that permits actual multiplexing, not merely a
* multiplexed protocol, and are rejected on a Multiplexing::NONE handler.
* The stream handler ignores EAGER and WAIT, and rejects the required
* family; CurlHandler has no multi handle to multiplex over. The required
* family also rejects final CURLOPT_HTTPAUTH masks that permit NTLM, which
* libcurl retries over HTTP/1.1. Multiplexing::NONE disables multiplexing
* for a whole handler when passed as the "multiplex" client configuration
* option, which configures the default handler and also becomes the default
* request option, or, when constructing a handler directly, as the
* CurlMultiHandler "multiplex" constructor option. A handler configured
* with Multiplexing::NONE wins over the default WAIT: default requests run
* without waiting, explicitly requested wait modes are rejected as a
* configuration conflict when the transfer would actually wait, and the
* required modes are always rejected, because they require a handler that
* permits actual multiplexing, not merely a multiplexed protocol. As a
* request option value, Multiplexing::NONE guarantees the transfer does not
* share its connection with any concurrent transfer. Multiplexing::NONE
* does not force HTTP/1.1: on a Multiplexing::NONE handler, HTTP/2 still
* negotiates and each transfer keeps its connection to itself.
*
* The request option value is accepted exactly where the guarantee holds
* and can be verified: on a CurlMultiHandler configured with
* Multiplexing::NONE, for requests whose declared protocol version is
* HTTP/1.x, on CurlHandler, and on the stream handler, which never
* multiplexes. An HTTP/2 or HTTP/3 request with a Multiplexing::NONE
* request option is rejected on a CurlMultiHandler that permits
* multiplexing. Acceptance is decided from the request's declared protocol
* version, before any transport-level downgrade: an HTTP/3 request sent
* through a proxy is delivered over HTTP/2 or HTTP/1.1 on the wire, but is
* still rejected. On a CurlMultiHandler that permits multiplexing,
* Multiplexing::NONE is also rejected with a custom "handle_factory", when
* the handler requires persistent transport sharing (the safeguards can
* require a fresh connection, which required sharing forbids), when the
* request carries an Expect: 100-continue header (its 417 retries select
* connections outside the safeguards; remove an explicitly supplied
* header, or set the "expect" request option to false to prevent it being
* added automatically), and combined with a raw CURLOPT_HTTPAUTH cURL
* option, whose authentication retries do the same.
*
* On a client whose multi handler permits multiplexing, the ordinary
* non-streaming default stack - both cURL handlers available and no
* connection caps forcing multi-only routing - runs synchronous requests on
* the CurlHandler path, which satisfies the guarantee for any protocol
* version, while asynchronous requests run on the CurlMultiHandler, so an
* HTTP/2 request with Multiplexing::NONE succeeds synchronously and is
* rejected asynchronously on the same client. Keep-alive reuse between
* consecutive transfers is unaffected, except on libcurl versions below
* 7.77.0 and from 8.11.0 through 8.12.1, where an accepted HTTP/1.x request
* on a multiplexing CurlMultiHandler forces a fresh connection. Custom
* handlers receive the "multiplex" option unchanged: its semantics are
* handler-defined, Guzzle does not guarantee it is honored, and a
* client-level Multiplexing::NONE with a custom handler flows to it as a
* default request option without client-side enforcement.
*/
public const MULTIPLEX = 'multiplex';
/**
* on_headers: (callable(ResponseInterface, RequestInterface): mixed) A
* callable that is invoked when the HTTP headers of the final response, or
* a 101 Switching Protocols response, have been received but the body has
* not yet begun to download. The callable is passed the response and
* request as {@see ResponseInterface} and {@see RequestInterface} objects,
* respectively. If it throws, the request promise is rejected with a
* GuzzleHttp\Exception\ResponseException (a RequestException subtype)
* wrapping the thrown exception. When this callback is supplied via
* GuzzleHttp\Pool's "options" configuration, the pool appends the request's
* iterable key as a third argument.
*/
public const ON_HEADERS = 'on_headers';
/**
* on_stats: (callable) allows you to get access to transfer statistics of
* a request and access the lower level transfer details of the handler
* associated with your client. ``on_stats`` is a callable that is invoked
* when a handler has finished sending a request. The callback is invoked
* with transfer statistics about the request, the response received, or
* the error encountered. Included in the data is the total amount of time
* taken to send the request.
* on_stats: (callable(TransferStats): mixed) allows you to get access to
* transfer statistics of a request and access the lower level transfer
* details of the handler associated with your client. ``on_stats`` is a
* callable that is invoked when a handler has finished sending a request.
* The callback is invoked with transfer statistics about the request, the
* response received, or the error encountered. Included in the data is the
* total amount of time taken to send the request. Exceptions thrown by
* on_stats are not wrapped by Guzzle. Built-in handlers reject non-callable
* values before starting the transfer. The built-in cURL handlers release
* native easy handles before invoking on_stats and invoke it per low-level
* transfer attempt. When this callback is supplied via GuzzleHttp\Pool's
* "options" configuration, the pool appends the request's iterable key as a
* second argument.
*/
public const ON_STATS = 'on_stats';
/**
* progress: (callable) Defines a function to invoke when transfer
* progress is made. The function accepts the following positional
* arguments: the total number of bytes expected to be downloaded, the
* number of bytes downloaded so far, the number of bytes expected to be
* uploaded, the number of bytes uploaded so far.
* on_trailers: (callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed)
* A callable that is invoked exactly once when a transfer completes
* successfully, with the HTTP trailer fields of the response. The callable
* is passed an associative array of trailer field names mapped to lists of
* field values, the response, and the request. The array is empty when the
* response carried no trailer fields, and trailer field names are
* lowercased and grouped case-insensitively while values keep their wire
* order. The callable is never invoked for failed transfers. If it throws,
* the request promise is rejected with a
* GuzzleHttp\Exception\ResponseException (a RequestException subtype)
* wrapping the thrown exception. Only the built-in cURL handlers invoke
* on_trailers; the built-in stream handler rejects the option because it
* cannot observe trailer fields, and the mock handler ignores it.
* Malformed trailer field lines are discarded before parsing. Trailer
* fields are reported separately from response headers and are never
* merged into the response. When this callback is supplied via
* GuzzleHttp\Pool's "options" configuration, the pool appends the
* request's iterable key as a fourth argument.
*/
public const ON_TRAILERS = 'on_trailers';
/**
* progress: (callable(int, int, int, int): mixed)
* Defines a function to invoke when transfer progress is made. The function
* accepts the following positional arguments: the total number of bytes
* expected to be downloaded, the number of bytes downloaded so far, the
* number of bytes expected to be uploaded, the number of bytes uploaded so
* far. With the built-in cURL handlers, returning a truthy value aborts the
* transfer and throwing rejects the promise with a RequestException. The
* built-in stream handler ignores return values. When this callback is
* supplied via GuzzleHttp\Pool's "options" configuration, the pool appends
* the request's iterable key as a fifth argument.
*/
public const PROGRESS = 'progress';
/**
* protocols: (non-empty-array<array-key, string>, default=['http', 'https'])
* Allowed URI schemes. Built-in handlers accept only the case-sensitive
* values "http" and "https".
* protocols: (non-empty-array<array-key, 'http'|'https'>,
* default=['http', 'https']) Allowed URI schemes. Values are
* case-sensitive; only "http" and "https" are accepted.
*/
public const PROTOCOLS = 'protocols';
/**
* proxy: (string|array) Pass a string to specify an HTTP proxy, or an
* array to specify different proxies for different protocols (where the
* key is the protocol and the value is a proxy string or null). Provide a
* "no" key as a comma-delimited string, array of strings, or null to
* specify hosts or host-and-port pairs that should not be proxied.
* proxy: (string|array) Pass a string to specify an HTTP proxy, or an array
* to specify different proxies for different protocols (where the key is
* the protocol and the value is a proxy string or null). Provide a "no" key
* 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.
*/
public const PROXY = 'proxy';
@ -235,9 +400,33 @@ final class RequestOptions
public const QUERY = 'query';
/**
* sink: (resource|string|\Psr\Http\Message\StreamInterface) Where the data
* of the response is written to. Defaults to a PHP temp stream. Providing
* a string will write data to a file by the given name.
* request_factory: (Psr\Http\Message\RequestFactoryInterface) PSR-17
* request factory used when creating requests through request() and
* requestAsync().
*/
public const REQUEST_FACTORY = 'request_factory';
/**
* stream_factory: (Psr\Http\Message\StreamFactoryInterface) PSR-17
* stream factory used when creating request body streams from body,
* form_params, and json request options, and when the built-in handlers
* create response body streams.
*/
public const STREAM_FACTORY = 'stream_factory';
/**
* response_factory: (Psr\Http\Message\ResponseFactoryInterface) PSR-17
* response factory used by the built-in handlers when creating the
* response message.
*/
public const RESPONSE_FACTORY = 'response_factory';
/**
* sink: (resource|string|StreamInterface) Where the data of the
* response is written to. Defaults to a PHP temp stream. Providing a
* string will write data to a file by the given name. Built-in handlers
* treat PHP resources as caller-owned; callers are responsible for closing
* resource sinks.
*/
public const SINK = 'sink';
@ -256,6 +445,8 @@ final class RequestOptions
* the first array element followed by the key password in the second
* element. A null password is treated the same as omitting it. Use
* ssl_key_type to specify another supported key format.
*
* @see self::SSL_KEY_TYPE
*/
public const SSL_KEY = 'ssl_key';
@ -270,6 +461,12 @@ final class RequestOptions
*/
public const STREAM = 'stream';
/**
* stream_context: (array) PHP stream context options to merge into the
* context used by the built-in stream handler.
*/
public const STREAM_CONTEXT = 'stream_context';
/**
* verify: (bool|string, default=true) Describes the SSL certificate
* verification behavior of a request. Set to true to enable SSL
@ -281,26 +478,48 @@ final class RequestOptions
public const VERIFY = 'verify';
/**
* timeout: (int|float, default=0) Number describing the timeout of the
* request in seconds. Use 0 to wait indefinitely (the default behavior).
* timeout: (int|float, default=0) Number of seconds to use as the total
* timeout of the request. Use 0 to disable the total timeout (the default
* behavior). Positive values below 0.001 seconds are rejected by the
* built-in handlers.
*/
public const TIMEOUT = 'timeout';
/**
* read_timeout: (int|float, default=default_socket_timeout ini setting)
* Number describing the body read timeout, for stream requests.
* read_timeout: (int|float, default=60) Number of seconds the connection
* may sit silent at any stage of the request. Use 0 to disable the idle
* timeout. Positive values below 0.001 seconds are rejected by the
* built-in stream handler.
*/
public const READ_TIMEOUT = 'read_timeout';
/**
* version: (string|int|float) Specifies the HTTP protocol version to attempt
* to use.
* retries: (int) Current retry count used by the retry middleware.
*/
public const RETRIES = 'retries';
/**
* uri_factory: (Psr\Http\Message\UriFactoryInterface) PSR-17 URI factory
* used when creating URI objects from string request URI, base_uri, and
* redirect Location values.
*/
public const URI_FACTORY = 'uri_factory';
/**
* version: (string|int|float, default=1.1) Specifies the HTTP protocol
* version to attempt to use.
*
* Guzzle defaults to HTTP/1.1. The built-in stream handler supports
* HTTP/1.0 and HTTP/1.1. The built-in cURL handler also supports HTTP/2
* and HTTP/3 when the installed cURL stack reports those features. For
* HTTP/2 and HTTP/3, libcurl may use a lower HTTP version when
* negotiation or connection setup falls back.
*/
public const VERSION = 'version';
/**
* force_ip_resolve: (string) Set to "v4" to force IPv4 resolution or "v6"
* for IPv6 resolution when supported by the handler.
* force_ip_resolve: ("v4"|"v6") Set to "v4" to force IPv4 resolution or
* "v6" to force IPv6 resolution when supported by the handler.
*/
public const FORCE_IP_RESOLVE = 'force_ip_resolve';
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Promise as P;
@ -15,63 +17,56 @@ use Psr\Http\Message\ResponseInterface;
*/
class RetryMiddleware
{
use NonSerializableTrait;
/**
* @var callable(RequestInterface, array): PromiseInterface
* @var callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private $nextHandler;
/**
* @var callable
* @var callable(int, RequestInterface, ResponseInterface|null, mixed): bool
*/
private $decider;
/**
* @var callable(int)
* @var callable(int, ResponseInterface|null, RequestInterface): int
*/
private $delay;
/**
* @param callable $decider Function that accepts the number of retries,
* a request, [response], and [exception] and
* returns true if the request is to be
* retried.
* @param callable(RequestInterface, array): PromiseInterface $nextHandler Next handler to invoke.
* @param (callable(int): int)|null $delay Function that accepts the number of retries
* and returns the number of
* milliseconds to delay.
* @param callable(int, RequestInterface, ResponseInterface|null, mixed): bool $decider Function that accepts the number of retries,
* a request, [response], and [rejection reason]
* and returns true if the request is to be retried.
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $nextHandler Next handler to invoke.
* @param (callable(int, ResponseInterface|null, RequestInterface): int)|null $delay Function that returns the number of milliseconds to delay.
*/
public function __construct(callable $decider, callable $nextHandler, ?callable $delay = null)
{
$this->decider = $decider;
$this->nextHandler = $nextHandler;
$this->delay = $delay ?: static function (int $retries): int {
return (int) 2 ** ($retries - 1) * 1000;
return (int) ((2 ** ($retries - 1)) * 1000);
};
}
/**
* Default exponential backoff delay function.
*
* @return int milliseconds.
*
* @deprecated since 7.11, will be removed in 8.0.
* @return PromiseInterface<ResponseInterface, mixed>
*/
public static function exponentialDelay(int $retries): int
{
\trigger_deprecation('guzzlehttp/guzzle', '7.11', '%s::%s() is deprecated and will be removed in 8.0.', __CLASS__, __FUNCTION__);
return (int) 2 ** ($retries - 1) * 1000;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
if (!isset($options['retries'])) {
$options['retries'] = 0;
} elseif (!\is_int($options['retries'])) {
throw new \InvalidArgumentException('retries must be an integer');
}
$fn = $this->nextHandler;
return $fn($request, $options)
/** @var PromiseInterface<ResponseInterface, mixed> */
return ($this->nextHandler)($request, $options)
->then(
$this->onFulfilled($request, $options),
$this->onRejected($request, $options)
@ -83,7 +78,10 @@ class RetryMiddleware
*/
private function onFulfilled(RequestInterface $request, array $options): callable
{
return function ($value) use ($request, $options) {
return function (
#[\SensitiveParameter]
$value
) use ($request, $options) {
if (!($this->decider)(
$options['retries'],
$request,
@ -102,7 +100,10 @@ class RetryMiddleware
*/
private function onRejected(RequestInterface $req, array $options): callable
{
return function ($reason) use ($req, $options) {
return function (
#[\SensitiveParameter]
$reason
) use ($req, $options): PromiseInterface {
if (!($this->decider)(
$options['retries'],
$req,
@ -112,13 +113,24 @@ class RetryMiddleware
return P\Create::rejectionFor($reason);
}
/** @var PromiseInterface<mixed, mixed> */
return $this->doRetry($req, $options);
};
}
private function doRetry(RequestInterface $request, array $options, ?ResponseInterface $response = null): PromiseInterface
{
$options['delay'] = ($this->delay)(++$options['retries'], $response, $request);
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function doRetry(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
?ResponseInterface $response = null
): PromiseInterface {
++$options['retries'];
$options['delay'] = ($this->delay)($options['retries'], $response, $request);
return $this($request, $options);
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\RequestInterface;
@ -12,25 +14,13 @@ use Psr\Http\Message\UriInterface;
*/
final class TransferStats
{
/**
* @var RequestInterface
*/
private $request;
private RequestInterface $request;
/**
* @var ResponseInterface|null
*/
private $response;
private ?ResponseInterface $response;
/**
* @var float|null
*/
private $transferTime;
private ?float $transferTime;
/**
* @var array
*/
private $handlerStats;
private array $handlerStats;
/**
* @var mixed|null

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
final class TransportSharing
@ -7,6 +9,8 @@ final class TransportSharing
public const NONE = 'none';
public const HANDLER_PREFER = 'handler_prefer';
public const HANDLER_REQUIRE = 'handler_require';
public const PERSISTENT_PREFER = 'persistent_prefer';
public const PERSISTENT_REQUIRE = 'persistent_require';
private function __construct()
{

View file

@ -1,42 +1,24 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\Handler\CurlMultiHandler;
use GuzzleHttp\Handler\CurlShareHandleState;
use GuzzleHttp\Handler\CurlVersion;
use GuzzleHttp\Handler\Proxy;
use GuzzleHttp\Handler\StreamHandler;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Http\Message\ResponseInterface;
final class Utils
{
/**
* Debug function used to describe the provided value type and class.
*
* @param mixed $input
*
* @return string Returns a string containing the type of the variable and
* if a class is provided, the class name.
*/
public static function describeType($input): string
private function __construct()
{
switch (\gettype($input)) {
case 'object':
return 'object('.\get_class($input).')';
case 'array':
return 'array('.\count($input).')';
default:
\ob_start();
\var_dump($input);
// normalize float vs double
/** @var string $varDumpContent */
$varDumpContent = \ob_get_clean();
return \str_replace('double(', 'float(', \rtrim($varDumpContent));
}
}
/**
@ -51,7 +33,7 @@ final class Utils
foreach ($lines as $line) {
$parts = \explode(':', $line, 2);
$headers[\trim($parts[0])][] = isset($parts[1]) ? \trim($parts[1]) : null;
$headers[\trim($parts[0], " \n\r\t\0\x0B")][] = isset($parts[1]) ? \trim($parts[1], " \n\r\t\0\x0B") : null;
}
return $headers;
@ -81,56 +63,162 @@ final class Utils
*
* The returned handler is not wrapped by any default middlewares.
*
* @param array{transport_sharing?: mixed} $handlerOptions Handler constructor options.
* @param array{transport_sharing?: mixed, max_host_connections?: mixed, max_total_connections?: mixed, multiplex?: mixed} $handlerOptions Handler constructor options.
*
* @return callable(RequestInterface, array): Promise\PromiseInterface Returns the best handler for the given system.
* @return callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> Returns the best handler for the given system.
*
* @throws \RuntimeException if no viable Handler is available.
*/
public static function chooseHandler(array $handlerOptions = []): callable
{
$handler = null;
$sharingMode = CurlShareHandleState::normalizeMode($handlerOptions['transport_sharing'] ?? null, 'transport_sharing');
$sharingRequested = $sharingMode !== TransportSharing::NONE;
$sharingRequired = $sharingMode === TransportSharing::HANDLER_REQUIRE;
$curlHandlerOptions = [];
$curlSupported = \defined('CURLOPT_CUSTOMREQUEST')
&& \function_exists('curl_version')
&& version_compare(curl_version()['version'], '7.21.2') >= 0
&& (\function_exists('curl_multi_exec') || \function_exists('curl_exec'));
$sharingRequired = self::isTransportSharingRequired($sharingMode);
$connectionCapsRequired = self::hasConnectionCapOptions($handlerOptions);
if ($sharingRequired && !$curlSupported) {
throw new \RuntimeException('Required transport sharing requires the PHP cURL extension, curl_exec() or curl_multi_exec(), and libcurl 7.21.2 or higher.');
if ($connectionCapsRequired && $sharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new InvalidArgumentException('The "max_host_connections" and "max_total_connections" options cannot be combined with required persistent transport sharing because libcurl does not reliably apply connection caps to shared connection pools.');
}
if ($curlSupported) {
if ($sharingRequested) {
$shareState = CurlShareHandleState::fromOption($sharingMode);
if ($shareState !== null) {
$curlHandlerOptions['transport_sharing'] = $shareState;
}
}
if ($connectionCapsRequired && $sharingMode === TransportSharing::PERSISTENT_PREFER) {
// libcurl does not apply cURL multi connection caps to transfers
// using a shared connection pool, so the best honorable offer for
// preferred persistent sharing is a handler-lifetime share.
$sharingMode = TransportSharing::HANDLER_PREFER;
}
if (\function_exists('curl_multi_exec') && \function_exists('curl_exec')) {
$handler = Proxy::wrapSync(new CurlMultiHandler($curlHandlerOptions), new CurlHandler($curlHandlerOptions));
} elseif (\function_exists('curl_exec')) {
$handler = new CurlHandler($curlHandlerOptions);
} elseif (\function_exists('curl_multi_exec')) {
$handler = new CurlMultiHandler($curlHandlerOptions);
}
$handler = self::createCurlHandler($sharingMode, $handlerOptions);
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 (\ini_get('allow_url_fopen')) {
$streamHandler = new StreamHandler(['transport_sharing' => $sharingMode]);
$handler = $handler
? Proxy::wrapStreaming($handler, $streamHandler)
: $streamHandler;
} elseif (!$handler) {
throw new \RuntimeException('GuzzleHttp requires cURL, the allow_url_fopen ini setting, or a custom HTTP handler.');
return self::addStreamHandler($handler, $sharingMode, self::connectionCapOptions($handlerOptions));
}
return $handler;
if ($handler !== null) {
return $handler;
}
if ($connectionCapsRequired) {
throw new \RuntimeException('Connection cap options require a cap-capable cURL multi handler or the allow_url_fopen ini setting for stream fallback.');
}
throw new \RuntimeException('GuzzleHttp requires a supported cURL version with SSL support, the allow_url_fopen ini setting, or a custom HTTP handler.');
}
private static function isTransportSharingRequired(string $sharingMode): bool
{
return \in_array($sharingMode, [TransportSharing::HANDLER_REQUIRE, TransportSharing::PERSISTENT_REQUIRE], true);
}
/**
* @param array{max_host_connections?: mixed, max_total_connections?: mixed} $handlerOptions
*/
private static function hasConnectionCapOptions(array $handlerOptions): bool
{
return self::connectionCapOptions($handlerOptions) !== [];
}
/**
* @param array{max_host_connections?: mixed, max_total_connections?: mixed, multiplex?: mixed} $handlerOptions
*
* @return (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)|null
*/
private static function createCurlHandler(string $sharingMode, array $handlerOptions): ?callable
{
if (!CurlVersion::supportsCurlHandler()) {
return null;
}
$connectionCapOptions = self::connectionCapOptions($handlerOptions);
if ($connectionCapOptions !== [] && !\function_exists('curl_multi_exec')) {
return null;
}
$curlHandlerOptions = self::createCurlHandlerOptions($sharingMode);
$curlMultiHandlerOptions = $curlHandlerOptions + $connectionCapOptions;
if (($handlerOptions['multiplex'] ?? null) === Multiplexing::NONE) {
// Forwarded to the CurlMultiHandler only: CurlHandler and
// StreamHandler validate known options, and both satisfy NONE
// per-request without a handler option.
$curlMultiHandlerOptions['multiplex'] = Multiplexing::NONE;
}
if (\function_exists('curl_multi_exec') && \function_exists('curl_exec')) {
$multiHandler = new CurlMultiHandler($curlMultiHandlerOptions);
if ($connectionCapOptions !== []) {
// Connection caps only govern transfers on the multi handle, so
// the synchronous CurlHandler fast path would escape them.
return $multiHandler;
}
return Proxy::wrapSync($multiHandler, new CurlHandler($curlHandlerOptions));
}
if ($connectionCapOptions === [] && \function_exists('curl_exec')) {
return new CurlHandler($curlHandlerOptions);
}
if (\function_exists('curl_multi_exec')) {
return new CurlMultiHandler($curlMultiHandlerOptions);
}
return null;
}
/**
* @return array<string, mixed>
*/
private static function createCurlHandlerOptions(string $sharingMode): array
{
if ($sharingMode === TransportSharing::NONE) {
return [];
}
$shareState = CurlShareHandleState::fromOption($sharingMode);
return $shareState === null ? [] : ['transport_sharing' => $shareState];
}
/**
* @param array{max_host_connections?: mixed, max_total_connections?: mixed} $handlerOptions
*
* @return array{max_host_connections?: int, max_total_connections?: int}
*/
private static function connectionCapOptions(array $handlerOptions): array
{
$options = [];
foreach (['max_host_connections', 'max_total_connections'] as $capOption) {
$value = $handlerOptions[$capOption] ?? null;
if ($value === null) {
continue;
}
if (!\is_int($value) || $value < 1) {
throw new InvalidArgumentException(\sprintf('%s must be a positive integer.', $capOption));
}
$options[$capOption] = $value;
}
return $options;
}
/**
* @param (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)|null $handler
* @param array{max_host_connections?: int, max_total_connections?: int} $connectionCapOptions
*
* @return callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private static function addStreamHandler(?callable $handler, string $sharingMode, array $connectionCapOptions): callable
{
$streamHandler = new StreamHandler(['transport_sharing' => $sharingMode] + $connectionCapOptions);
return $handler
? Proxy::wrapStreaming($handler, $streamHandler)
: $streamHandler;
}
/**
@ -141,77 +229,6 @@ final class Utils
return sprintf('GuzzleHttp/%d', ClientInterface::MAJOR_VERSION);
}
/**
* Returns the default cacert bundle for the current system.
*
* First, the openssl.cafile and curl.cainfo php.ini settings are checked.
* If those settings are not configured, then the common locations for
* bundles found on Red Hat, CentOS, Fedora, Ubuntu, Debian, FreeBSD, OS X
* and Windows are checked. If any of these file locations are found on
* disk, they will be utilized.
*
* Note: the result of this function is cached for subsequent calls.
*
* @throws \RuntimeException if no bundle can be found.
*
* @deprecated Utils::defaultCaBundle will be removed in guzzlehttp/guzzle:8.0. This method is not needed in PHP 5.6+.
*/
public static function defaultCaBundle(): string
{
static $cached = null;
static $cafiles = [
// Red Hat, CentOS, Fedora (provided by the ca-certificates package)
'/etc/pki/tls/certs/ca-bundle.crt',
// Ubuntu, Debian (provided by the ca-certificates package)
'/etc/ssl/certs/ca-certificates.crt',
// FreeBSD (provided by the ca_root_nss package)
'/usr/local/share/certs/ca-root-nss.crt',
// SLES 12 (provided by the ca-certificates package)
'/var/lib/ca-certificates/ca-bundle.pem',
// OS X provided by homebrew (using the default path)
'/usr/local/etc/openssl/cert.pem',
// Google app engine
'/etc/ca-certificates.crt',
// Windows?
'C:\\windows\\system32\\curl-ca-bundle.crt',
'C:\\windows\\curl-ca-bundle.crt',
];
if ($cached) {
return $cached;
}
if ($ca = \ini_get('openssl.cafile')) {
return $cached = $ca;
}
if ($ca = \ini_get('curl.cainfo')) {
return $cached = $ca;
}
foreach ($cafiles as $filename) {
if (\file_exists($filename)) {
return $cached = $filename;
}
}
throw new \RuntimeException(
<<< EOT
No system CA bundle could be found in any of the the common system locations.
PHP versions earlier than 5.6 are not properly configured to use the system's
CA bundle by default. In order to verify peer certificates, you will need to
supply the path on disk to a certificate bundle to the 'verify' request
option: https://github.com/guzzle/guzzle/blob/7.11/docs/request-options.md#verify. If
you do not need a specific certificate bundle, then Mozilla provides a commonly
used CA bundle which can be downloaded here (provided by the maintainer of
cURL): https://curl.haxx.se/ca/cacert.pem. Once you have a CA bundle available
on disk, you can set the 'openssl.cafile' PHP ini setting to point to the path
to the file, allowing you to omit the 'verify' request option. See
https://curl.haxx.se/docs/sslcerts.html for more information.
EOT
);
}
/**
* Creates an associative array of lowercase header names to the actual
* header casing.
@ -220,7 +237,7 @@ EOT
{
$result = [];
foreach (\array_keys($headers) as $key) {
$result[\strtolower((string) $key)] = $key;
$result[Psr7\Utils::asciiToLower((string) $key)] = $key;
}
return $result;
@ -255,374 +272,4 @@ EOT
return \array_keys($normalized);
}
/**
* Returns true if the provided host matches any of the no proxy areas.
*
* This method will strip a port from the host if it is present. Each pattern
* can be matched with an exact match (e.g., "foo.com" == "foo.com") or a
* partial match: (e.g., "foo.com" == "baz.foo.com" and ".foo.com" ==
* "baz.foo.com", but ".foo.com" != "foo.com").
*
* Areas are matched in the following cases:
* 1. "*" (without quotes) always matches any hosts.
* 2. An exact match.
* 3. The area starts with "." and the area is the last part of the host. e.g.
* '.mit.edu' will match any host that ends with '.mit.edu'.
*
* @param string $host Host to check against the patterns.
* @param string[] $noProxyArray An array of host patterns.
*
* @throws InvalidArgumentException
*/
public static function isHostInNoProxy(string $host, array $noProxyArray): bool
{
if (\strlen($host) === 0) {
throw new InvalidArgumentException('Empty host provided');
}
$host = self::normalizeNoProxyHost($host, true);
foreach ($noProxyArray as $area) {
// Always match on wildcards.
if ($area === '*') {
return true;
}
if ($area === '') {
continue;
}
$area = self::normalizeNoProxyHost($area, false);
if ($area === $host) {
// Exact matches.
return true;
}
// Special match if the area when prefixed with ".". Remove any
// existing leading "." and add a new leading ".".
$area = '.'.\ltrim($area, '.');
if (
\strpos($host, ':') === false
&& \strpos($area, ':') === false
&& \substr($host, -\strlen($area)) === $area
) {
return true;
}
}
return false;
}
/**
* Returns true if the provided URI matches any of the no proxy areas.
*
* @param mixed $noProxy No-proxy host patterns.
*
* @internal
*/
public static function isUriInNoProxy(UriInterface $uri, $noProxy): bool
{
if (\is_string($noProxy)) {
$noProxy = \explode(',', $noProxy);
}
if (!\is_array($noProxy)) {
return false;
}
$host = $uri->getHost();
if ($host === '') {
return false;
}
$port = $uri->getPort();
if ($port === null) {
$port = self::getDefaultPort($uri->getScheme());
}
foreach ($noProxy as $area) {
if (!\is_string($area)) {
continue;
}
$area = \trim($area);
// Always match on wildcards.
if ($area === '*') {
return true;
}
if ($area === '') {
continue;
}
[$area, $areaPort] = self::splitNoProxyHostAndPort($area);
if ($areaPort !== null && $areaPort !== $port) {
continue;
}
if (self::isHostInNoProxy($host, [$area])) {
return true;
}
}
return false;
}
private static function normalizeNoProxyHost(string $host, bool $stripPort): string
{
if ($host !== '' && $host[0] === '[') {
$closingBracket = \strpos($host, ']');
if ($closingBracket !== false) {
$address = \substr($host, 1, $closingBracket - 1);
$tail = \substr($host, $closingBracket + 1);
if (
($tail === '' || ($stripPort && \preg_match('/^:\d+$/', $tail)))
&& \filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)
) {
return \strtolower($address);
}
}
}
if (\filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return \strtolower($host);
}
if ($stripPort) {
[$host] = \explode(':', $host, 2);
}
return $host;
}
/**
* @return array{0: string, 1: int|null}
*/
private static function splitNoProxyHostAndPort(string $area): array
{
if ($area !== '' && $area[0] === '[') {
$closingBracket = \strpos($area, ']');
if ($closingBracket !== false) {
$tail = \substr($area, $closingBracket + 1);
if ($tail !== '' && $tail[0] === ':') {
$port = self::parseNoProxyPort(\substr($tail, 1));
if ($port !== null) {
return [\substr($area, 0, $closingBracket + 1), $port];
}
}
}
return [$area, null];
}
if (\filter_var($area, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return [$area, null];
}
$colon = \strrpos($area, ':');
if ($colon === false) {
return [$area, null];
}
$port = self::parseNoProxyPort(\substr($area, $colon + 1));
if ($port === null) {
return [$area, null];
}
return [\substr($area, 0, $colon), $port];
}
private static function parseNoProxyPort(string $port): ?int
{
if ($port === '' || !\ctype_digit($port)) {
return null;
}
$port = (int) $port;
return $port <= 65535 ? $port : null;
}
private static function getDefaultPort(string $scheme): ?int
{
if ($scheme === 'http') {
return 80;
}
if ($scheme === 'https') {
return 443;
}
return null;
}
/**
* Wrapper for json_decode that throws when an error occurs.
*
* @param string $json JSON data to parse
* @param bool $assoc When true, returned objects will be converted
* into associative arrays.
* @param int $depth User specified recursion depth.
* @param int $options Bitmask of JSON decode options.
*
* @return object|array|string|int|float|bool|null
*
* @throws InvalidArgumentException if the JSON cannot be decoded.
*
* @see https://www.php.net/manual/en/function.json-decode.php
*/
public static function jsonDecode(string $json, bool $assoc = false, int $depth = 512, int $options = 0)
{
if ($depth < 1) {
throw new InvalidArgumentException('json_decode error: Maximum stack depth exceeded');
}
$data = \json_decode($json, $assoc, $depth, $options);
if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new InvalidArgumentException('json_decode error: '.\json_last_error_msg());
}
return $data;
}
/**
* Wrapper for JSON encoding that throws when an error occurs.
*
* @param mixed $value The value being encoded
* @param int $options JSON encode option bitmask
* @param int $depth Set the maximum depth. Must be greater than zero.
*
* @throws InvalidArgumentException if the JSON cannot be encoded.
*
* @see https://www.php.net/manual/en/function.json-encode.php
*/
public static function jsonEncode($value, int $options = 0, int $depth = 512): string
{
$json = \json_encode($value, $options, $depth);
if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new InvalidArgumentException('json_encode error: '.\json_last_error_msg());
}
/** @var string */
return $json;
}
/**
* Wrapper for the hrtime() or microtime() functions
* (depending on the PHP version, one of the two is used)
*
* @return float UNIX timestamp
*
* @internal
*/
public static function currentTime(): float
{
return (float) \function_exists('hrtime') ? \hrtime(true) / 1e9 : \microtime(true);
}
/**
* @param mixed $value
*
* @internal
*/
public static function normalizeIdnConversionOption($value): ?int
{
if ($value === null || $value === false) {
return null;
}
if ($value === true) {
return \IDNA_DEFAULT;
}
if (\is_int($value)) {
return $value;
}
if ((\is_string($value) && \is_numeric($value)) || (\is_float($value) && \is_finite($value))) {
\trigger_deprecation(
'guzzlehttp/guzzle',
'7.11',
'Passing %s as the "idn_conversion" request option is deprecated; guzzlehttp/guzzle 8.0 will reject values that are not true, false, null, or an integer IDNA_* bitmask.',
self::describeType($value)
);
return (int) $value;
}
throw new InvalidArgumentException('idn_conversion must be true, false, null, or an integer IDNA_* bitmask');
}
/**
* @throws InvalidArgumentException
*
* @internal
*/
public static function idnUriConvert(UriInterface $uri, int $options = 0): UriInterface
{
if ($uri->getHost()) {
$asciiHost = self::idnToAsci($uri->getHost(), $options, $info);
if ($asciiHost === false) {
$errorBitSet = $info['errors'] ?? 0;
$errorConstants = array_filter(array_keys(get_defined_constants()), static function (string $name): bool {
return substr($name, 0, 11) === 'IDNA_ERROR_';
});
$errors = [];
foreach ($errorConstants as $errorConstant) {
if ($errorBitSet & constant($errorConstant)) {
$errors[] = $errorConstant;
}
}
$errorMessage = 'IDN conversion failed';
if ($errors) {
$errorMessage .= ' (errors: '.implode(', ', $errors).')';
}
throw new InvalidArgumentException($errorMessage);
}
if ($uri->getHost() !== $asciiHost) {
// Replace URI only if the ASCII version is different
$uri = $uri->withHost($asciiHost);
}
}
return $uri;
}
/**
* @internal
*/
public static function getenv(string $name): ?string
{
if (isset($_SERVER[$name])) {
return (string) $_SERVER[$name];
}
if (\PHP_SAPI === 'cli' && ($value = \getenv($name)) !== false && $value !== null) {
return (string) $value;
}
return null;
}
/**
* @return string|false
*/
private static function idnToAsci(string $domain, int $options, ?array &$info = [])
{
if (\function_exists('idn_to_ascii') && \defined('INTL_IDNA_VARIANT_UTS46')) {
return \idn_to_ascii($domain, $options, \INTL_IDNA_VARIANT_UTS46, $info);
}
throw new \Error('ext-idn or symfony/polyfill-intl-idn not loaded or too old');
}
}

View file

@ -1,167 +0,0 @@
<?php
namespace GuzzleHttp;
/**
* Debug function used to describe the provided value type and class.
*
* @param mixed $input Any type of variable to describe the type of. This
* parameter misses a typehint because of that.
*
* @return string Returns a string containing the type of the variable and
* if a class is provided, the class name.
*
* @deprecated describe_type will be removed in guzzlehttp/guzzle:8.0. Use Utils::describeType instead.
*/
function describe_type($input): string
{
return Utils::describeType($input);
}
/**
* Parses an array of header lines into an associative array of headers.
*
* @param iterable $lines Header lines array of strings in the following
* format: "Name: Value"
*
* @deprecated headers_from_lines will be removed in guzzlehttp/guzzle:8.0. Use Utils::headersFromLines instead.
*/
function headers_from_lines(iterable $lines): array
{
return Utils::headersFromLines($lines);
}
/**
* Returns a debug stream based on the provided variable.
*
* @param mixed $value Optional value
*
* @return resource
*
* @deprecated debug_resource will be removed in guzzlehttp/guzzle:8.0. Use Utils::debugResource instead.
*/
function debug_resource($value = null)
{
return Utils::debugResource($value);
}
/**
* Chooses and creates a default handler to use based on the environment.
*
* The returned handler is not wrapped by any default middlewares.
*
* @return callable(\Psr\Http\Message\RequestInterface, array): Promise\PromiseInterface Returns the best handler for the given system.
*
* @throws \RuntimeException if no viable Handler is available.
*
* @deprecated choose_handler will be removed in guzzlehttp/guzzle:8.0. Use Utils::chooseHandler instead.
*/
function choose_handler(): callable
{
return Utils::chooseHandler();
}
/**
* Get the default User-Agent string to use with Guzzle.
*
* @deprecated default_user_agent will be removed in guzzlehttp/guzzle:8.0. Use Utils::defaultUserAgent instead.
*/
function default_user_agent(): string
{
return Utils::defaultUserAgent();
}
/**
* Returns the default cacert bundle for the current system.
*
* First, the openssl.cafile and curl.cainfo php.ini settings are checked.
* If those settings are not configured, then the common locations for
* bundles found on Red Hat, CentOS, Fedora, Ubuntu, Debian, FreeBSD, OS X
* and Windows are checked. If any of these file locations are found on
* disk, they will be utilized.
*
* Note: the result of this function is cached for subsequent calls.
*
* @throws \RuntimeException if no bundle can be found.
*
* @deprecated default_ca_bundle will be removed in guzzlehttp/guzzle:8.0. This function is not needed in PHP 5.6+.
*/
function default_ca_bundle(): string
{
return Utils::defaultCaBundle();
}
/**
* Creates an associative array of lowercase header names to the actual
* header casing.
*
* @deprecated normalize_header_keys will be removed in guzzlehttp/guzzle:8.0. Use Utils::normalizeHeaderKeys instead.
*/
function normalize_header_keys(array $headers): array
{
return Utils::normalizeHeaderKeys($headers);
}
/**
* Returns true if the provided host matches any of the no proxy areas.
*
* This method will strip a port from the host if it is present. Each pattern
* can be matched with an exact match (e.g., "foo.com" == "foo.com") or a
* partial match: (e.g., "foo.com" == "baz.foo.com" and ".foo.com" ==
* "baz.foo.com", but ".foo.com" != "foo.com").
*
* Areas are matched in the following cases:
* 1. "*" (without quotes) always matches any hosts.
* 2. An exact match.
* 3. The area starts with "." and the area is the last part of the host. e.g.
* '.mit.edu' will match any host that ends with '.mit.edu'.
*
* @param string $host Host to check against the patterns.
* @param string[] $noProxyArray An array of host patterns.
*
* @throws Exception\InvalidArgumentException
*
* @deprecated is_host_in_noproxy will be removed in guzzlehttp/guzzle:8.0. Use Utils::isHostInNoProxy instead.
*/
function is_host_in_noproxy(string $host, array $noProxyArray): bool
{
return Utils::isHostInNoProxy($host, $noProxyArray);
}
/**
* Wrapper for json_decode that throws when an error occurs.
*
* @param string $json JSON data to parse
* @param bool $assoc When true, returned objects will be converted
* into associative arrays.
* @param int $depth User specified recursion depth.
* @param int $options Bitmask of JSON decode options.
*
* @return object|array|string|int|float|bool|null
*
* @throws Exception\InvalidArgumentException if the JSON cannot be decoded.
*
* @see https://www.php.net/manual/en/function.json-decode.php
* @deprecated json_decode will be removed in guzzlehttp/guzzle:8.0. Use Utils::jsonDecode instead.
*/
function json_decode(string $json, bool $assoc = false, int $depth = 512, int $options = 0)
{
return Utils::jsonDecode($json, $assoc, $depth, $options);
}
/**
* Wrapper for JSON encoding that throws when an error occurs.
*
* @param mixed $value The value being encoded
* @param int $options JSON encode option bitmask
* @param int $depth Set the maximum depth. Must be greater than zero.
*
* @throws Exception\InvalidArgumentException if the JSON cannot be encoded.
*
* @see https://www.php.net/manual/en/function.json-encode.php
* @deprecated json_encode will be removed in guzzlehttp/guzzle:8.0. Use Utils::jsonEncode instead.
*/
function json_encode($value, int $options = 0, int $depth = 512): string
{
return Utils::jsonEncode($value, $options, $depth);
}

View file

@ -1,6 +0,0 @@
<?php
// Don't redefine the functions if included multiple times.
if (!\function_exists('GuzzleHttp\describe_type')) {
require __DIR__.'/functions.php';
}