diff --git a/composer.json b/composer.json index c244cd50..a9a31081 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "yoast/wp-test-utils": "*", "php-parallel-lint/php-parallel-lint": "*", "php-parallel-lint/php-console-highlighter": "*", - "friendsofphp/php-cs-fixer": "*" + "friendsofphp/php-cs-fixer": "*", + "symfony/deprecation-contracts": "^2.5" }, "scripts": { "post-install-cmd": [ diff --git a/composer.lock b/composer.lock index 8847792e..573514bd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "96603a6f64d93fb039d098aa83de1069", + "content-hash": "9f0fc00c387dea264383eb6e71d007e2", "packages": [ { "name": "composer/installers", @@ -4364,20 +4364,20 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v2.5.4", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.1" }, "type": "library", "extra": { @@ -4386,7 +4386,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "2.5-dev" } }, "autoload": { @@ -4411,7 +4411,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" }, "funding": [ { @@ -4427,7 +4427,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2024-09-25T14:11:13+00:00" }, { "name": "symfony/event-dispatcher", diff --git a/plausible-analytics.php b/plausible-analytics.php index 1fd506c1..078281f3 100644 --- a/plausible-analytics.php +++ b/plausible-analytics.php @@ -24,6 +24,7 @@ // Automatically loads files used throughout the plugin. require_once PLAUSIBLE_ANALYTICS_PLUGIN_DIR . 'vendor/autoload.php'; +require_once PLAUSIBLE_ANALYTICS_PLUGIN_DIR . 'src/polyfills.php'; // Initialize the plugin. $plugin = new Plugin(); diff --git a/readme.txt b/readme.txt index c5b2d594..a60a5c85 100644 --- a/readme.txt +++ b/readme.txt @@ -278,6 +278,9 @@ Please make sure you make a backup of your database before updating any version == Changelog == += 2.6.3 = +* Security: updated the bundled Guzzle HTTP client to 7.15.5, which fixes several security advisories and a deprecation notice on PHP 8.5. + = 2.6.2 = * *Important!* This release rebuilds the WooCommerce (and EDD) purchase funnel for sites using WPML with a translated product base. If you built your own funnels using the same goals, please rebuild them after installing this update. * Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. diff --git a/src/Client/composer.json b/src/Client/composer.json index 4a8076b6..32f82415 100644 --- a/src/Client/composer.json +++ b/src/Client/composer.json @@ -22,8 +22,8 @@ "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "guzzlehttp/guzzle": "^7.3", - "guzzlehttp/psr7": "^1.7 || ^2.0" + "guzzlehttp/guzzle": "^7.15.2", + "guzzlehttp/psr7": "^2.12.3" }, "require-dev": { "phpunit/phpunit": "^8.0 || ^9.0", diff --git a/src/Client/composer.lock b/src/Client/composer.lock index 395a9b79..9d032062 100644 --- a/src/Client/composer.lock +++ b/src/Client/composer.lock @@ -4,39 +4,41 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1317ab5455350493dc53e9dbcf22af1b", + "content-hash": "0693dd2c05393ebee1c7ac50eb63fd4d", "packages": [ { "name": "guzzlehttp/guzzle", - "version": "7.8.0", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "1110f66a6530a40fe7aea0378fe608ee2b2248f9" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/1110f66a6530a40fe7aea0378fe608ee2b2248f9", - "reference": "1110f66a6530a40fe7aea0378fe608ee2b2248f9", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.1", - "guzzlehttp/psr7": "^1.9.1 || ^2.5.1", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.1", + "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.29 || ^9.5.23", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -114,7 +116,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.8.0" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -130,28 +132,29 @@ "type": "tidelift" } ], - "time": "2023-08-27T10:20:53+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.0.1", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "111166291a0f8130081195ac4556a5587d7f1b5d" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/111166291a0f8130081195ac4556a5587d7f1b5d", - "reference": "111166291a0f8130081195ac4556a5587d7f1b5d", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.1", - "phpunit/phpunit": "^8.5.29 || ^9.5.23" + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -197,7 +200,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.1" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -213,36 +216,39 @@ "type": "tidelift" } ], - "time": "2023-08-03T15:11:55+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.6.1", + "version": "2.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "be45764272e8873c72dbe3d2edcfdfcc3bc9f727" + "reference": "95e7828100de18b4e269fb1703be530082d5166d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/be45764272e8873c72dbe3d2edcfdfcc3bc9f727", - "reference": "be45764272e8873c72dbe3d2edcfdfcc3bc9f727", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.1", - "http-interop/http-factory-tests": "^0.9", - "phpunit/phpunit": "^8.5.29 || ^9.5.23" + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -313,7 +319,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.6.1" + "source": "https://github.com/guzzle/psr7/tree/2.13.1" }, "funding": [ { @@ -329,7 +335,7 @@ "type": "tidelift" } ], - "time": "2023-08-27T10:13:57+00:00" + "time": "2026-08-24T09:13:11+00:00" }, { "name": "psr/http-client", @@ -385,20 +391,20 @@ }, { "name": "psr/http-factory", - "version": "1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-factory.git", - "reference": "e616d01114759c4c489f93b099585439f795fe35" + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35", - "reference": "e616d01114759c4c489f93b099585439f795fe35", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "shasum": "" }, "require": { - "php": ">=7.0.0", + "php": ">=7.1", "psr/http-message": "^1.0 || ^2.0" }, "type": "library", @@ -422,7 +428,7 @@ "homepage": "https://www.php-fig.org/" } ], - "description": "Common interfaces for PSR-7 HTTP message factories", + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", "keywords": [ "factory", "http", @@ -434,9 +440,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-factory/tree/1.0.2" + "source": "https://github.com/php-fig/http-factory" }, - "time": "2023-04-10T20:10:41+00:00" + "time": "2024-04-15T12:06:14+00:00" }, { "name": "psr/http-message", @@ -537,29 +543,29 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.0.2", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", - "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { - "php": ">=8.0.2" + "php": ">=8.1" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -584,7 +590,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.2" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -595,12 +601,100 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2022-01-02T09:55:41+00:00" + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" } ], "packages-dev": [ @@ -3528,89 +3622,6 @@ ], "time": "2023-07-28T09:04:16+00:00" }, - { - "name": "symfony/polyfill-php80", - "version": "v1.28.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-01-26T09:26:14+00:00" - }, { "name": "symfony/polyfill-php81", "version": "v1.28.0", @@ -4033,7 +4044,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4042,6 +4053,6 @@ "ext-json": "*", "ext-mbstring": "*" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/src/Client/lib/Lib/GuzzleHttp/BodySummarizer.php b/src/Client/lib/Lib/GuzzleHttp/BodySummarizer.php index e5c4ae89..aa671b14 100644 --- a/src/Client/lib/Lib/GuzzleHttp/BodySummarizer.php +++ b/src/Client/lib/Lib/GuzzleHttp/BodySummarizer.php @@ -22,7 +22,7 @@ public function __construct(?int $truncateAt = null) public function summarize(MessageInterface $message): ?string { return $this->truncateAt === null - ? \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7\Message::bodySummary($message) - : \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7\Message::bodySummary($message, $this->truncateAt); + ? Psr7\Message::bodySummary($message) + : Psr7\Message::bodySummary($message, $this->truncateAt); } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Client.php b/src/Client/lib/Lib/GuzzleHttp/Client.php index 2601aa9a..6cd87664 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Client.php +++ b/src/Client/lib/Lib/GuzzleHttp/Client.php @@ -3,451 +3,1403 @@ namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie\CookieJar; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie\CookieJarInterface; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\GuzzleException; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\InvalidArgumentException; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Handler\CurlShareHandleState; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Handler\CurlVersion; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise as P; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\ResponseInterface; +use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\StreamInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\UriInterface; /** * @final */ -class Client implements ClientInterface, \Plausible\Analytics\WP\Client\Lib\Psr\Http\Client\ClientInterface { - use ClientTrait; - - /** - * @var array Default request options - */ - private $config; - - /** - * Clients accept an array of constructor parameters. - * Here's an example of creating a client using a base_uri and an array of - * default request options to apply to each request: - * $client = new Client([ - * 'base_uri' => 'http://www.foo.com/1.0/', - * 'timeout' => 0, - * 'allow_redirects' => false, - * 'proxy' => '192.168.16.1:10' - * ]); - * Client configuration settings include the following options: - * - handler: (callable) Function that transfers HTTP requests over the - * wire. The function is called with a Psr7\Http\Message\RequestInterface - * and array of transfer options, and must return a - * Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface that is fulfilled with a - * Psr7\Http\Message\ResponseInterface on success. - * If no handler is provided, a default handler will be created - * that enables all of the request options below by attaching all of the - * default middleware to the handler. - * - base_uri: (string|UriInterface) Base URI of the client that is merged - * into relative URIs. Can be a string or instance of UriInterface. - * - **: any request option - * @see \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions for a list of available request options. - * - * @param array $config Client configuration settings. - */ - public function __construct( array $config = [] ) { - if ( ! isset( $config[ 'handler' ] ) ) { - $config[ 'handler' ] = HandlerStack::create(); - } elseif ( ! \is_callable( $config[ 'handler' ] ) ) { - throw new InvalidArgumentException( 'handler must be a callable' ); - } - - // Convert the base_uri to a UriInterface - if ( isset( $config[ 'base_uri' ] ) ) { - $config[ 'base_uri' ] = Psr7\Utils::uriFor( $config[ 'base_uri' ] ); - } - - $this->configureDefaults( $config ); - } - - /** - * Configures the default options for a client. - */ - private function configureDefaults( array $config ): void { - $defaults = [ - 'allow_redirects' => RedirectMiddleware::$defaultSettings, - 'http_errors' => true, - 'decode_content' => true, - 'verify' => true, - 'cookies' => false, - 'idn_conversion' => false, - ]; - - // Use the standard Linux HTTP_PROXY and HTTPS_PROXY if set. - - // We can only trust the HTTP_PROXY environment variable in a CLI - // process due to the fact that PHP has no reliable mechanism to - // get environment variables that start with "HTTP_". - if ( \PHP_SAPI === 'cli' && ( $proxy = Utils::getenv( 'HTTP_PROXY' ) ) ) { - $defaults[ 'proxy' ][ 'http' ] = $proxy; - } - - if ( $proxy = Utils::getenv( 'HTTPS_PROXY' ) ) { - $defaults[ 'proxy' ][ 'https' ] = $proxy; - } - - if ( $noProxy = Utils::getenv( 'NO_PROXY' ) ) { - $cleanedNoProxy = \str_replace( ' ', '', $noProxy ); - $defaults[ 'proxy' ][ 'no' ] = \explode( ',', $cleanedNoProxy ); - } - - $this->config = $config + $defaults; - - if ( ! empty( $config[ 'cookies' ] ) && $config[ 'cookies' ] === true ) { - $this->config[ 'cookies' ] = new CookieJar(); - } - - // Add the default user-agent header. - if ( ! isset( $this->config[ 'headers' ] ) ) { - $this->config[ 'headers' ] = [ 'User-Agent' => Utils::defaultUserAgent() ]; - } else { - // Add the User-Agent header if one was not already set. - foreach ( \array_keys( $this->config[ 'headers' ] ) as $name ) { - if ( \strtolower( $name ) === 'user-agent' ) { - return; - } - } - $this->config[ 'headers' ][ 'User-Agent' ] = Utils::defaultUserAgent(); - } - } - - /** - * @param string $method - * @param array $args - * - * @return PromiseInterface|ResponseInterface - * @deprecated Client::__call will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. - */ - public function __call( $method, $args ) { - if ( \count( $args ) < 1 ) { - throw new InvalidArgumentException( 'Magic request methods require a URI and optional options array' ); - } - - $uri = $args[ 0 ]; - $opts = $args[ 1 ] ?? []; - - return \substr( $method, - 5 ) === 'Async' ? $this->requestAsync( \substr( $method, 0, - 5 ), $uri, $opts ) : - $this->request( $method, $uri, $opts ); - } - - /** - * Create and send an asynchronous HTTP request. - * 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. - * - * @param string $method HTTP method - * @param string|UriInterface $uri URI object or string. - * @param array $options Request options to apply. See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions. - */ - public function requestAsync( string $method, $uri = '', array $options = [] ): PromiseInterface { - $options = $this->prepareDefaults( $options ); - // Remove request modifying parameter because it can be done up-front. - $headers = $options[ 'headers' ] ?? []; - $body = $options[ 'body' ] ?? null; - $version = $options[ 'version' ] ?? '1.1'; - // Merge the URI into the base URI. - $uri = $this->buildUri( Psr7\Utils::uriFor( $uri ), $options ); - if ( \is_array( $body ) ) { - throw $this->invalidBody(); - } - $request = new Psr7\Request( $method, $uri, $headers, $body, $version ); - // Remove the option so that they are not doubly-applied. - unset( $options[ 'headers' ], $options[ 'body' ], $options[ 'version' ] ); - - return $this->transfer( $request, $options ); - } - - /** - * Merges default options into the array. - * - * @param array $options Options to modify by reference - */ - private function prepareDefaults( array $options ): array { - $defaults = $this->config; - - if ( ! empty( $defaults[ 'headers' ] ) ) { - // Default headers are only added if they are not present. - $defaults[ '_conditional' ] = $defaults[ 'headers' ]; - unset( $defaults[ 'headers' ] ); - } - - // Special handling for headers is required as they are added as - // conditional headers and as headers passed to a request ctor. - if ( \array_key_exists( 'headers', $options ) ) { - // Allows default headers to be unset. - if ( $options[ 'headers' ] === null ) { - $defaults[ '_conditional' ] = []; - unset( $options[ 'headers' ] ); - } elseif ( ! \is_array( $options[ 'headers' ] ) ) { - throw new InvalidArgumentException( 'headers must be an array' ); - } - } - - // Shallow merge defaults underneath options. - $result = $options + $defaults; - - // Remove null values. - foreach ( $result as $k => $v ) { - if ( $v === null ) { - unset( $result[ $k ] ); - } - } - - return $result; - } - - private function buildUri( UriInterface $uri, array $config ): UriInterface { - if ( isset( $config[ 'base_uri' ] ) ) { - $uri = Psr7\UriResolver::resolve( Psr7\Utils::uriFor( $config[ 'base_uri' ] ), $uri ); - } - - if ( isset( $config[ 'idn_conversion' ] ) && ( $config[ 'idn_conversion' ] !== false ) ) { - $idnOptions = ( $config[ 'idn_conversion' ] === true ) ? \IDNA_DEFAULT : $config[ 'idn_conversion' ]; - $uri = Utils::idnUriConvert( $uri, $idnOptions ); - } - - return $uri->getScheme() === '' && $uri->getHost() !== '' ? $uri->withScheme( 'http' ) : $uri; - } - - /** - * Return an InvalidArgumentException with pre-set message. - */ - private function invalidBody(): InvalidArgumentException { - return new InvalidArgumentException( - 'Passing in the "body" request ' . - 'option as an array to send a request is not supported. ' . - 'Please use the "form_params" request option to send a ' . - 'application/x-www-form-urlencoded request, or the "multipart" ' . - 'request option to send a multipart/form-data request.' - ); - } - - /** - * Transfers the given request and applies request options. - * The URI of the request is not modified and the request options are used - * as-is without merging in default options. - * - * @param array $options See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions. - */ - private function transfer( RequestInterface $request, array $options ): PromiseInterface { - $request = $this->applyOptions( $request, $options ); - /** @var HandlerStack $handler */ - $handler = $options[ 'handler' ]; - - try { - return P\Create::promiseFor( $handler( $request, $options ) ); - } catch ( \Exception $e ) { - return P\Create::rejectionFor( $e ); - } - } - - /** - * Applies the array of request options to a request. - */ - private function applyOptions( RequestInterface $request, array &$options ): RequestInterface { - $modify = [ - 'set_headers' => [], - ]; - - if ( isset( $options[ 'headers' ] ) ) { - if ( array_keys( $options[ 'headers' ] ) === range( 0, count( $options[ 'headers' ] ) - 1 ) ) { - throw new InvalidArgumentException( 'The headers array must have header name as keys.' ); - } - $modify[ 'set_headers' ] = $options[ 'headers' ]; - unset( $options[ 'headers' ] ); - } - - if ( isset( $options[ 'form_params' ] ) ) { - if ( isset( $options[ 'multipart' ] ) ) { - throw new InvalidArgumentException( - 'You cannot use ' . - 'form_params and multipart at the same time. Use the ' . - 'form_params option if you want to send application/' . - 'x-www-form-urlencoded requests, and the multipart ' . - 'option to send multipart/form-data requests.' - ); - } - $options[ 'body' ] = \http_build_query( $options[ 'form_params' ], '', '&' ); - unset( $options[ 'form_params' ] ); - // Ensure that we don't have the header in different case and set the new value. - $options[ '_conditional' ] = Psr7\Utils::caselessRemove( [ 'Content-Type' ], $options[ '_conditional' ] ); - $options[ '_conditional' ][ 'Content-Type' ] = 'application/x-www-form-urlencoded'; - } - - if ( isset( $options[ 'multipart' ] ) ) { - $options[ 'body' ] = new Psr7\MultipartStream( $options[ 'multipart' ] ); - unset( $options[ 'multipart' ] ); - } - - if ( isset( $options[ 'json' ] ) ) { - $options[ 'body' ] = Utils::jsonEncode( $options[ 'json' ] ); - unset( $options[ 'json' ] ); - // Ensure that we don't have the header in different case and set the new value. - $options[ '_conditional' ] = Psr7\Utils::caselessRemove( [ 'Content-Type' ], $options[ '_conditional' ] ); - $options[ '_conditional' ][ 'Content-Type' ] = 'application/json'; - } - - if ( ! empty( $options[ 'decode_content' ] ) && $options[ 'decode_content' ] !== true ) { - // Ensure that we don't have the header in different case and set the new value. - $options[ '_conditional' ] = Psr7\Utils::caselessRemove( [ 'Accept-Encoding' ], $options[ '_conditional' ] ); - $modify[ 'set_headers' ][ 'Accept-Encoding' ] = $options[ 'decode_content' ]; - } - - if ( isset( $options[ 'body' ] ) ) { - if ( \is_array( $options[ 'body' ] ) ) { - throw $this->invalidBody(); - } - $modify[ 'body' ] = Psr7\Utils::streamFor( $options[ 'body' ] ); - unset( $options[ 'body' ] ); - } - - if ( ! empty( $options[ 'auth' ] ) && \is_array( $options[ 'auth' ] ) ) { - $value = $options[ 'auth' ]; - $type = isset( $value[ 2 ] ) ? \strtolower( $value[ 2 ] ) : 'basic'; - switch ( $type ) { - case 'basic': - // Ensure that we don't have the header in different case and set the new value. - $modify[ 'set_headers' ] = Psr7\Utils::caselessRemove( [ 'Authorization' ], $modify[ 'set_headers' ] ); - $modify[ 'set_headers' ][ 'Authorization' ] = 'Basic ' . \base64_encode( "$value[0]:$value[1]" ); - break; - case 'digest': - // @todo: Do not rely on curl - $options[ 'curl' ][ \CURLOPT_HTTPAUTH ] = \CURLAUTH_DIGEST; - $options[ 'curl' ][ \CURLOPT_USERPWD ] = "$value[0]:$value[1]"; - break; - case 'ntlm': - $options[ 'curl' ][ \CURLOPT_HTTPAUTH ] = \CURLAUTH_NTLM; - $options[ 'curl' ][ \CURLOPT_USERPWD ] = "$value[0]:$value[1]"; - break; - } - } - - if ( isset( $options[ 'query' ] ) ) { - $value = $options[ 'query' ]; - if ( \is_array( $value ) ) { - $value = \http_build_query( $value, '', '&', \PHP_QUERY_RFC3986 ); - } - if ( ! \is_string( $value ) ) { - throw new InvalidArgumentException( 'query must be a string or array' ); - } - $modify[ 'query' ] = $value; - unset( $options[ 'query' ] ); - } - - // Ensure that sink is not an invalid value. - if ( isset( $options[ 'sink' ] ) ) { - // TODO: Add more sink validation? - if ( \is_bool( $options[ 'sink' ] ) ) { - throw new InvalidArgumentException( 'sink must not be a boolean' ); - } - } - - if ( isset( $options[ 'version' ] ) ) { - $modify[ 'version' ] = $options[ 'version' ]; - } - - $request = Psr7\Utils::modifyRequest( $request, $modify ); - if ( $request->getBody() instanceof Psr7\MultipartStream ) { - // Use a multipart/form-data POST if a Content-Type is not set. - // Ensure that we don't have the header in different case and set the new value. - $options[ '_conditional' ] = Psr7\Utils::caselessRemove( [ 'Content-Type' ], $options[ '_conditional' ] ); - $options[ '_conditional' ][ 'Content-Type' ] = 'multipart/form-data; boundary=' . $request->getBody()->getBoundary(); - } - - // Merge in conditional headers if they are not present. - if ( isset( $options[ '_conditional' ] ) ) { - // Build up the changes so it's in a single clone of the message. - $modify = []; - foreach ( $options[ '_conditional' ] as $k => $v ) { - if ( ! $request->hasHeader( $k ) ) { - $modify[ 'set_headers' ][ $k ] = $v; - } - } - $request = Psr7\Utils::modifyRequest( $request, $modify ); - // Don't pass this internal value along to middleware/handlers. - unset( $options[ '_conditional' ] ); - } - - return $request; - } - - /** - * Create and send an HTTP request. - * 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. - * - * @param string $method HTTP method. - * @param string|UriInterface $uri URI object or string. - * @param array $options Request options to apply. See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions. - * - * @throws GuzzleException - */ - public function request( string $method, $uri = '', array $options = [] ): ResponseInterface { - $options[ RequestOptions::SYNCHRONOUS ] = true; - - return $this->requestAsync( $method, $uri, $options )->wait(); - } - - /** - * Send an HTTP request. - * - * @param array $options Request options to apply to the given - * request and to the transfer. See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions. - * - * @throws GuzzleException - */ - public function send( RequestInterface $request, array $options = [] ): ResponseInterface { - $options[ RequestOptions::SYNCHRONOUS ] = true; - - return $this->sendAsync( $request, $options )->wait(); - } - - /** - * Asynchronously send an HTTP request. - * - * @param array $options Request options to apply to the given - * request and to the transfer. See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions. - */ - public function sendAsync( RequestInterface $request, array $options = [] ): PromiseInterface { - // Merge the base URI into the request URI if needed. - $options = $this->prepareDefaults( $options ); - - return $this->transfer( - $request->withUri( $this->buildUri( $request->getUri(), $options ), $request->hasHeader( 'Host' ) ), - $options - ); - } - - /** - * The HttpClient PSR (PSR-18) specify this method. - * {@inheritDoc} - */ - public function sendRequest( RequestInterface $request ): ResponseInterface { - $options[ RequestOptions::SYNCHRONOUS ] = true; - $options[ RequestOptions::ALLOW_REDIRECTS ] = false; - $options[ RequestOptions::HTTP_ERRORS ] = false; - - return $this->sendAsync( $request, $options )->wait(); - } - - /** - * 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 Client::getConfig will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. - */ - public function getConfig( ?string $option = null ) { - return $option === null ? $this->config : ( $this->config[ $option ] ?? null ); - } +class Client implements ClientInterface, \Plausible\Analytics\WP\Client\Lib\Psr\Http\Client\ClientInterface +{ + use ClientTrait; + + /** + * @var array Default request options + */ + private $config; + + /** + * Clients accept an array of constructor parameters. + * + * Here's an example of creating a client using a base_uri and an array of + * default request options to apply to each request: + * + * $client = new Client([ + * 'base_uri' => 'http://www.foo.com/1.0/', + * 'timeout' => 0, + * 'allow_redirects' => false, + * 'proxy' => '192.168.16.1:10' + * ]); + * + * Client configuration settings include the following options: + * + * - handler: (callable) Function that transfers HTTP requests over the + * wire. The function is called with a Psr7\Http\Message\RequestInterface + * and array of transfer options, and must return a + * Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface that is fulfilled with a + * Psr7\Http\Message\ResponseInterface on success. + * If no handler is provided, a default handler will be created + * that enables all of the request options below by attaching all of the + * default middleware to the handler. + * - base_uri: (string|UriInterface) Base URI of the client that is merged + * into relative URIs. Can be a string or instance of UriInterface. + * - transport_sharing: (string|null) Transport sharing mode for the + * default handler. Accepts TransportSharing::* or null. Defaults to null. + * - max_host_connections: (int|null) Maximum concurrent connections per + * host, applied by the default CurlMultiHandler. The default stream + * fallback receives the cap as a marker only: it rejects enabled + * response streaming ("stream" => true) and does not limit overlapping + * buffered calls. + * - max_total_connections: (int|null) Maximum concurrent connections + * overall, applied by the default CurlMultiHandler. The default stream + * fallback receives the cap as a marker only: it rejects enabled + * response streaming ("stream" => true) and does not limit overlapping + * buffered calls. + * - multiplex: (string|null) Multiplexing::NONE to disable multiplexing on + * the default CurlMultiHandler; the value also becomes the default + * "multiplex" request option. Other Multiplexing::* values act as the + * default request option only. + * - **: any request option + * + * @param array $config Client configuration settings. + * + * @see RequestOptions for a list of available request options. + */ + public function __construct(array $config = []) + { + $handlerOptions = []; + foreach (['max_host_connections', 'max_total_connections'] as $capOption) { + if (\array_key_exists($capOption, $config)) { + if ($config[$capOption] !== null) { + $handlerOptions[$capOption] = $config[$capOption]; + } + + unset($config[$capOption]); + } + } + + // Deliberately not unset: the value also becomes the default + // "multiplex" request option, which the configured handler accepts. + $handlerMultiplex = ($config['multiplex'] ?? null) === Multiplexing::NONE; + + $transportSharing = \array_key_exists('transport_sharing', $config) ? $config['transport_sharing'] : null; + $transportSharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing'); + unset($config['transport_sharing']); + + if (!isset($config['handler'])) { + if ($transportSharingMode !== TransportSharing::NONE) { + $handlerOptions['transport_sharing'] = $transportSharingMode; + } + + if ($handlerMultiplex) { + $handlerOptions['multiplex'] = Multiplexing::NONE; + } + + $config['handler'] = $handlerOptions === [] + ? HandlerStack::create() + : HandlerStack::create(Utils::chooseHandler($handlerOptions)); + } elseif (!\is_callable($config['handler'])) { + throw new InvalidArgumentException('handler must be a callable'); + } elseif ($handlerOptions !== []) { + throw new InvalidArgumentException('The "max_host_connections" and "max_total_connections" client options require Guzzle to create the default handler. Configure the options on the CurlMultiHandler constructor to apply numeric connection caps, or on the StreamHandler constructor to reject enabled response streaming, when providing a custom handler.'); + } elseif ($transportSharingMode === TransportSharing::HANDLER_REQUIRE) { + throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler or CurlMultiHandler when providing a custom cURL handler.'); + } + + // Convert the base_uri to a UriInterface + if (isset($config['base_uri'])) { + $config['base_uri'] = Psr7\Utils::uriFor($config['base_uri']); + } + + $this->configureDefaults($config); + } + + /** + * @param string $method + * @param array $args + * + * @return PromiseInterface|ResponseInterface + * + * @deprecated Client::__call will be removed in guzzlehttp/guzzle:8.0. + */ + public function __call($method, $args) + { + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s::%s() is deprecated and will be removed in 8.0.', __CLASS__, __FUNCTION__); + + if (\count($args) < 1) { + throw new InvalidArgumentException('Magic request methods require a URI and optional options array'); + } + + $uri = $args[0]; + $opts = $args[1] ?? []; + + $isAsync = \substr($method, -5) === 'Async'; + $method = $isAsync ? \substr($method, 0, -5) : $method; + $method = Psr7\Utils::asciiToUpper($method); + + return $isAsync + ? $this->requestAsync($method, $uri, $opts) + : $this->request($method, $uri, $opts); + } + + /** + * Asynchronously send an HTTP request. + * + * @param array $options Request options to apply to the given + * request and to the transfer. See {@see RequestOptions}. + */ + public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface + { + // Merge the base URI into the request URI if needed. + $options = $this->prepareDefaults($options); + + return $this->transfer( + $request->withUri($this->buildUri($request->getUri(), $options), self::shouldPreserveHost($request)), + $options + ); + } + + /** + * Send an HTTP request. + * + * @param array $options Request options to apply to the given + * request and to the transfer. See {@see RequestOptions}. + * + * @throws GuzzleException + */ + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + $options[RequestOptions::SYNCHRONOUS] = true; + + return $this->sendAsync($request, $options)->wait(); + } + + /** + * The HttpClient PSR (PSR-18) specify this method. + * + * {@inheritDoc} + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $options[RequestOptions::SYNCHRONOUS] = true; + $options[RequestOptions::ALLOW_REDIRECTS] = false; + $options[RequestOptions::HTTP_ERRORS] = false; + + return $this->sendAsync($request, $options)->wait(); + } + + /** + * Create and send an asynchronous HTTP request. + * + * 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. + * + * @param string $method HTTP method + * @param string|UriInterface $uri URI object or string. + * @param array $options Request options to apply. See {@see RequestOptions}. + */ + public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface + { + $normalizedMethod = Psr7\Utils::asciiToUpper($method); + if ($method !== $normalizedMethod) { + \trigger_deprecation( + 'guzzlehttp/guzzle', + '7.11', + 'Passing a non-uppercase HTTP method to Client::requestAsync() is deprecated; guzzlehttp/guzzle 8.0 will preserve HTTP method casing. Pass an uppercase method explicitly if uppercase is required.' + ); + $method = $normalizedMethod; + } + + $options = $this->prepareDefaults($options); + // Remove request modifying parameter because it can be done up-front. + $headers = $options['headers'] ?? []; + $droppedHeaderNames = self::castDeprecatedHeaderOptionValues($headers); + if ($droppedHeaderNames !== [] && isset($options['_conditional'])) { + $options['_conditional'] = Psr7\Utils::caselessRemove($droppedHeaderNames, $options['_conditional']); + } + $body = $options['body'] ?? null; + $version = self::normalizeProtocolVersion($options['version'] ?? '1.1'); + // Merge the URI into the base URI. + $uri = $this->buildUri(Psr7\Utils::uriFor($uri), $options); + if (\is_array($body)) { + throw $this->invalidBody(); + } + $body = self::createBodyStream($body); + $request = new Psr7\Request($method, $uri, $headers, $body, $version); + // Remove the option so that they are not doubly-applied. + unset($options['headers'], $options['body'], $options['version']); + + return $this->transfer($request, $options); + } + + /** + * Create and send an HTTP request. + * + * 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. + * + * @param string $method HTTP method. + * @param string|UriInterface $uri URI object or string. + * @param array $options Request options to apply. See {@see RequestOptions}. + * + * @throws GuzzleException + */ + public function request(string $method, $uri = '', array $options = []): ResponseInterface + { + $normalizedMethod = Psr7\Utils::asciiToUpper($method); + if ($method !== $normalizedMethod) { + \trigger_deprecation( + 'guzzlehttp/guzzle', + '7.11', + 'Passing a non-uppercase HTTP method to Client::request() is deprecated; guzzlehttp/guzzle 8.0 will preserve HTTP method casing. Pass an uppercase method explicitly if uppercase is required.' + ); + $method = $normalizedMethod; + } + + $options[RequestOptions::SYNCHRONOUS] = true; + + return $this->requestAsync($method, $uri, $options)->wait(); + } + + /** + * 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 + */ + public function getConfig(?string $option = null) + { + return $option === null + ? $this->config + : ($this->config[$option] ?? null); + } + + private function buildUri(UriInterface $uri, array $config): UriInterface + { + if (isset($config['base_uri'])) { + $uri = Psr7\UriResolver::resolve(Psr7\Utils::uriFor($config['base_uri']), $uri); + } + + $idnOptions = Utils::normalizeIdnConversionOption($config['idn_conversion'] ?? null); + if ($idnOptions !== null) { + $uri = Utils::idnUriConvert($uri, $idnOptions); + } + + if ($uri->getScheme() === '' && $uri->getHost() !== '') { + $uri = $uri->withScheme('http'); + } + + return $uri; + } + + /** + * Whether to preserve an existing Host header when the URI changes. + * + * A header matching the current URI carries no explicit override and is + * regenerated after base URI resolution or IDN conversion. Other values + * are preserved as deliberate overrides, as PSR-7 requires. + */ + private static function shouldPreserveHost(RequestInterface $request): bool + { + if (!$request->hasHeader('Host')) { + return false; + } + + $uri = $request->getUri(); + $host = $uri->getHost(); + $port = $uri->getPort(); + + if ($port !== null) { + $host .= ':'.$port; + } + + return $host !== $request->getHeaderLine('Host'); + } + + /** + * Configures the default options for a client. + */ + private function configureDefaults(array $config): void + { + $defaults = [ + 'allow_redirects' => RedirectMiddleware::$defaultSettings, + 'http_errors' => true, + 'decode_content' => true, + 'verify' => true, + 'cookies' => false, + 'idn_conversion' => false, + 'protocols' => ['http', 'https'], + ]; + + // Use the standard Linux HTTP_PROXY and HTTPS_PROXY if set. + + // We can only trust the HTTP_PROXY environment variable in a CLI + // process due to the fact that PHP has no reliable mechanism to + // get environment variables that start with "HTTP_". + if (\PHP_SAPI === 'cli' && ($proxy = Utils::getenv('HTTP_PROXY'))) { + $defaults['proxy']['http'] = $proxy; + } + + if ($proxy = Utils::getenv('HTTPS_PROXY')) { + $defaults['proxy']['https'] = $proxy; + } + + if ($noProxy = Utils::getenv('NO_PROXY')) { + $cleanedNoProxy = \str_replace(' ', '', $noProxy); + $defaults['proxy']['no'] = \explode(',', $cleanedNoProxy); + } + + $this->config = $config + $defaults; + + if (!empty($config['cookies']) && $config['cookies'] === true) { + $this->config['cookies'] = new CookieJar(); + } + + // Add the default user-agent header. + if (!isset($this->config['headers'])) { + $this->config['headers'] = ['User-Agent' => Utils::defaultUserAgent()]; + } else { + // Add the User-Agent header if one was not already set. + $hasUserAgent = false; + foreach (\array_keys($this->config['headers']) as $name) { + if (Psr7\Utils::asciiToLower((string) $name) === 'user-agent') { + $hasUserAgent = true; + break; + } + } + + if (!$hasUserAgent) { + $this->config['headers']['User-Agent'] = Utils::defaultUserAgent(); + } + } + + if (\is_array($this->config['headers'])) { + self::warnAboutInvalidHeaderOptionTypes($this->config['headers']); + self::castDeprecatedHeaderOptionValues($this->config['headers']); + } + } + + /** + * Merges default options into the array. + * + * @param array $options Options to modify by reference + */ + private function prepareDefaults(array $options): array + { + self::warnAboutRequestLevelHandler($options); + + $defaults = $this->config; + + if (!empty($defaults['headers'])) { + // Default headers are only added if they are not present. + $defaults['_conditional'] = $defaults['headers']; + unset($defaults['headers']); + } + + // Special handling for headers is required as they are added as + // conditional headers and as headers passed to a request ctor. + if (\array_key_exists('headers', $options)) { + // Allows default headers to be unset. + if ($options['headers'] === null) { + $defaults['_conditional'] = []; + unset($options['headers']); + } elseif (!\is_array($options['headers'])) { + throw new InvalidArgumentException('headers must be an array'); + } + } + + // Shallow merge defaults underneath options. + $result = $options + $defaults; + + // Remove null values. + foreach ($result as $k => $v) { + if ($v === null) { + unset($result[$k]); + } + } + + self::warnAboutInvalidRequestOptionTypes($result); + + return self::normalizeDeprecatedRequestOptionValues($result); + } + + /** + * Normalize values that guzzlehttp/guzzle 8.0 rejects only after the + * corresponding 7.x deprecation has already been emitted. + * + * @param array $options + * + * @return array + */ + private static function normalizeDeprecatedRequestOptionValues(array $options): array + { + self::normalizeDeprecatedAuthOptionValues($options); + self::normalizeDeprecatedTlsFileOptionValues($options, 'cert'); + self::normalizeDeprecatedTlsFileOptionValues($options, 'ssl_key'); + self::normalizeDeprecatedStringOptionValues($options); + self::normalizeDeprecatedNumericOptionValues($options); + self::normalizeDeprecatedIntegerOptionValues($options); + + return $options; + } + + /** + * @param mixed $value + */ + private static function canStringifyDeprecatedValue($value): bool + { + return $value === null + || \is_scalar($value) + || (\is_object($value) && \method_exists($value, '__toString')); + } + + /** + * @param mixed $value + */ + private static function stringifyDeprecatedValue($value): string + { + if (\is_float($value) && !\is_finite($value)) { + return \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF'); + } + + if ($value === null) { + return ''; + } + + if (\is_scalar($value)) { + return (string) $value; + } + + if (\is_object($value) && \method_exists($value, '__toString')) { + return $value->__toString(); + } + + throw new \LogicException('Value is not stringable.'); + } + + /** + * @param array $options + */ + private static function normalizeDeprecatedAuthOptionValues(array &$options): void + { + if (!isset($options['auth']) || !\is_array($options['auth']) || $options['auth'] === []) { + return; + } + + foreach ([0, 1] as $index) { + if ( + \array_key_exists($index, $options['auth']) + && !\is_string($options['auth'][$index]) + && self::canStringifyDeprecatedValue($options['auth'][$index]) + ) { + $options['auth'][$index] = self::stringifyDeprecatedValue($options['auth'][$index]); + } + } + + if ( + \array_key_exists(2, $options['auth']) + && $options['auth'][2] !== null + && !\is_string($options['auth'][2]) + && self::canStringifyDeprecatedValue($options['auth'][2]) + ) { + $options['auth'][2] = self::stringifyDeprecatedValue($options['auth'][2]); + } + } + + /** + * @param array $options + */ + private static function normalizeDeprecatedTlsFileOptionValues(array &$options, string $option): void + { + if (!isset($options[$option]) || !\is_array($options[$option])) { + return; + } + + foreach ([0, 1] as $index) { + if ( + \array_key_exists($index, $options[$option]) + && $options[$option][$index] !== null + && !\is_string($options[$option][$index]) + && self::canStringifyDeprecatedValue($options[$option][$index]) + ) { + $options[$option][$index] = self::stringifyDeprecatedValue($options[$option][$index]); + } + } + } + + /** + * @param array $options + */ + private static function normalizeDeprecatedStringOptionValues(array &$options): void + { + foreach (['cert_type', 'force_ip_resolve', 'ssl_key_type'] as $option) { + if ( + \array_key_exists($option, $options) + && !\is_string($options[$option]) + && self::canStringifyDeprecatedValue($options[$option]) + ) { + $options[$option] = self::stringifyDeprecatedValue($options[$option]); + } + } + } + + /** + * @param array $options + */ + private static function normalizeDeprecatedNumericOptionValues(array &$options): void + { + foreach (['connect_timeout', 'delay', 'read_timeout', 'timeout'] as $option) { + if ( + \array_key_exists($option, $options) + && \is_string($options[$option]) + && \is_numeric($options[$option]) + ) { + $options[$option] = $options[$option] + 0; + } + } + } + + /** + * @param array $options + */ + private static function normalizeDeprecatedIntegerOptionValues(array &$options): void + { + foreach (['crypto_method', 'crypto_method_max', 'retries'] as $option) { + if (!\array_key_exists($option, $options)) { + continue; + } + + if (\is_string($options[$option]) && \preg_match('/^-?\d+$/D', $options[$option]) === 1) { + $options[$option] = (int) $options[$option]; + } elseif ( + \is_float($options[$option]) + && \is_finite($options[$option]) + && $options[$option] === (float) (int) $options[$option] + ) { + $options[$option] = (int) $options[$option]; + } + } + } + + private static function warnAboutRequestLevelHandler(array $options): void + { + if (!\array_key_exists('handler', $options)) { + return; + } + + \trigger_deprecation( + 'guzzlehttp/guzzle', + '7.12', + 'Passing the "handler" request option is deprecated; guzzlehttp/guzzle 8.0 will ignore request-level handlers. Configure the handler when creating the Client, or use a separate Client instance for requests that need a different handler.' + ); + } + + private static function warnAboutInvalidRequestOptionTypes(array $options): void + { + if (isset($options['handler']) && !\is_callable($options['handler'])) { + self::warnInvalidRequestOptionType('handler', 'callable', $options['handler']); + } + + if (isset($options['allow_redirects']) && \is_array($options['allow_redirects'])) { + self::warnAboutInvalidAllowRedirectsOptionTypes($options['allow_redirects']); + } elseif (isset($options['allow_redirects']) && !\is_bool($options['allow_redirects'])) { + self::warnInvalidRequestOptionType('allow_redirects', 'bool|array', $options['allow_redirects'], '7.13'); + } + + if (isset($options['auth'])) { + self::warnAboutInvalidAuthOptionTypes($options['auth']); + } + + if (isset($options['body']) && \is_array($options['body'])) { + self::warnInvalidRequestOptionType('body', 'resource|string|null|int|float|bool|StreamInterface|(callable&object)|\Iterator|\Stringable', $options['body']); + } + + self::warnAboutInvalidTlsFileOptionTypes($options, 'cert'); + self::warnIfPresentAndNotString($options, 'cert_type'); + self::warnIfPresentAndNotNumber($options, 'connect_timeout'); + self::warnIfPresentAndNotInt($options, 'crypto_method'); + self::warnIfPresentAndNotInt($options, 'crypto_method_max', null, '7.13'); + self::warnIfPresentAndNotBoolOrResource($options, 'debug'); + self::warnIfPresentAndNotBoolOrString($options, 'decode_content'); + self::warnIfPresentAndNotNumber($options, 'delay'); + if (isset($options['delay']) && \is_numeric($options['delay'])) { + $delay = (float) $options['delay']; + if (!\is_finite($delay) || $delay < 0.0) { + self::warnInvalidRequestOptionType('delay', 'finite int|float greater than or equal to 0', $options['delay'], '7.13'); + } + } + self::warnIfPresentAndNotBoolOrInt($options, 'expect'); + + if (isset($options['form_params'])) { + self::warnAboutInvalidFormParamTypes($options['form_params']); + } + + if (isset($options['force_ip_resolve']) && !\is_string($options['force_ip_resolve'])) { + self::warnInvalidRequestOptionType('force_ip_resolve', 'string', $options['force_ip_resolve']); + } + + if ( + isset($options['force_ip_resolve']) + && \is_string($options['force_ip_resolve']) + && $options['force_ip_resolve'] !== 'v4' + && $options['force_ip_resolve'] !== 'v6' + ) { + self::warnInvalidRequestOptionType('force_ip_resolve', '"v4"|"v6"', $options['force_ip_resolve'], '7.13'); + } + + if (isset($options['headers'])) { + self::warnAboutInvalidHeaderOptionTypes($options['headers']); + } + + self::warnIfPresentAndNotBool($options, 'http_errors'); + + if (isset($options['multipart'])) { + self::warnAboutInvalidMultipartOptionTypes($options['multipart']); + } + + self::warnIfPresentAndNotCallable($options, 'on_headers'); + self::warnIfPresentAndNotCallable($options, 'on_stats'); + self::warnIfPresentAndNotCallable($options, 'on_trailers', null, '7.14'); + self::warnIfPresentAndNotCallable($options, 'progress'); + self::warnIfPresentAndNotStringArray($options, 'protocols', true); + self::warnAboutInvalidProtocolValues($options, 'protocols'); + self::warnAboutInvalidProxyOptionTypes($options); + + self::warnIfPresentAndNotNumber($options, 'read_timeout'); + self::warnIfPresentAndNotInt($options, 'retries'); + + if (isset($options['sink']) && !\is_bool($options['sink']) && !\is_resource($options['sink']) && !\is_string($options['sink']) && !$options['sink'] instanceof StreamInterface) { + self::warnInvalidRequestOptionType('sink', 'resource|string|StreamInterface', $options['sink']); + } + + self::warnAboutInvalidTlsFileOptionTypes($options, 'ssl_key'); + self::warnIfPresentAndNotString($options, 'ssl_key_type'); + self::warnIfPresentAndNotBool($options, 'stream'); + self::warnIfPresentAndNotArray($options, 'stream_context', 'array'); + self::warnIfPresentAndNotBool($options, 'synchronous'); + self::warnIfPresentAndNotNumber($options, 'timeout'); + self::warnIfPresentAndNotBoolOrString($options, 'verify'); + self::warnIfPresentAndNotStringOrNumber($options, 'version'); + self::warnIfPresentAndNotArray($options, 'curl', 'array'); + + if (isset($options['cookies']) && $options['cookies'] === true) { + self::warnInvalidRequestOptionType('cookies', 'false|CookieJarInterface', $options['cookies']); + } + + if ( + isset($options['cookies']) + && $options['cookies'] !== false + && $options['cookies'] !== true + && !($options['cookies'] instanceof CookieJarInterface) + ) { + self::warnInvalidRequestOptionType('cookies', 'false|CookieJarInterface', $options['cookies'], '7.13'); + } + } + + private static function warnAboutInvalidAllowRedirectsOptionTypes(array $allowRedirects): void + { + self::warnIfPresentAndNotInt($allowRedirects, 'max', 'allow_redirects.max'); + self::warnIfPresentAndNotBool($allowRedirects, 'strict', 'allow_redirects.strict'); + self::warnIfPresentAndNotBool($allowRedirects, 'referer', 'allow_redirects.referer'); + self::warnIfPresentAndNotStringArray($allowRedirects, 'protocols', true, 'allow_redirects.protocols'); + self::warnAboutInvalidProtocolValues($allowRedirects, 'protocols', 'allow_redirects.protocols'); + self::warnIfPresentAndNotCallable($allowRedirects, 'on_redirect', 'allow_redirects.on_redirect'); + self::warnIfPresentAndNotBool($allowRedirects, 'track_redirects', 'allow_redirects.track_redirects'); + } + + /** + * @param mixed $auth + */ + private static function warnAboutInvalidAuthOptionTypes($auth): void + { + if ($auth === false || \is_string($auth) || $auth === []) { + return; + } + + if (!\is_array($auth)) { + self::warnInvalidRequestOptionType('auth', 'array{0: string, 1: string, 2?: string|null}|string|false|null', $auth); + + return; + } + + if (!\array_key_exists(0, $auth) || !\is_string($auth[0])) { + self::warnInvalidRequestOptionType('auth.0', 'string', $auth[0] ?? null); + } + + if (!\array_key_exists(1, $auth) || !\is_string($auth[1])) { + self::warnInvalidRequestOptionType('auth.1', 'string', $auth[1] ?? null); + } + + if (\array_key_exists(2, $auth) && $auth[2] !== null && !\is_string($auth[2])) { + self::warnInvalidRequestOptionType('auth.2', 'string|null', $auth[2]); + } + } + + /** + * @param mixed $value + */ + private static function warnAboutInvalidFormParamTypes($value): void + { + if (!\is_array($value)) { + self::warnInvalidRequestOptionType('form_params', 'array', $value); + + return; + } + + self::warnAboutInvalidFormParamArray($value, 'form_params'); + } + + private static function warnAboutInvalidFormParamArray(array $values, string $path): bool + { + foreach ($values as $key => $item) { + $itemPath = $path.'.'.(string) $key; + if (\is_array($item)) { + if (!self::warnAboutInvalidFormParamArray($item, $itemPath)) { + return false; + } + + continue; + } + + if ($item !== null && !\is_scalar($item)) { + self::warnInvalidRequestOptionType($itemPath, 'string|int|float|bool|null|array', $item); + + return false; + } + } + + return true; + } + + /** + * @param mixed $headers + */ + private static function warnAboutInvalidHeaderOptionTypes($headers): void + { + if (!\is_array($headers)) { + self::warnInvalidRequestOptionType('headers', 'array>|null', $headers); + + return; + } + + foreach ($headers as $name => $value) { + $path = 'headers.'.(string) $name; + if (\is_array($value)) { + if ($value === []) { + self::warnInvalidRequestOptionType($path, 'string|non-empty-array', $value); + + break; + } + + foreach ($value as $index => $item) { + if (!\is_string($item)) { + self::warnInvalidRequestOptionType($path.'.'.(string) $index, 'string', $item); + + break 2; + } + } + } elseif (!\is_string($value)) { + self::warnInvalidRequestOptionType($path, 'string|non-empty-array', $value); + + break; + } + } + } + + /** + * @param mixed $multipart + */ + private static function warnAboutInvalidMultipartOptionTypes($multipart): void + { + if (!\is_array($multipart)) { + self::warnInvalidRequestOptionType('multipart', 'array, filename?: string}>', $multipart); + + return; + } + + foreach ($multipart as $index => $part) { + $path = 'multipart.'.(string) $index; + if (!\is_array($part)) { + self::warnInvalidRequestOptionType($path, 'array{name: string|int, contents: mixed, headers?: array, filename?: string}', $part); + + return; + } + + if (!\array_key_exists('name', $part) || (!\is_string($part['name']) && !\is_int($part['name']))) { + self::warnInvalidRequestOptionType($path.'.name', 'string|int', $part['name'] ?? null); + } + + if (!\array_key_exists('contents', $part)) { + self::warnInvalidRequestOptionType($path, 'array{name: string|int, contents: mixed, headers?: array, filename?: string}', $part); + } + + if (\array_key_exists('headers', $part)) { + if (!\is_array($part['headers'])) { + self::warnInvalidRequestOptionType($path.'.headers', 'array', $part['headers']); + } else { + foreach ($part['headers'] as $name => $value) { + if (!\is_string($value)) { + self::warnInvalidRequestOptionType($path.'.headers.'.(string) $name, 'string', $value); + + break 2; + } + } + } + } + + if (\array_key_exists('filename', $part) && !\is_string($part['filename'])) { + self::warnInvalidRequestOptionType($path.'.filename', 'string', $part['filename']); + } + } + } + + private static function warnAboutInvalidProxyOptionTypes(array $options): void + { + if (!isset($options['proxy'])) { + return; + } + + if (!\is_string($options['proxy']) && !\is_array($options['proxy'])) { + self::warnInvalidRequestOptionType('proxy', 'string|array{http?: string|null, https?: string|null, no?: string|array|null}', $options['proxy']); + + return; + } + + if (!\is_array($options['proxy'])) { + return; + } + + foreach (['http', 'https'] as $scheme) { + if (\array_key_exists($scheme, $options['proxy']) && $options['proxy'][$scheme] !== null && !\is_string($options['proxy'][$scheme])) { + self::warnInvalidRequestOptionType('proxy.'.$scheme, 'string|null', $options['proxy'][$scheme]); + } + } + + if (!\array_key_exists('no', $options['proxy']) || $options['proxy']['no'] === null) { + return; + } + + if (\is_string($options['proxy']['no'])) { + return; + } + + if (!\is_array($options['proxy']['no'])) { + self::warnInvalidRequestOptionType('proxy.no', 'string|array|null', $options['proxy']['no']); + + return; + } + + foreach ($options['proxy']['no'] as $index => $noProxy) { + if (!\is_string($noProxy)) { + self::warnInvalidRequestOptionType('proxy.no.'.(string) $index, 'string', $noProxy); + + return; + } + } + } + + private static function warnAboutInvalidTlsFileOptionTypes(array $options, string $option): void + { + if (!isset($options[$option])) { + return; + } + + if (\is_string($options[$option])) { + return; + } + + if (!\is_array($options[$option])) { + self::warnInvalidRequestOptionType($option, 'string|array{0: string, 1?: string}', $options[$option]); + + return; + } + + if (!\array_key_exists(0, $options[$option]) || !\is_string($options[$option][0])) { + self::warnInvalidRequestOptionType($option.'.0', 'string', $options[$option][0] ?? null); + } + + if (\array_key_exists(1, $options[$option]) && $options[$option][1] !== null && !\is_string($options[$option][1])) { + self::warnInvalidRequestOptionType($option.'.1', 'string|null', $options[$option][1]); + } + } + + private static function warnIfPresentAndNotArray(array $options, string $option, string $expected): void + { + if (\array_key_exists($option, $options) && !\is_array($options[$option])) { + self::warnInvalidRequestOptionType($option, $expected, $options[$option]); + } + } + + private static function warnIfPresentAndNotBool(array $options, string $option, ?string $path = null): void + { + if (\array_key_exists($option, $options) && !\is_bool($options[$option])) { + self::warnInvalidRequestOptionType($path ?? $option, 'bool', $options[$option]); + } + } + + private static function warnIfPresentAndNotBoolOrInt(array $options, string $option): void + { + if (\array_key_exists($option, $options) && !\is_bool($options[$option]) && !\is_int($options[$option])) { + self::warnInvalidRequestOptionType($option, 'bool|int', $options[$option]); + } + } + + private static function warnIfPresentAndNotBoolOrResource(array $options, string $option): void + { + if (\array_key_exists($option, $options) && !\is_bool($options[$option]) && !\is_resource($options[$option])) { + self::warnInvalidRequestOptionType($option, 'bool|resource', $options[$option]); + } + } + + private static function warnIfPresentAndNotBoolOrString(array $options, string $option): void + { + if (\array_key_exists($option, $options) && !\is_bool($options[$option]) && !\is_string($options[$option])) { + self::warnInvalidRequestOptionType($option, 'bool|string', $options[$option]); + } + } + + private static function warnIfPresentAndNotCallable( + array $options, + string $option, + ?string $path = null, + string $since = '7.11' + ): void { + if (\array_key_exists($option, $options) && !\is_callable($options[$option])) { + self::warnInvalidRequestOptionType($path ?? $option, 'callable', $options[$option], $since); + } + } + + private static function warnIfPresentAndNotInt( + array $options, + string $option, + ?string $path = null, + string $since = '7.11' + ): void { + if (\array_key_exists($option, $options) && !\is_int($options[$option])) { + self::warnInvalidRequestOptionType($path ?? $option, 'int', $options[$option], $since); + } + } + + private static function warnIfPresentAndNotNumber(array $options, string $option): void + { + if (\array_key_exists($option, $options) && !\is_int($options[$option]) && !\is_float($options[$option])) { + self::warnInvalidRequestOptionType($option, 'int|float', $options[$option]); + } + } + + private static function warnIfPresentAndNotString(array $options, string $option): void + { + if (\array_key_exists($option, $options) && !\is_string($options[$option])) { + self::warnInvalidRequestOptionType($option, 'string', $options[$option]); + } + } + + private static function warnIfPresentAndNotStringArray(array $options, string $option, bool $nonEmpty, ?string $path = null): void + { + if (!\array_key_exists($option, $options)) { + return; + } + + $path = $path ?? $option; + $expected = ($nonEmpty ? 'non-empty-' : '').'array'; + + if (!\is_array($options[$option]) || ($nonEmpty && $options[$option] === [])) { + self::warnInvalidRequestOptionType($path, $expected, $options[$option]); + + return; + } + + foreach ($options[$option] as $index => $item) { + if (!\is_string($item)) { + self::warnInvalidRequestOptionType($path.'.'.(string) $index, 'string', $item); + + return; + } + } + } + + /** + * @param array $options + */ + private static function warnAboutInvalidProtocolValues(array $options, string $option, ?string $path = null): void + { + if (!isset($options[$option]) || !\is_array($options[$option])) { + return; + } + + $path = $path ?? $option; + + foreach ($options[$option] as $index => $protocol) { + if (\is_string($protocol) && $protocol !== 'http' && $protocol !== 'https') { + self::warnInvalidRequestOptionType($path.'.'.(string) $index, '"http"|"https"', $protocol, '7.13'); + } + } + } + + private static function warnIfPresentAndNotStringOrNumber(array $options, string $option): void + { + if ( + \array_key_exists($option, $options) + && !\is_string($options[$option]) + && !\is_int($options[$option]) + && !\is_float($options[$option]) + ) { + self::warnInvalidRequestOptionType($option, 'string|int|float', $options[$option]); + } + } + + /** + * @param mixed $value + */ + private static function warnInvalidRequestOptionType(string $option, string $expected, $value, string $since = '7.11'): void + { + \trigger_deprecation( + 'guzzlehttp/guzzle', + $since, + 'Passing %s to request option "%s" is deprecated; guzzlehttp/guzzle 8.0 requires %s.', + \get_debug_type($value), + $option, + $expected + ); + } + + /** + * Transfers the given request and applies request options. + * + * The URI of the request is not modified and the request options are used + * as-is without merging in default options. + * + * @param array $options See {@see RequestOptions}. + */ + private function transfer(RequestInterface $request, array $options): PromiseInterface + { + $request = $this->applyOptions($request, $options); + + $protocolVersion = $request->getProtocolVersion(); + + if ('' === $protocolVersion) { + \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Sending a request with an empty protocol version is deprecated; guzzlehttp/guzzle 8.0 will reject empty protocol versions.'); + + $request = Psr7\Utils::modifyRequest($request, ['version' => '1.1']); + } elseif (!self::isProtocolVersionValid($protocolVersion)) { + \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Sending a request with a malformed protocol version is deprecated; guzzlehttp/guzzle 8.0 will reject malformed protocol versions.'); + } + + /** @var HandlerStack $handler */ + $handler = $options['handler']; + + try { + return P\Create::promiseFor($handler($request, $options)); + } catch (\Exception $e) { + return P\Create::rejectionFor($e); + } + } + + /** + * Applies the array of request options to a request. + */ + private function applyOptions(RequestInterface $request, array &$options): RequestInterface + { + $modify = [ + 'set_headers' => [], + ]; + + if (isset($options['headers'])) { + if (array_keys($options['headers']) === range(0, count($options['headers']) - 1)) { + throw new InvalidArgumentException('The headers array must have header name as keys.'); + } + $headers = $options['headers']; + $droppedHeaderNames = self::castDeprecatedHeaderOptionValues($headers); + if ($droppedHeaderNames !== [] && isset($options['_conditional'])) { + $options['_conditional'] = Psr7\Utils::caselessRemove($droppedHeaderNames, $options['_conditional']); + } + $modify['set_headers'] = $headers; + unset($options['headers']); + } + + if (isset($options['form_params'])) { + if (isset($options['multipart'])) { + throw new InvalidArgumentException('You cannot use ' + .'form_params and multipart at the same time. Use the ' + .'form_params option if you want to send application/' + .'x-www-form-urlencoded requests, and the multipart ' + .'option to send multipart/form-data requests.'); + } + $options['body'] = \http_build_query(self::normalizeNonFiniteFloats($options['form_params'], 'form_params'), '', '&'); + unset($options['form_params']); + // Ensure that we don't have the header in different case and set the new value. + $options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']); + $options['_conditional']['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + if (isset($options['multipart'])) { + $options['body'] = new Psr7\MultipartStream($options['multipart']); + unset($options['multipart']); + } + + if (isset($options['json'])) { + $json = \json_encode($options['json']); + if (\JSON_ERROR_NONE !== \json_last_error()) { + throw new InvalidArgumentException('json_encode error: '.\json_last_error_msg()); + } + + /** @var non-empty-string $json */ + $options['body'] = $json; + unset($options['json']); + // Ensure that we don't have the header in different case and set the new value. + $options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']); + $options['_conditional']['Content-Type'] = 'application/json'; + } + + if (isset($options['decode_content']) && \is_string($options['decode_content'])) { + // Ensure that we don't have the header in different case and set the new value. + $options['_conditional'] = Psr7\Utils::caselessRemove(['Accept-Encoding'], $options['_conditional']); + $modify['set_headers']['Accept-Encoding'] = (string) $options['decode_content']; + } + + if (isset($options['body'])) { + if (\is_array($options['body'])) { + throw $this->invalidBody(); + } + $modify['body'] = self::createBodyStream($options['body']); + unset($options['body']); + } + + if (!empty($options['auth']) && \is_array($options['auth'])) { + $value = $options['auth']; + $type = isset($value[2]) ? Psr7\Utils::asciiToLower($value[2]) : 'basic'; + switch ($type) { + case 'basic': + // Ensure that we don't have the header in different case and set the new value. + $modify['set_headers'] = Psr7\Utils::caselessRemove(['Authorization'], $modify['set_headers']); + $modify['set_headers']['Authorization'] = 'Basic ' + .\base64_encode("$value[0]:$value[1]"); + break; + case 'digest': + // @todo: Do not rely on curl + $options['curl'][\CURLOPT_HTTPAUTH] = \CURLAUTH_DIGEST; + $options['curl'][\CURLOPT_USERPWD] = "$value[0]:$value[1]"; + break; + case 'ntlm': + \trigger_deprecation( + 'guzzlehttp/guzzle', + '7.12', + 'Passing "ntlm" as the built-in auth type is deprecated; guzzlehttp/guzzle 8.0 will no longer apply NTLM through the "auth" request option. NTLM is also deprecated by curl/libcurl and may be unavailable in current or future libcurl builds. Avoid NTLM; if you must use it temporarily, configure cURL HTTP authentication options directly with a libcurl build that still supports NTLM.' + ); + if (!CurlVersion::supportsNtlm()) { + throw new InvalidArgumentException('NTLM authentication is not available because the installed curl/libcurl build does not provide NTLM support.'); + } + $options['curl'][\CURLOPT_HTTPAUTH] = \CURLAUTH_NTLM; + $options['curl'][\CURLOPT_USERPWD] = "$value[0]:$value[1]"; + break; + } + } + + if (isset($options['query'])) { + $value = $options['query']; + if (\is_array($value)) { + $value = \http_build_query(self::normalizeNonFiniteFloats($value, 'query'), '', '&', \PHP_QUERY_RFC3986); + } + if (!\is_string($value)) { + throw new InvalidArgumentException('query must be a string or array'); + } + $modify['query'] = $value; + unset($options['query']); + } + + // Ensure that sink is not an invalid value. + if (isset($options['sink'])) { + // TODO: Add more sink validation? + if (\is_bool($options['sink'])) { + throw new InvalidArgumentException('sink must not be a boolean'); + } + } + + if (isset($options['version'])) { + $modify['version'] = self::normalizeProtocolVersion($options['version']); + } + + $request = Psr7\Utils::modifyRequest($request, $modify); + if ($request->getBody() instanceof Psr7\MultipartStream) { + // Use a multipart/form-data POST if a Content-Type is not set. + // Ensure that we don't have the header in different case and set the new value. + $options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']); + $options['_conditional']['Content-Type'] = 'multipart/form-data; boundary=' + .$request->getBody()->getBoundary(); + } + + // Merge in conditional headers if they are not present. + if (isset($options['_conditional'])) { + // Build up the changes so it's in a single clone of the message. + $modify = []; + foreach ($options['_conditional'] as $k => $v) { + $name = (string) $k; + if (!$request->hasHeader($name)) { + $modify['set_headers'][$name] = $v; + } + } + $request = Psr7\Utils::modifyRequest($request, $modify); + // Don't pass this internal value along to middleware/handlers. + unset($options['_conditional']); + } + + return $request; + } + + /** + * @param array $headers + * + * @return list + */ + private static function castDeprecatedHeaderOptionValues(array &$headers): array + { + $droppedHeaderNames = []; + + foreach ($headers as $name => $value) { + if (\is_array($value)) { + if ($value === []) { + $droppedHeaderNames[] = (string) $name; + unset($headers[$name]); + + continue; + } + + foreach ($value as $index => $item) { + if ($item === null || (!\is_string($item) && \is_scalar($item))) { + if (\is_float($item) && !\is_finite($item)) { + $item = \is_nan($item) ? 'NAN' : ($item > 0 ? 'INF' : '-INF'); + } + $value[$index] = (string) $item; + } + } + + $headers[$name] = $value; + + continue; + } + + if ($value === null || (!\is_string($value) && \is_scalar($value))) { + if (\is_float($value) && !\is_finite($value)) { + $value = \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF'); + } + $headers[$name] = (string) $value; + } + } + + return $droppedHeaderNames; + } + + /** + * @param mixed $body + */ + private static function createBodyStream($body): StreamInterface + { + if ($body instanceof StreamInterface) { + return $body; + } + + if (\is_resource($body) || $body === null || \is_string($body) || $body instanceof \Iterator) { + return Psr7\Utils::streamFor($body); + } + + if (\is_scalar($body)) { + \trigger_deprecation('guzzlehttp/guzzle', '7.12', 'Passing a non-string scalar to the "body" request option is deprecated; guzzlehttp/guzzle 8.0 will reject non-string scalar bodies.'); + + return Psr7\Utils::streamFor(self::stringifyScalar($body)); + } + + if (\is_object($body) && \method_exists($body, '__toString')) { + return Psr7\Utils::streamFor((string) $body); + } + + if (\is_callable($body)) { + return Psr7\Utils::streamFor($body); + } + + throw new InvalidArgumentException(\sprintf( + 'Passing %s to request option "body" is invalid; expected resource|string|null|int|float|bool|StreamInterface|callable&object|Iterator|Stringable.', + \get_debug_type($body) + )); + } + + /** + * @param bool|float|int|string $value + */ + private static function stringifyScalar($value): string + { + // Normalize non-finite floats to dodge PHP 8.5's (string) NAN + // coercion warning while the value is still accepted. + if (\is_float($value) && !\is_finite($value)) { + $value = \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF'); + } + + return (string) $value; + } + + /** + * Converts non-finite floats in the array to the strings PHP coerces + * them to, as implicit coercion of NAN emits a warning on PHP 8.5. + */ + private static function normalizeNonFiniteFloats(array $values, string $option): array + { + foreach ($values as $key => $value) { + if (\is_array($value)) { + $values[$key] = self::normalizeNonFiniteFloats($value, $option); + } elseif (\is_float($value) && !\is_finite($value)) { + \trigger_deprecation('guzzlehttp/guzzle', '7.12', 'Passing a non-finite float in the "%s" request option is deprecated; guzzlehttp/guzzle 8.0 will reject non-finite floats.', $option); + + $values[$key] = \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF'); + } + } + + return $values; + } + + /** + * @param string|int|float $version + */ + private static function normalizeProtocolVersion($version): string + { + if ('' === $version) { + \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing an empty "version" request option is deprecated; guzzlehttp/guzzle 8.0 will reject empty protocol versions.'); + + return '1.1'; + } + + return \is_float($version) ? \number_format($version, 1, '.', '') : (string) $version; + } + + private static function isProtocolVersionValid(string $version): bool + { + return 1 === \preg_match('/^\d+(?:\.\d+)?$/D', $version); + } + + /** + * Return an InvalidArgumentException with pre-set message. + */ + private function invalidBody(): InvalidArgumentException + { + return new InvalidArgumentException('Passing in the "body" request ' + .'option as an array to send a request is not supported. ' + .'Please use the "form_params" request option to send a ' + .'application/x-www-form-urlencoded request, or the "multipart" ' + .'request option to send a multipart/form-data request.'); + } } diff --git a/src/Client/lib/Lib/GuzzleHttp/ClientInterface.php b/src/Client/lib/Lib/GuzzleHttp/ClientInterface.php index 3d0aa3bc..6d1c48d8 100644 --- a/src/Client/lib/Lib/GuzzleHttp/ClientInterface.php +++ b/src/Client/lib/Lib/GuzzleHttp/ClientInterface.php @@ -78,7 +78,7 @@ public function requestAsync(string $method, $uri, array $options = []): Promise * * @return mixed * - * @deprecated ClientInterface::getConfig will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. + * @deprecated ClientInterface::getConfig will be removed in guzzlehttp/guzzle:8.0. */ public function getConfig(?string $option = null); } diff --git a/src/Client/lib/Lib/GuzzleHttp/Cookie/CookieJar.php b/src/Client/lib/Lib/GuzzleHttp/Cookie/CookieJar.php index cbb16e16..a3822fc3 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Cookie/CookieJar.php +++ b/src/Client/lib/Lib/GuzzleHttp/Cookie/CookieJar.php @@ -2,6 +2,7 @@ namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\ResponseInterface; @@ -10,6 +11,11 @@ */ 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 */ @@ -32,7 +38,7 @@ public function __construct(bool $strictMode = false, array $cookieArray = []) $this->strictMode = $strictMode; foreach ($cookieArray as $cookie) { - if (!($cookie instanceof SetCookie)) { + if (!$cookie instanceof SetCookie) { $cookie = new SetCookie($cookie); } $this->setCookie($cookie); @@ -88,7 +94,7 @@ public static function shouldPersist(SetCookie $cookie, bool $allowSessionCookie public function getCookieByName(string $name): ?SetCookie { foreach ($this->cookies as $cookie) { - if ($cookie->getName() !== null && \strcasecmp($cookie->getName(), $name) === 0) { + if ($cookie->getName() !== null && Psr7\Utils::caselessEquals($cookie->getName(), $name)) { return $cookie; } } @@ -103,24 +109,25 @@ public function toArray(): array }, $this->getIterator()->getArrayCopy()); } - public function clear( ?string $domain = null, ?string $path = null, ?string $name = null ): void + public function clear(?string $domain = null, ?string $path = null, ?string $name = null): void { - if (!$domain) { + if ($domain === null) { $this->cookies = []; return; - } elseif (!$path) { + } elseif ($path === null) { $this->cookies = \array_filter( $this->cookies, static function (SetCookie $cookie) use ($domain): bool { - return !$cookie->matchesDomain($domain); + 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 { - return !($cookie->matchesPath($path) + return !($cookie->getDomain() !== null + && $cookie->matchesPath($path) && $cookie->matchesDomain($domain)); } ); @@ -128,7 +135,8 @@ static function (SetCookie $cookie) use ($path, $domain): bool { $this->cookies = \array_filter( $this->cookies, static function (SetCookie $cookie) use ($path, $domain, $name) { - return !($cookie->getName() == $name + return !($cookie->getDomain() !== null + && $cookie->getName() === $name && $cookie->matchesPath($path) && $cookie->matchesDomain($domain)); } @@ -166,13 +174,23 @@ public function setCookie(SetCookie $cookie): bool 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; } @@ -222,10 +240,23 @@ public function getIterator(): \ArrayIterator public function extractCookies(RequestInterface $request, ResponseInterface $response): void { if ($cookieHeader = $response->getHeader('Set-Cookie')) { + $accepted = 0; foreach ($cookieHeader as $cookie) { + if (\strlen($cookie) > self::MAX_SET_COOKIE_FIELD_LENGTH) { + continue; + } + $sc = SetCookie::fromString($cookie); - if (!$sc->getDomain()) { + $domain = $sc->getDomain(); + if ($domain === null || $domain === '') { $sc->setDomain($request->getUri()->getHost()); + $sc->setHostOnly(true); + } elseif (\substr($domain, -1) === '.' && '' !== \trim($domain, '.')) { + // Keep pure-dot domains rejected by the dot-only fix. + $sc->setDomain($request->getUri()->getHost()); + $sc->setHostOnly(true); + } else { + $sc->setHostOnly(false); } if (0 !== \strpos($sc->getPath(), '/')) { $sc->setPath($this->getCookiePathFromRequest($request)); @@ -235,7 +266,9 @@ public function extractCookies(RequestInterface $request, ResponseInterface $res } // 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; + } } } } @@ -243,7 +276,7 @@ public function extractCookies(RequestInterface $request, ResponseInterface $res /** * Computes cookie path following RFC 6265 section 5.1.4 * - * @see https://tools.ietf.org/html/rfc6265#section-5.1.4 + * @see https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4 */ private function getCookiePathFromRequest(RequestInterface $request): string { @@ -268,19 +301,32 @@ private function getCookiePathFromRequest(RequestInterface $request): string public function withCookieHeader(RequestInterface $request): RequestInterface { $values = []; + $headerLength = 8; $uri = $request->getUri(); $scheme = $uri->getScheme(); $host = $uri->getHost(); $path = $uri->getPath() ?: '/'; foreach ($this->cookies as $cookie) { - if ($cookie->matchesPath($path) + if ($cookie->getDomain() !== null + && $cookie->matchesPath($path) && $cookie->matchesDomain($host) && !$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; + } } } @@ -296,12 +342,37 @@ public function withCookieHeader(RequestInterface $request): RequestInterface private function removeCookieIfEmpty(SetCookie $cookie): void { $cookieValue = $cookie->getValue(); - if ($cookieValue === null || $cookieValue === '') { - $this->clear( - $cookie->getDomain(), - $cookie->getPath(), - $cookie->getName() - ); + if (($cookieValue === null || $cookieValue === '') && $cookie->getDomain() !== null) { + $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() + && self::cookieDomainsEqual($stored->getDomain(), $cookie->getDomain()) + && $stored->getHostOnly() === $cookie->getHostOnly()); + } + ); + } + + private static function cookieDomainsEqual(?string $first, ?string $second): bool + { + if ($first === null || $second === null) { + return $first === $second; + } + + if (isset($first[0]) && $first[0] === '.') { + $first = \substr($first, 1); + } + if (isset($second[0]) && $second[0] === '.') { + $second = \substr($second, 1); + } + + return Psr7\Utils::caselessEquals($first, $second); + } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Cookie/CookieJarInterface.php b/src/Client/lib/Lib/GuzzleHttp/Cookie/CookieJarInterface.php index e1061eff..35766ab6 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Cookie/CookieJarInterface.php +++ b/src/Client/lib/Lib/GuzzleHttp/Cookie/CookieJarInterface.php @@ -62,7 +62,7 @@ public function setCookie(SetCookie $cookie): bool; * @param string|null $path Clears cookies matching a domain and path * @param string|null $name Clears cookies matching a domain, path, and name */ - public function clear( ?string $domain = null, ?string $path = null, ?string $name = null ): void; + public function clear(?string $domain = null, ?string $path = null, ?string $name = null): void; /** * Discard all sessions cookies. diff --git a/src/Client/lib/Lib/GuzzleHttp/Cookie/FileCookieJar.php b/src/Client/lib/Lib/GuzzleHttp/Cookie/FileCookieJar.php index b37390e0..3875e442 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Cookie/FileCookieJar.php +++ b/src/Client/lib/Lib/GuzzleHttp/Cookie/FileCookieJar.php @@ -2,7 +2,7 @@ namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie; -use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Utils; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\InvalidArgumentException; /** * Persists non-session cookies using a JSON formatted file @@ -60,11 +60,18 @@ public function save(string $filename): void /** @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); + $jsonStr = \json_encode($json); + if (\JSON_ERROR_NONE !== \json_last_error()) { + throw new InvalidArgumentException('json_encode error: '.\json_last_error_msg()); + } + + /** @var non-empty-string $jsonStr */ if (false === \file_put_contents($filename, $jsonStr, \LOCK_EX)) { throw new \RuntimeException("Unable to save file {$filename}"); } @@ -89,10 +96,23 @@ public function load(string $filename): void return; } - $data = Utils::jsonDecode($json, true); + $data = \json_decode($json, true); + if (\JSON_ERROR_NONE !== \json_last_error()) { + throw new InvalidArgumentException('json_decode error: '.\json_last_error_msg()); + } + if (\is_array($data)) { + $cookies = []; foreach ($data as $cookie) { - $this->setCookie(new SetCookie($cookie)); + if (!\is_array($cookie) || !\array_key_exists('HostOnly', $cookie) || !\is_bool($cookie['HostOnly'])) { + throw new \RuntimeException("Invalid cookie file: {$filename}"); + } + + $cookies[] = new SetCookie($cookie); + } + + foreach ($cookies as $cookie) { + $this->setCookie($cookie); } } elseif (\is_scalar($data) && !empty($data)) { throw new \RuntimeException("Invalid cookie file: {$filename}"); diff --git a/src/Client/lib/Lib/GuzzleHttp/Cookie/SessionCookieJar.php b/src/Client/lib/Lib/GuzzleHttp/Cookie/SessionCookieJar.php index c811fbe2..dc218ce9 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Cookie/SessionCookieJar.php +++ b/src/Client/lib/Lib/GuzzleHttp/Cookie/SessionCookieJar.php @@ -50,11 +50,18 @@ public function save(): void /** @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; } } - $_SESSION[$this->sessionKey] = \json_encode($json); + $json = \json_encode($json); + if (false === $json) { + throw new \RuntimeException('Unable to encode cookie data'); + } + + $_SESSION[$this->sessionKey] = $json; } /** @@ -65,12 +72,27 @@ protected function load(): void if (!isset($_SESSION[$this->sessionKey])) { return; } - $data = \json_decode($_SESSION[$this->sessionKey], true); + + $json = $_SESSION[$this->sessionKey]; + if (!\is_string($json)) { + throw new \RuntimeException('Invalid cookie data'); + } + + $data = \json_decode($json, true); if (\is_array($data)) { + $cookies = []; foreach ($data as $cookie) { - $this->setCookie(new SetCookie($cookie)); + if (!\is_array($cookie) || !\array_key_exists('HostOnly', $cookie) || !\is_bool($cookie['HostOnly'])) { + throw new \RuntimeException('Invalid cookie data'); + } + + $cookies[] = new SetCookie($cookie); + } + + foreach ($cookies as $cookie) { + $this->setCookie($cookie); } - } elseif (\strlen($data)) { + } elseif (\is_scalar($data) && \strlen((string) $data)) { throw new \RuntimeException('Invalid cookie data'); } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Cookie/SetCookie.php b/src/Client/lib/Lib/GuzzleHttp/Cookie/SetCookie.php index b0523ce8..0fbfb8e2 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Cookie/SetCookie.php +++ b/src/Client/lib/Lib/GuzzleHttp/Cookie/SetCookie.php @@ -2,6 +2,9 @@ namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Handler\HostValidator; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7; + /** * Set-Cookie object */ @@ -27,6 +30,11 @@ class SetCookie */ private $data; + /** + * @var bool Whether this cookie was set without a Domain attribute + */ + private $hostOnly = false; + /** * Create a new SetCookie object from a string. * @@ -37,7 +45,9 @@ public static function fromString(string $cookie): self // Create the default return array $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, " \n\r\t\0\x0B"); + }, \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,7 +56,7 @@ public static function fromString(string $cookie): self // 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], " \n\r\t\0\x0B"); $value = isset($cookieParts[1]) ? \trim($cookieParts[1], " \n\r\t\0\x0B") : true; @@ -57,17 +67,24 @@ public static function fromString(string $cookie): self $data['Value'] = $value; } else { foreach (\array_keys(self::$defaults) as $search) { - if (!\strcasecmp($search, $key)) { + if (Psr7\Utils::caselessEquals($search, $key)) { if ($search === 'Max-Age') { if (is_numeric($value)) { $data[$search] = (int) $value; } + } elseif ($search === 'Secure' || $search === 'Discard' || $search === 'HttpOnly') { + if ($value) { + $data[$search] = true; + } } else { $data[$search] = $value; } continue 2; } } + if (Psr7\Utils::caselessEquals('HostOnly', $key)) { + continue; + } $data[$key] = $value; } } @@ -82,6 +99,14 @@ public function __construct(array $data = []) { $this->data = self::$defaults; + if (\array_key_exists('HostOnly', $data)) { + if (!\is_bool($data['HostOnly'])) { + throw new \InvalidArgumentException('Cookie field "HostOnly" must be a boolean'); + } + $this->setHostOnly($data['HostOnly']); + unset($data['HostOnly']); + } + if (isset($data['Name'])) { $this->setName($data['Name']); } @@ -124,18 +149,34 @@ public function __construct(array $data = []) } // Extract the Expires value and turn it into a UNIX timestamp if needed - if (!$this->getExpires() && $this->getMaxAge()) { + $maxAge = $this->getMaxAge(); + if (!$this->getExpires() && $maxAge !== null) { // Calculate the Expires date - $this->setExpires(\time() + $this->getMaxAge()); + $this->setExpires(self::maxAgeToExpires($maxAge, \time())); } elseif (null !== ($expires = $this->getExpires()) && !\is_numeric($expires)) { $this->setExpires($expires); } } + private static function maxAgeToExpires(int $maxAge, int $now): int + { + if ($maxAge <= 0) { + return $now - 1; + } + if ($maxAge > \PHP_INT_MAX - $now) { + return \PHP_INT_MAX; + } + + return $now + $maxAge; + } + public function __toString() { $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).'; '; @@ -150,7 +191,12 @@ public function __toString() public function toArray(): array { - return $this->data; + $data = $this->data; + if ($this->getHostOnly()) { + $data['HostOnly'] = true; + } + + return $data; } /** @@ -171,7 +217,7 @@ public function getName() public function setName($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__); + \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; @@ -195,7 +241,7 @@ public function getValue() public function setValue($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__); + \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; @@ -219,12 +265,32 @@ public function getDomain() public function setDomain($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__); + \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 : (string) $domain; } + /** + * Get whether this cookie is scoped to the origin host only. + * + * @return bool + */ + public function getHostOnly() + { + 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. * @@ -243,7 +309,7 @@ public function getPath() public function setPath($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__); + \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; @@ -267,7 +333,7 @@ public function getMaxAge() public function setMaxAge($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__); + \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; @@ -291,10 +357,18 @@ public function getExpires() 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__); + \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__); } - $this->data['Expires'] = null === $timestamp ? null : (\is_numeric($timestamp) ? (int) $timestamp : \strtotime((string) $timestamp)); + if (null === $timestamp) { + $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; + } } /** @@ -315,7 +389,7 @@ public function getSecure() public function setSecure($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__); + \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; @@ -339,7 +413,7 @@ public function getDiscard() public function setDiscard($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__); + \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; @@ -363,7 +437,7 @@ public function getHttpOnly() public function setHttpOnly($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__); + \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; @@ -389,7 +463,7 @@ public function matchesPath(string $requestPath): bool $cookiePath = $this->getPath(); // Match on exact matches or when path is the default empty "/" - if ($cookiePath === '/' || $cookiePath == $requestPath) { + if ($cookiePath === '/' || $cookiePath === $requestPath) { return true; } @@ -416,27 +490,79 @@ public function matchesDomain(string $domain): bool { $cookieDomain = $this->getDomain(); if (null === $cookieDomain) { - return true; + return !$this->getHostOnly(); } - // Remove the leading '.' as per spec in RFC 6265. - // https://tools.ietf.org/html/rfc6265#section-5.2.3 - $cookieDomain = \ltrim(\strtolower($cookieDomain), '.'); + if ($this->getHostOnly()) { + return Psr7\Utils::asciiToLower($domain) === Psr7\Utils::asciiToLower($cookieDomain); + } - $domain = \strtolower($domain); + // Remove the leading '.' as per spec in RFC 6265. + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3 + $cookieDomain = Psr7\Utils::asciiToLower($cookieDomain); + if ($cookieDomain !== '' && $cookieDomain[0] === '.') { + /** @var string */ + $cookieDomain = \substr($cookieDomain, 1); + } + if ('' === $cookieDomain) { + return false; + } - // Domain not set or exact match. - if ('' === $cookieDomain || $domain === $cookieDomain) { + $domain = Psr7\Utils::asciiToLower($domain); + if ($domain === $cookieDomain) { return true; } + // A percent-escaped cookie domain can decode to another host spelling. + // Keep it exact-match-only to avoid extending that host's cookie scope. + if (\strpos($cookieDomain, '%') !== false) { + return false; + } + + // IP literals and numeric hosts are exact-match-only per RFC 6265. + // Only the exact match above may succeed for those cookie domains. + if (self::isIpAddressOrNumericHost($cookieDomain)) { + return false; + } + // Matching the subdomain according to RFC 6265. - // https://tools.ietf.org/html/rfc6265#section-5.1.3 + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3 if (\filter_var($domain, \FILTER_VALIDATE_IP)) { return false; } - return (bool) \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/', $domain); + return (bool) \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/D', $domain); + } + + 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; + } + + // Apply the transport's decimal, octal and hexadecimal inet_aton-style + // grammar. Omitting range checks conservatively holds some names to an + // exact match. + return HostValidator::isNumericIpv4Host(\rtrim($host, '.')); } /** @@ -460,10 +586,7 @@ public function validate() } // 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: ()<>@,;:\"/?={}'; @@ -476,10 +599,10 @@ public function validate() return 'The cookie value must not be empty'; } - // Domains must not be empty, but can be 0. "0" is not a valid internet - // domain, but may be used as server name in a private network. + // 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 === null || $domain === '') { + if ($domain === '' || (null !== $domain && '' === \ltrim(\trim($domain, " \n\r\t\0\x0B"), '.'))) { return 'The cookie domain must not be empty'; } diff --git a/src/Client/lib/Lib/GuzzleHttp/Exception/BadResponseException.php b/src/Client/lib/Lib/GuzzleHttp/Exception/BadResponseException.php index 884c9c3b..4547b690 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Exception/BadResponseException.php +++ b/src/Client/lib/Lib/GuzzleHttp/Exception/BadResponseException.php @@ -14,7 +14,7 @@ public function __construct( string $message, RequestInterface $request, ResponseInterface $response, - ?\Throwable $previous = null, + ?\Throwable $previous = null, array $handlerContext = [] ) { parent::__construct($message, $request, $response, $previous, $handlerContext); diff --git a/src/Client/lib/Lib/GuzzleHttp/Exception/ConnectException.php b/src/Client/lib/Lib/GuzzleHttp/Exception/ConnectException.php index ee4e99f8..b2bfcb7a 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Exception/ConnectException.php +++ b/src/Client/lib/Lib/GuzzleHttp/Exception/ConnectException.php @@ -7,8 +7,6 @@ /** * Exception thrown when a connection cannot be established. - * - * Note that no response is present for a ConnectException */ class ConnectException extends TransferException implements NetworkExceptionInterface { @@ -25,7 +23,7 @@ class ConnectException extends TransferException implements NetworkExceptionInte public function __construct( string $message, RequestInterface $request, - ?\Throwable $previous = null, + ?\Throwable $previous = null, array $handlerContext = [] ) { parent::__construct($message, 0, $previous); diff --git a/src/Client/lib/Lib/GuzzleHttp/Exception/RequestException.php b/src/Client/lib/Lib/GuzzleHttp/Exception/RequestException.php index 38f4a477..d3266a2e 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Exception/RequestException.php +++ b/src/Client/lib/Lib/GuzzleHttp/Exception/RequestException.php @@ -7,7 +7,6 @@ use Plausible\Analytics\WP\Client\Lib\Psr\Http\Client\RequestExceptionInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\ResponseInterface; -use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\UriInterface; /** * HTTP Request exception @@ -32,8 +31,8 @@ class RequestException extends TransferException implements RequestExceptionInte public function __construct( string $message, RequestInterface $request, - ?ResponseInterface $response = null, - ?\Throwable $previous = null, + ?ResponseInterface $response = null, + ?\Throwable $previous = null, array $handlerContext = [] ) { // Set the code of the exception if the response is set and not future. @@ -46,9 +45,13 @@ public function __construct( /** * 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); } @@ -63,10 +66,10 @@ public static function wrapException(RequestInterface $request, \Throwable $e): */ public static function create( RequestInterface $request, - ?ResponseInterface $response = null, - ?\Throwable $previous = null, + ?ResponseInterface $response = null, + ?\Throwable $previous = null, array $handlerContext = [], - ?BodySummarizerInterface $bodySummarizer = null + ?BodySummarizerInterface $bodySummarizer = null ): self { if (!$response) { return new self( @@ -90,8 +93,7 @@ public static function create( $className = __CLASS__; } - $uri = $request->getUri(); - $uri = static::obfuscateUri($uri); + $uri = \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7\Utils::redactUserInfo($request->getUri()); // Client Error: `GET /` resulted in a `404 Not Found` response: // ... (truncated) @@ -113,20 +115,6 @@ public static function create( return new $className($message, $request, $response, $previous, $handlerContext); } - /** - * Obfuscates URI if there is a username and a password present - */ - private static function obfuscateUri(UriInterface $uri): UriInterface - { - $userInfo = $uri->getUserInfo(); - - if (false !== ($pos = \strpos($userInfo, ':'))) { - return $uri->withUserInfo(\substr($userInfo, 0, $pos), '***'); - } - - return $uri; - } - /** * Get the request that caused the exception */ diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/CurlFactory.php b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlFactory.php index 1a862051..4ab4aeee 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Handler/CurlFactory.php +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlFactory.php @@ -4,13 +4,18 @@ use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\ConnectException; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\RequestException; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Multiplexing; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise as P; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\FulfilledPromise; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7\LazyOpenStream; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7\Uri; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\TransferStats; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\TransportSharing; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Utils; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface; +use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\UriInterface; /** * Creates curl resources from a request @@ -21,6 +26,29 @@ class CurlFactory implements CurlFactoryInterface { public const CURL_VERSION_STR = 'curl_version'; + private const DELEGATED_PROXY_TUNNEL_OWNER = 'proxy-tunnel:delegated-to-libcurl'; + + /** + * String-valued proxy credential cURL options whose values feed the + * connection-reuse section signatures. Stringable values are cast + * exactly once, before signature computation, so the signature and + * ext-curl observe the same string; a stateful __toString() could + * otherwise produce one value for the signature and a different one on + * the wire, giving two credentials the same section. Numeric options + * (CURLOPT_PROXYTYPE, CURLOPT_PROXY_SSLVERSION) and blob options + * (CURLOPT_PROXY_SSLCERT_BLOB) are deliberately excluded. + */ + private const STRINGABLE_PROXY_CREDENTIAL_OPTIONS = [ + 'CURLOPT_PROXYUSERPWD', + 'CURLOPT_PROXYUSERNAME', + 'CURLOPT_PROXYPASSWORD', + 'CURLOPT_PROXY_SSLCERT', + 'CURLOPT_PROXY_SSLKEY', + 'CURLOPT_PROXY_KEYPASSWD', + 'CURLOPT_PROXY_TLSAUTH_USERNAME', + 'CURLOPT_PROXY_TLSAUTH_PASSWORD', + ]; + /** * @deprecated */ @@ -31,26 +59,158 @@ class CurlFactory implements CurlFactoryInterface */ private $handles = []; + /** + * @var string|null Owner signature of the proxy tunnels that pooled idle + * handles may still hold + */ + private $proxyTunnelOwner; + + /** + * @var bool Whether an in-domain handle has been pooled since the last purge + */ + private $poolMayHoldTunnels = false; + /** * @var int Total number of idle handles to keep in cache */ private $maxHandles; /** - * @param int $maxHandles Maximum number of idle handles. + * @var resource|\CurlShareHandle|null + */ + private $shareHandle; + + /** + * @var string + */ + private $shareMode; + + /** + * @var bool Whether the configured share handle may own a connection + * cache populated outside this factory */ - public function __construct(int $maxHandles) + private $opaqueShareConnectionCache = false; + + /** + * @param int $maxHandles Maximum number of idle handles. + * @param resource|\CurlShareHandle|CurlShareHandleState|null $shareHandle + */ + public function __construct(int $maxHandles, string $shareMode = TransportSharing::NONE, $shareHandle = null) { $this->maxHandles = $maxHandles; + $this->shareMode = CurlShareHandleState::normalizeMode($shareMode, 'transport_sharing'); + + if ($shareHandle instanceof CurlShareHandleState) { + if ($shareHandle->mode !== $this->shareMode) { + throw new \InvalidArgumentException('The cURL share handle state mode does not match the configured transport sharing mode.'); + } + + // A Guzzle-created handler-lifetime state locks only DNS and TLS + // session data, so its handle can never own a connection cache. + $shareHandle = $shareHandle->handle; + } elseif ($shareHandle !== null) { + // An externally supplied handle's lock set and cached contents + // cannot be inspected from PHP, so it may own a connection cache + // populated outside this factory. + $this->opaqueShareConnectionCache = true; + } + + if ($this->shareMode === TransportSharing::NONE && $shareHandle !== null) { + throw new \InvalidArgumentException('A cURL share handle cannot be provided when transport sharing is disabled.'); + } + + if ($this->shareMode !== TransportSharing::NONE && $shareHandle === null) { + throw new \InvalidArgumentException('A cURL share handle is required when transport sharing is enabled.'); + } + + if ($shareHandle !== null && !self::isCurlShareHandle($shareHandle)) { + throw new \InvalidArgumentException('A cURL share handle must be an instance of CurlShareHandle or a curl_share resource.'); + } + + $this->shareHandle = $shareHandle; + } + + /** + * @param mixed $value + */ + private static function isCurlShareHandle($value): bool + { + if (\PHP_VERSION_ID < 80000) { + return \is_resource($value) && \get_resource_type($value) === 'curl_share'; + } + + return $value instanceof \CurlShareHandle; } public function create(RequestInterface $request, array $options): EasyHandle { + self::validateRequestUriScheme($request); + + if (isset($options['on_trailers']) && !\is_callable($options['on_trailers'])) { + throw new \InvalidArgumentException('on_trailers must be callable'); + } + + $protocolVersion = $request->getProtocolVersion(); + + if ('' === $protocolVersion) { + \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Sending a request with an empty protocol version is deprecated; guzzlehttp/guzzle 8.0 will reject empty protocol versions.'); + + $protocolVersion = '1.1'; + $request = Psr7\Utils::modifyRequest($request, ['version' => $protocolVersion]); + } + + $multiplex = self::normalizeMultiplex($options); + $requiredMultiplex = \in_array($multiplex, [Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true); + + if ($requiredMultiplex && isset($options['curl']) && \is_array($options['curl'])) { + $requiredModeConflicts = [ + \CURLOPT_HTTP_VERSION => ['CURLOPT_HTTP_VERSION', 'the request protocol version'], + \CURLOPT_URL => ['CURLOPT_URL', 'the request URI'], + \CURLOPT_FOLLOWLOCATION => ['CURLOPT_FOLLOWLOCATION', 'the "allow_redirects" request option'], + ]; + + foreach ($requiredModeConflicts as $option => [$name, $replacement]) { + if (\array_key_exists($option, $options['curl'])) { + // Key presence alone conflicts: whatever the raw value, + // it is a second authority over the protocol or route, + // applied after the required mode's decisions. + throw new \InvalidArgumentException(\sprintf('The "multiplex" request option cannot be required when the raw %s cURL option is set; remove the raw option and use %s instead.', $name, $replacement)); + } + } + } + + if ('2' === $protocolVersion || '2.0' === $protocolVersion) { + if (!CurlVersion::supportsHttp2()) { + if ($requiredMultiplex) { + throw new ConnectException('Required multiplexing needs libcurl 8.14.0 or newer built with HTTP/2 support.', $request); + } + + throw new ConnectException('HTTP/2 is supported by the cURL handler, however libcurl is built without HTTP/2 support.', $request); + } + } elseif ('1.0' !== $protocolVersion && '1.1' !== $protocolVersion) { + throw new ConnectException(sprintf('HTTP/%s is not supported by the cURL handler.', $protocolVersion), $request); + } + if (isset($options['curl']['body_as_string'])) { $options['_body_as_string'] = $options['curl']['body_as_string']; unset($options['curl']['body_as_string']); } + self::triggerUnsupportedRequestOptionDeprecations($options); + self::triggerUnsupportedCurlOptionDeprecations($options); + self::triggerConflictingCurlOptionDeprecations($options); + + // Capture the managed Proxy-Authorization values before header + // serialization so they never enter the origin header list, and + // record whether a deprecated raw CURLOPT_HTTPHEADER value replaces + // every generated header, the managed values included. Key presence + // alone replaces: an empty or null raw value still suppresses the + // generated list. + $managedProxyAuthorization = self::managedProxyAuthorizationHeaderLines($request); + $rawHttpHeadersReplaceManaged = isset($options['curl']) + && \is_array($options['curl']) + && \array_key_exists(\CURLOPT_HTTPHEADER, $options['curl']); + $easy = new EasyHandle(); $easy->request = $request; $easy->options = $options; @@ -65,152 +225,1670 @@ public function create(RequestInterface $request, array $options): EasyHandle $conf = \array_replace($conf, $options['curl']); } + self::assertFinalProxyOptionTypes($conf, $requiredMultiplex && 'https' !== $request->getUri()->getScheme()); + self::isolatePreProxyOnAffectedCurl($conf); + self::normalizeStringableProxyCredentialOptions($conf); + + if ($requiredMultiplex) { + self::assertRequiredMultiplexRouteDirect($easy, $conf); + self::assertRequiredMultiplexAuthSupported($conf); + } + + self::normalizeCurlHeaderOptions($conf); + self::applyProxyAuthorizationHeaderHandling($request, $conf); + self::applyManagedProxyAuthorization($request, $conf, $managedProxyAuthorization, $rawHttpHeadersReplaceManaged); + // Validate the appended managed lines too: a custom RequestInterface + // can bypass a normal PSR-7 implementation's header validation. + self::normalizeCurlHeaderOptions($conf); + $this->rejectRequestLevelShareConflict($options); + self::rejectRequestLevelShareWithProxyAuth($request, $options, $conf); + + if ($this->shareHandle !== null) { + // Conservative blanket mode: a configured share handle hides the + // pooled connections' provenance, so sectioned reuse cannot reason + // about them. + self::forceFreshConnectionForAuthenticatedProxy($request, $conf); + $this->isolateOpaqueShareAnonymousProxyTunnel($request, $conf); + } else { + $signature = self::proxyTunnelSignature($request, $conf); + $easy->proxyTunnelSignature = $signature; + if ($signature !== null && $signature !== $this->proxyTunnelOwner) { + if ($this->poolMayHoldTunnels) { + // Pooled idle handles may hold a different owner's tunnel. + $this->discardIdleHandles(); + $this->poolMayHoldTunnels = false; + } + // The first in-domain owner latches without purging: the pool + // provably holds no in-domain tunnel yet. + $this->proxyTunnelOwner = $signature; + } + } + + $easy->effectiveProxy = self::getEffectiveProxy($conf); + $conf[\CURLOPT_HEADERFUNCTION] = $this->createHeaderFn($easy); - $easy->handle = $this->handles ? \array_pop($this->handles) : \curl_init(); - curl_setopt_array($easy->handle, $conf); + if ($this->shareHandle !== null) { + if (!\defined('CURLOPT_SHARE')) { + throw new \InvalidArgumentException('The configured cURL share handle requires CURLOPT_SHARE, but it is not available in the installed PHP cURL extension.'); + } + + $conf[(int) \constant('CURLOPT_SHARE')] = $this->shareHandle; + } + + if (\defined('CURLOPT_PIPEWAIT')) { + $easy->usesPipewait = !empty($conf[(int) \constant('CURLOPT_PIPEWAIT')]); + } + + $handle = $this->handles ? \array_pop($this->handles) : \curl_init(); + if (false === $handle) { + throw new \RuntimeException('Can not initialize cURL handle.'); + } + $easy->handle = $handle; + + try { + $this->applyCurlOptions($handle, $conf); + } catch (\Throwable $e) { + if (PHP_VERSION_ID < 80000 && \is_resource($handle)) { + \curl_close($handle); + } + unset($easy->handle); + + throw $e; + } return $easy; } - public function release(EasyHandle $easy): void + /** + * @param resource|\CurlHandle $handle + * @param array $conf + */ + private function applyCurlOptions($handle, array $conf): void { - $resource = $easy->handle; - unset($easy->handle); + foreach ($conf as $option => $value) { + if (!\is_int($option)) { + throw new \InvalidArgumentException(\sprintf( + 'Invalid cURL option %s.', + self::formatCurlOption($option) + )); + } - if (\count($this->handles) >= $this->maxHandles) { - \curl_close($resource); - } else { - // Remove all callback functions as they can hold onto references - // and are not cleaned up by curl_reset. Using curl_setopt_array - // does not work for some reason, so removing each one - // individually. - \curl_setopt($resource, \CURLOPT_HEADERFUNCTION, null); - \curl_setopt($resource, \CURLOPT_READFUNCTION, null); - \curl_setopt($resource, \CURLOPT_WRITEFUNCTION, null); - \curl_setopt($resource, \CURLOPT_PROGRESSFUNCTION, null); - \curl_reset($resource); - $this->handles[] = $resource; + try { + $success = curl_setopt($handle, $option, $value); + } catch (\Throwable $e) { + throw new \InvalidArgumentException( + \sprintf( + 'Unable to set cURL option %s: %s', + self::formatCurlOption($option), + $e->getMessage() + ), + 0, + $e + ); + } + + if (!$success) { + throw new \InvalidArgumentException(\sprintf( + 'Unable to set cURL option %s.', + self::formatCurlOption($option) + )); + } } } /** - * Completes a cURL transaction, either returning a response promise or a - * rejected promise. + * @param array $conf + */ + private static function normalizeStringableProxyCredentialOptions(array &$conf): void + { + foreach (self::STRINGABLE_PROXY_CREDENTIAL_OPTIONS as $name) { + if (!\defined($name)) { + continue; + } + + $option = (int) \constant($name); + if (!isset($conf[$option]) || !\is_object($conf[$option]) || !\method_exists($conf[$option], '__toString')) { + continue; + } + + try { + $conf[$option] = (string) $conf[$option]; + } catch (\Throwable $e) { + // Wrap the failure exactly as applyCurlOptions() does for a + // value that cannot be applied. + throw new \InvalidArgumentException( + \sprintf( + 'Unable to set cURL option %s: %s', + self::formatCurlOption($option), + $e->getMessage() + ), + 0, + $e + ); + } + } + } + + private function rejectRequestLevelShareConflict(array $options): void + { + if ($this->shareHandle === null) { + return; + } + + if ( + !\defined('CURLOPT_SHARE') + || !isset($options['curl']) + || !\is_array($options['curl']) + || !\array_key_exists((int) \constant('CURLOPT_SHARE'), $options['curl']) + ) { + return; + } + + throw new \InvalidArgumentException('The request-level CURLOPT_SHARE cURL option cannot be combined with configured transport sharing.'); + } + + private static function normalizeMultiplex(array $options): ?string + { + $multiplex = $options['multiplex'] ?? null; + + if ($multiplex === null) { + // Absent/null leaves multiplexing to libcurl: no CURLOPT_PIPEWAIT + // is written and no guarantees apply. + return null; + } + + if (!\in_array($multiplex, [Multiplexing::NONE, Multiplexing::EAGER, Multiplexing::WAIT, Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { + throw new \InvalidArgumentException(\sprintf( + 'The "multiplex" option must be null or a Plausible\Analytics\WP\Client\Lib\GuzzleHttp\\Multiplexing::* constant; received %s.', + \get_debug_type($multiplex) + )); + } + + return $multiplex; + } + + private static function assertRequiredMultiplexSupported(EasyHandle $easy): void + { + if (!CurlVersion::supportsRequiredMultiplex()) { + throw new ConnectException('Required multiplexing needs libcurl 8.14.0 or newer built with HTTP/2 support.', $easy->request); + } + } + + /** + * Required multiplexing sends cleartext requests with HTTP/2 prior + * knowledge, which an HTTP proxy hop silently downgrades, so the request + * must reach the origin directly. The check runs against the final + * merged cURL configuration because deprecated raw proxy options are + * applied after Guzzle's own decisions and may add, replace, or disable + * the selected proxy. Value types that ext-curl would coerce are + * rejected as ambiguous, and only the exact CURLOPT_NOPROXY wildcard '*' + * counts as disabling the primary proxy and pre-proxy: host-specific + * patterns are conservatively treated as leaving them active. + * + * @param array $conf + */ + private static function assertRequiredMultiplexRouteDirect(EasyHandle $easy, array $conf): void + { + if ('https' === $easy->request->getUri()->getScheme()) { + return; + } + + $proxyOptions = [\CURLOPT_PROXY => 'CURLOPT_PROXY']; + if (\defined('CURLOPT_NOPROXY')) { + $proxyOptions[(int) \constant('CURLOPT_NOPROXY')] = 'CURLOPT_NOPROXY'; + } + if (\defined('CURLOPT_PRE_PROXY')) { + $proxyOptions[(int) \constant('CURLOPT_PRE_PROXY')] = 'CURLOPT_PRE_PROXY'; + } + + foreach ($proxyOptions as $option => $name) { + if (\array_key_exists($option, $conf) && !\is_string($conf[$option])) { + throw new \InvalidArgumentException(\sprintf('The "multiplex" request option cannot be required when the final %s cURL option value is not a string.', $name)); + } + } + + if (\defined('CURLOPT_NOPROXY') && ($conf[(int) \constant('CURLOPT_NOPROXY')] ?? null) === '*') { + // libcurl's exact wildcard disables the primary proxy and the + // pre-proxy together, leaving a direct route. + return; + } + + if (self::getEffectiveProxy($conf) !== null + || (\defined('CURLOPT_PRE_PROXY') && ($conf[(int) \constant('CURLOPT_PRE_PROXY')] ?? '') !== '') + ) { + throw new ConnectException('Required multiplexing cannot be guaranteed for cleartext requests sent through a proxy.', $easy->request); + } + } + + /** + * libcurl forces NTLM-authenticated transfers onto HTTP/1.1: when the + * server picks NTLM from the offered mask, the connection is closed and + * the request is retried over HTTP/1.1 whatever HTTP version was asked + * for, silently defeating the required protocol guarantee on both + * cleartext and TLS routes. The final merged mask is checked so the + * deprecated "auth" request option and the raw CURLOPT_HTTPAUTH cURL + * option are both covered, and any mask permitting NTLM, such as + * CURLAUTH_ANY, is rejected because the selection is server-controlled. + * + * @param array $conf + */ + private static function assertRequiredMultiplexAuthSupported(array $conf): void + { + if (!\array_key_exists(\CURLOPT_HTTPAUTH, $conf)) { + return; + } + + $auth = $conf[\CURLOPT_HTTPAUTH]; + if (!\is_scalar($auth)) { + throw new \InvalidArgumentException('The "multiplex" request option cannot be required when the final CURLOPT_HTTPAUTH cURL option value is not an integer.'); + } + + $ntlmBits = \CURLAUTH_NTLM; + if (\defined('CURLAUTH_NTLM_WB')) { + $ntlmBits |= (int) \constant('CURLAUTH_NTLM_WB'); + } + + if (((int) $auth & $ntlmBits) !== 0) { + throw new \InvalidArgumentException('The "multiplex" request option cannot be required when the final CURLOPT_HTTPAUTH cURL option value permits NTLM; libcurl retries NTLM authentication over HTTP/1.1.'); + } + } + + /** + * @param mixed $proxyConf + */ + private static function assertResolvedProxySupported(RequestInterface $request, $proxyConf): void + { + if (!\is_string($proxyConf) || $proxyConf === '') { + return; + } + + $scheme = self::proxyScheme($proxyConf); + if ($scheme !== null && \preg_match('/^[a-z][a-z0-9.+-]*$/D', $scheme) !== 1) { + throw new RequestException('The proxy URL is malformed.', $request); + } + + if ($scheme === 'https' && !CurlVersion::supportsHttpsProxy()) { + throw new RequestException('HTTPS proxies are not supported by the installed libcurl; libcurl 7.52.0 or newer built with HTTPS-proxy support is required.', $request); + } + } + + /** + * @return array{0: mixed, 1: string} + */ + private static function resolveProxy(RequestInterface $request, array $options): array + { + $proxyConf = null; + $noProxyConf = ''; + + if (isset($options['proxy'])) { + if (!\is_array($options['proxy'])) { + $proxyConf = $options['proxy']; + } else { + $scheme = $request->getUri()->getScheme(); + if (isset($options['proxy'][$scheme])) { + if ( + isset($options['proxy']['no']) + && Utils::isUriInNoProxy($request->getUri(), $options['proxy']['no']) + ) { + $proxyConf = ''; + $noProxyConf = '*'; + } else { + $proxyConf = $options['proxy'][$scheme]; + } + } + } + } + + if ($proxyConf === null) { + $proxyConf = ProxyEnvironment::getProxyForScheme($request->getUri()->getScheme()); + if ($proxyConf === null) { + $proxyConf = ''; + } elseif ( + ($noProxy = ProxyEnvironment::getNoProxy()) !== null + && Utils::isUriInNoProxy($request->getUri(), ProxyEnvironment::splitNoProxy($noProxy)) + ) { + $proxyConf = ''; + $noProxyConf = '*'; + } + } + + return [$proxyConf, $noProxyConf]; + } + + /** + * @param array $conf + */ + private static function rejectRequestLevelShareWithProxyAuth(RequestInterface $request, array $options, array $conf): void + { + if (!self::hasRequestLevelCurlShare($options)) { + return; + } + + $proxy = self::getEffectiveProxy($conf); + if ($proxy === null) { + return; + } + + // An external share handle may pool SOCKS connections where no section + // signature can reach them. On affected libcurl, even an anonymous + // request could inherit authenticated state already in that pool. + if (self::isSocksProxy($proxy, $conf)) { + if (!CurlVersion::supportsSocksProxyCredentialAwareConnectionReuse()) { + throw new \InvalidArgumentException('The request-level CURLOPT_SHARE cURL option cannot be combined with SOCKS proxy configuration on libcurl before 7.69.0; use Guzzle-managed "transport_sharing" or a custom handler/factory instead.'); + } + + if (self::hasAuthenticatedSocksProxyState($proxy, $conf)) { + throw new \InvalidArgumentException('The request-level CURLOPT_SHARE cURL option cannot be combined with authenticated SOCKS proxy configuration; use Guzzle-managed "transport_sharing" or a custom handler/factory instead.'); + } + } + + if ( + !self::usesProxyTunnel($request, $conf) + || !self::isHttpProxyForConnectionReuse($proxy, $conf) + ) { + return; + } + + if (self::hasAuthenticatedHttpProxyState($proxy, $conf)) { + throw new \InvalidArgumentException('The request-level CURLOPT_SHARE cURL option cannot be combined with authenticated HTTP/HTTPS proxy tunnel configuration; use Guzzle-managed "transport_sharing" or a custom handler/factory instead.'); + } + + // From libcurl 7.57.0 the external share can also own a connection + // cache seeded outside Guzzle with tunnel identity libcurl cannot + // key, so anonymous tunnels are rejected there too. + if (CurlVersion::supportsShareConnectionCaches()) { + throw new \InvalidArgumentException('The request-level CURLOPT_SHARE cURL option cannot be combined with HTTP/HTTPS proxy tunnel configuration on libcurl 7.57.0 or newer; use Guzzle-managed "transport_sharing" or a custom handler/factory instead.'); + } + } + + private static function hasRequestLevelCurlShare(array $options): bool + { + return \defined('CURLOPT_SHARE') + && isset($options['curl']) + && \is_array($options['curl']) + && \array_key_exists((int) \constant('CURLOPT_SHARE'), $options['curl']); + } + + /** + * @param array $conf + */ + private static function hasAuthenticatedHttpProxyState(string $proxy, array $conf): bool + { + $proxyForParsing = \strpos($proxy, '://') === false ? 'http://'.$proxy : $proxy; + $proxyParts = \parse_url($proxyForParsing); + + if ( + \is_array($proxyParts) + && (\array_key_exists('user', $proxyParts) || \array_key_exists('pass', $proxyParts)) + ) { + return true; + } + + if (self::hasCurlProxyCredentials($conf)) { + return true; + } + + if (self::hasCurlProxyAuthorizationHeader($conf)) { + return true; + } + + $httpHeaders = $conf[\CURLOPT_HTTPHEADER] ?? []; + if (\is_array($httpHeaders) && self::proxyAuthorizationHeaderValuesFromList($httpHeaders) !== []) { + return true; + } + + return self::hasCurlProxyTlsCredentials($conf); + } + + /** + * @param int|string $option + */ + private static function formatCurlOption($option): string + { + if (!\is_int($option)) { + return \sprintf('"%s"', $option); + } + + static $names = null; + + if (null === $names) { + $names = []; + foreach (\get_defined_constants(true)['curl'] ?? [] as $name => $value) { + if (\is_int($value) && \strpos($name, 'CURLOPT_') === 0 && !isset($names[$value])) { + $names[$value] = $name; + } + } + } + + if (isset($names[$option])) { + return \sprintf('%s (%d)', $names[$option], $option); + } + + return (string) $option; + } + + private static function triggerConflictingCurlOptionDeprecations(array $options): void + { + if (!isset($options['curl']) || !\is_array($options['curl']) || $options['curl'] === []) { + return; + } + + $conflictingOptions = self::conflictingCurlOptions(); + $sinceOverrides = self::conflictingCurlOptionSinceOverrides(); + + foreach ($options['curl'] as $option => $_) { + if (!\array_key_exists($option, $conflictingOptions)) { + continue; + } + + $name = self::formatCurlOption($option); + $replacement = $conflictingOptions[$option]; + $since = $sinceOverrides[$option] ?? '7.11'; + if ($replacement !== null) { + \trigger_deprecation( + 'guzzlehttp/guzzle', + $since, + \sprintf( + 'Passing %s in the "curl" request option is deprecated; guzzlehttp/guzzle 8.0 will reject this option because it conflicts with Guzzle-managed request handling. Use %s instead.', + $name, + $replacement + ) + ); + + continue; + } + + \trigger_deprecation( + 'guzzlehttp/guzzle', + $since, + \sprintf( + 'Passing %s in the "curl" request option is deprecated; guzzlehttp/guzzle 8.0 will reject this option because it conflicts with Guzzle-managed cURL internals.', + $name + ) + ); + } + } + + private static function triggerUnsupportedCurlOptionDeprecations(array $options): void + { + if (!isset($options['curl']) || !\is_array($options['curl']) || $options['curl'] === []) { + return; + } + + if ( + \defined('CURLOPT_PROXYHEADER') + && \array_key_exists((int) \constant('CURLOPT_PROXYHEADER'), $options['curl']) + && !CurlVersion::supportsProxyHeaderSeparation() + ) { + \trigger_deprecation( + 'guzzlehttp/guzzle', + '7.15', + \sprintf( + 'Passing %s in the "curl" request option on a build without proxy header separation support is deprecated; guzzlehttp/guzzle 8.0 will reject this configuration because proxy headers require libcurl 7.37.0 or newer built with proxy header separation support.', + self::formatCurlOption((int) \constant('CURLOPT_PROXYHEADER')) + ) + ); + } + + $supportedOptions = self::supportedCurlOptions(); + $conflictingOptions = self::conflictingCurlOptions(); + + foreach ($options['curl'] as $option => $_) { + if ( + !\is_int($option) + || \array_key_exists($option, $supportedOptions) + || \array_key_exists($option, $conflictingOptions) + ) { + continue; + } + + \trigger_deprecation( + 'guzzlehttp/guzzle', + '7.12', + \sprintf( + 'Passing %s in the "curl" request option is deprecated; guzzlehttp/guzzle 8.0 will reject raw cURL options outside the built-in cURL handlers\' allow-list.', + self::formatCurlOption($option) + ) + ); + } + } + + private static function triggerUnsupportedRequestOptionDeprecations(array $options): void + { + if (\array_key_exists('stream_context', $options)) { + \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "stream_context" request option to a cURL handler is deprecated; guzzlehttp/guzzle 8.0 will reject this option because cURL handlers ignore PHP stream context options.'); + } + } + + /** + * @return array + */ + private static function conflictingCurlOptions(): array + { + static $options = null; + + if ($options !== null) { + return $options; + } + + $options = []; + + self::addConflictingCurlOption($options, 'CURLOPT_SHARE', 'the "transport_sharing" client option or cURL handler option'); + self::addConflictingCurlOption($options, 'CURLOPT_URL', 'the request URI'); + self::addConflictingCurlOption($options, 'CURLOPT_PORT', 'the request URI'); + self::addConflictingCurlOption($options, 'CURLOPT_CUSTOMREQUEST', 'the request method'); + self::addConflictingCurlOption($options, 'CURLOPT_HTTPGET', 'the request method'); + self::addConflictingCurlOption($options, 'CURLOPT_POST', 'the request method and body'); + self::addConflictingCurlOption($options, 'CURLOPT_PUT', 'the request method and body'); + self::addConflictingCurlOption($options, 'CURLOPT_NOBODY', 'the request method'); + self::addConflictingCurlOption($options, 'CURLOPT_UPLOAD', 'the request body'); + self::addConflictingCurlOption($options, 'CURLOPT_POSTFIELDS', 'the request body'); + self::addConflictingCurlOption($options, 'CURLOPT_READFUNCTION', 'the request body'); + self::addConflictingCurlOption($options, 'CURLOPT_READDATA', 'the request body'); + self::addConflictingCurlOption($options, 'CURLOPT_INFILE', 'the request body'); + self::addConflictingCurlOption($options, 'CURLOPT_INFILESIZE', 'the request body'); + self::addConflictingCurlOption($options, 'CURLOPT_INFILESIZE_LARGE', 'the request body'); + self::addConflictingCurlOption($options, 'CURLOPT_HTTPHEADER', 'the request headers'); + self::addConflictingCurlOption($options, 'CURLOPT_USERAGENT', 'the request headers'); + self::addConflictingCurlOption($options, 'CURLOPT_REFERER', 'the request headers'); + self::addConflictingCurlOption($options, 'CURLOPT_HEADERFUNCTION', 'the "on_headers" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_WRITEFUNCTION', 'the "sink" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_FILE', 'the "sink" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_TIMEOUT', 'the "timeout" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_TIMEOUT_MS', 'the "timeout" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_CONNECTTIMEOUT', 'the "connect_timeout" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_CONNECTTIMEOUT_MS', 'the "connect_timeout" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_NOSIGNAL', 'the "timeout" or "connect_timeout" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_NOPROGRESS', 'the "progress" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_PROGRESSFUNCTION', 'the "progress" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_XFERINFOFUNCTION', 'the "progress" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_VERBOSE', 'the "debug" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_STDERR', 'the "debug" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_PROXY', 'the "proxy" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_NOPROXY', 'the "proxy" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_PROXYTYPE', 'the "proxy" request option with a scheme-prefixed URL'); + self::addConflictingCurlOption($options, 'CURLOPT_FOLLOWLOCATION', 'the "allow_redirects" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_MAXREDIRS', 'the "allow_redirects" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_POSTREDIR', 'the "allow_redirects" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_REDIR_PROTOCOLS', 'the "allow_redirects" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_REDIR_PROTOCOLS_STR', 'the "allow_redirects" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_PROTOCOLS', 'the "protocols" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_PROTOCOLS_STR', 'the "protocols" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_HTTP_VERSION', 'the request protocol version'); + self::addConflictingCurlOption($options, 'CURLOPT_PIPEWAIT', 'the "multiplex" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_IPRESOLVE', 'the "force_ip_resolve" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_SSL_VERIFYPEER', 'the "verify" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_SSL_VERIFYHOST', 'the "verify" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_CAINFO', 'the "verify" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_CAPATH', 'the "verify" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_SSLVERSION', 'the "crypto_method" or "crypto_method_max" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_SSLCERT', 'the "cert" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_SSLCERTPASSWD', 'the "cert" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_SSLCERTTYPE', 'the "cert_type" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_SSLKEY', 'the "ssl_key" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_SSLKEYPASSWD', 'the "ssl_key" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_KEYPASSWD', 'the "ssl_key" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_SSLKEYTYPE', 'the "ssl_key_type" request option'); + self::addConflictingCurlOption($options, 'CURLOPT_COOKIE', 'the "Cookie" request header or Guzzle cookie middleware'); + self::addConflictingCurlOption($options, 'CURLOPT_COOKIEFILE', 'Guzzle cookie middleware'); + self::addConflictingCurlOption($options, 'CURLOPT_COOKIEJAR', 'Guzzle cookie middleware'); + self::addConflictingCurlOption($options, 'CURLOPT_COOKIELIST', 'Guzzle cookie middleware'); + self::addConflictingCurlOption($options, 'CURLOPT_COOKIESESSION', 'Guzzle cookie middleware'); + + return $options; + } + + /** + * @return array + */ + private static function conflictingCurlOptionSinceOverrides(): array + { + static $options = null; + + if ($options !== null) { + return $options; + } + + $options = []; + + if (\defined('CURLOPT_PROXYTYPE')) { + $options[\CURLOPT_PROXYTYPE] = '7.12'; + } + + if (\defined('CURLOPT_PIPEWAIT')) { + $options[\CURLOPT_PIPEWAIT] = '7.14'; + } + + return $options; + } + + /** + * @return array + */ + private static function supportedCurlOptions(): array + { + static $options = null; + + if ($options !== null) { + return $options; + } + + $options = []; + + self::addSupportedCurlOption($options, 'CURLOPT_ADDRESS_SCOPE'); + self::addSupportedCurlOption($options, 'CURLOPT_CERTINFO'); + self::addSupportedCurlOption($options, 'CURLOPT_CONNECT_TO'); + self::addSupportedCurlOption($options, 'CURLOPT_DNS_CACHE_TIMEOUT'); + self::addSupportedCurlOption($options, 'CURLOPT_DNS_INTERFACE'); + self::addSupportedCurlOption($options, 'CURLOPT_DNS_LOCAL_IP4'); + self::addSupportedCurlOption($options, 'CURLOPT_DNS_LOCAL_IP6'); + self::addSupportedCurlOption($options, 'CURLOPT_DNS_SERVERS'); + self::addSupportedCurlOption($options, 'CURLOPT_DNS_SHUFFLE_ADDRESSES'); + self::addSupportedCurlOption($options, 'CURLOPT_ENCODING'); + self::addSupportedCurlOption($options, 'CURLOPT_FORBID_REUSE'); + self::addSupportedCurlOption($options, 'CURLOPT_FRESH_CONNECT'); + self::addSupportedCurlOption($options, 'CURLOPT_HAPPY_EYEBALLS_TIMEOUT_MS'); + self::addSupportedCurlOption($options, 'CURLOPT_HTTPAUTH'); + self::addSupportedCurlOption($options, 'CURLOPT_INTERFACE'); + self::addSupportedCurlOption($options, 'CURLOPT_LOCALPORT'); + self::addSupportedCurlOption($options, 'CURLOPT_LOCALPORTRANGE'); + self::addSupportedCurlOption($options, 'CURLOPT_LOW_SPEED_LIMIT'); + self::addSupportedCurlOption($options, 'CURLOPT_LOW_SPEED_TIME'); + self::addSupportedCurlOption($options, 'CURLOPT_MAXAGE_CONN'); + self::addSupportedCurlOption($options, 'CURLOPT_MAXCONNECTS'); + self::addSupportedCurlOption($options, 'CURLOPT_MAXLIFETIME_CONN'); + self::addSupportedCurlOption($options, 'CURLOPT_HTTPPROXYTUNNEL'); + self::addSupportedCurlOption($options, 'CURLOPT_PREREQFUNCTION'); + self::addSupportedCurlOption($options, 'CURLOPT_PROXYHEADER'); + self::addSupportedCurlOption($options, 'CURLOPT_PROXYUSERPWD'); + self::addSupportedCurlOption($options, 'CURLOPT_RESOLVE'); + self::addSupportedCurlOption($options, 'CURLOPT_SSL_CIPHER_LIST'); + self::addSupportedCurlOption($options, 'CURLOPT_SSL_EC_CURVES'); + self::addSupportedCurlOption($options, 'CURLOPT_TCP_FASTOPEN'); + self::addSupportedCurlOption($options, 'CURLOPT_TCP_KEEPALIVE'); + self::addSupportedCurlOption($options, 'CURLOPT_TCP_KEEPIDLE'); + self::addSupportedCurlOption($options, 'CURLOPT_TCP_KEEPINTVL'); + self::addSupportedCurlOption($options, 'CURLOPT_TCP_KEEPCNT'); + self::addSupportedCurlOption($options, 'CURLOPT_TCP_NODELAY'); + self::addSupportedCurlOption($options, 'CURLOPT_TLS13_CIPHERS'); + self::addSupportedCurlOption($options, 'CURLOPT_UNIX_SOCKET_PATH'); + self::addSupportedCurlOption($options, 'CURLOPT_USERPWD'); + + return $options; + } + + /** + * @param array $options + */ + private static function addSupportedCurlOption(array &$options, string $constant): void + { + if (!\defined($constant)) { + return; + } + + $value = \constant($constant); + if (\is_int($value)) { + $options[$value] = true; + } + } + + /** + * @param array $options + */ + private static function addConflictingCurlOption(array &$options, string $constant, ?string $replacement): void + { + if (!\defined($constant)) { + return; + } + + $value = \constant($constant); + if (\is_int($value)) { + $options[$value] = $replacement; + } + } + + public function release(EasyHandle $easy): void + { + $resource = $easy->handle; + unset($easy->handle); + + if ( + \count($this->handles) >= $this->maxHandles + || ($easy->proxyTunnelSignature !== null && $easy->proxyTunnelSignature !== $this->proxyTunnelOwner) + ) { + // Pool is full, or this handle belongs to a superseded tunnel + // owner (an async create/release overlap can hand a stale-owner + // handle back after a purge) - drop it instead of pooling it. + if (PHP_VERSION_ID < 80000) { + \curl_close($resource); + } + + return; + } + + if ($easy->proxyTunnelSignature !== null) { + // A pooled handle now carries the current owner's tunnel. + $this->poolMayHoldTunnels = true; + } + + // Remove all callback functions as they can hold onto references and + // are not cleaned up by curl_reset. Using curl_setopt_array does not + // work for some reason, so removing each one individually. + \curl_setopt($resource, \CURLOPT_HEADERFUNCTION, null); + \curl_setopt($resource, \CURLOPT_READFUNCTION, null); + \curl_setopt($resource, \CURLOPT_WRITEFUNCTION, null); + \curl_setopt($resource, \CURLOPT_PROGRESSFUNCTION, null); + + if (\defined('CURLOPT_PREREQFUNCTION')) { + \curl_setopt($resource, (int) \constant('CURLOPT_PREREQFUNCTION'), null); + } + + \curl_reset($resource); + $this->handles[] = $resource; + } + + /** + * Completes a cURL transaction, either returning a response promise or a + * rejected promise. + * + * @param callable(RequestInterface, array): PromiseInterface $handler + * @param CurlFactoryInterface $factory Dictates how the handle is released + */ + public static function finish(callable $handler, EasyHandle $easy, CurlFactoryInterface $factory): PromiseInterface + { + if (isset($easy->options['on_stats'])) { + try { + self::invokeStats($easy); + } catch (\Throwable $e) { + try { + $factory->release($easy); + } catch (\Throwable $releaseFailure) { + // Keep the on_stats throwable as the visible failure. + } + + throw $e; + } + } + + if (!$easy->response || $easy->errno) { + return self::finishError($handler, $easy, $factory); + } + + // Return the response if it is present and there is no error. + $factory->release($easy); + + // Rewind the body of the response if possible. + $body = $easy->response->getBody(); + if ($body->isSeekable()) { + $body->rewind(); + } + + if (isset($easy->options['on_trailers'])) { + try { + ($easy->options['on_trailers'])(self::headersFromTrailerLines($easy->trailers), $easy->response); + } catch (\Throwable $e) { + return P\Create::rejectionFor( + new RequestException( + 'An error was encountered during the on_trailers event', + $easy->request, + $easy->response, + $e + ) + ); + } + } + + return new FulfilledPromise($easy->response); + } + + private static function invokeStats(EasyHandle $easy): void + { + $curlStats = \curl_getinfo($easy->handle); + $curlStats['appconnect_time'] = \curl_getinfo($easy->handle, \CURLINFO_APPCONNECT_TIME); + $stats = new TransferStats( + $easy->request, + $easy->response, + $curlStats['total_time'], + $easy->errno, + $curlStats + ); + ($easy->options['on_stats'])($stats); + } + + /** + * @param callable(RequestInterface, array): PromiseInterface $handler + */ + private static function finishError(callable $handler, EasyHandle $easy, CurlFactoryInterface $factory): PromiseInterface + { + // Get error information and release the handle to the factory. + $ctx = [ + 'errno' => $easy->errno, + 'error' => \curl_error($easy->handle), + 'appconnect_time' => \curl_getinfo($easy->handle, \CURLINFO_APPCONNECT_TIME), + ] + \curl_getinfo($easy->handle); + $ctx[self::CURL_VERSION_STR] = CurlVersion::getVersion() ?? ''; + $factory->release($easy); + + // Retry when nothing is present or when curl failed to rewind. + if (empty($easy->options['_err_message']) && (!$easy->errno || $easy->errno == 65)) { + return self::retryFailedRewind($handler, $easy, $ctx); + } + + return self::createRejection($easy, $ctx); + } + + private static function createRejection(EasyHandle $easy, array $ctx): PromiseInterface + { + static $connectionErrors = [ + \CURLE_OPERATION_TIMEOUTED => true, + \CURLE_COULDNT_RESOLVE_HOST => true, + \CURLE_COULDNT_CONNECT => true, + \CURLE_SSL_CONNECT_ERROR => true, + \CURLE_GOT_NOTHING => true, + ]; + + $uri = $easy->request->getUri(); + + // Redact the native error before it reaches any exception so the + // handler context matches the sanitized exception message. + $ctx['error'] = self::sanitizeCurlError((string) ($ctx['error'] ?? ''), $uri, $easy->effectiveProxy); + + if ($easy->createResponseException) { + return P\Create::rejectionFor( + new RequestException( + 'An error was encountered while creating the response', + $easy->request, + null, + $easy->createResponseException, + $ctx + ) + ); + } + + // If an exception was encountered during the onHeaders event, then + // return a rejected promise that wraps that exception. + if ($easy->onHeadersException) { + return P\Create::rejectionFor( + new RequestException( + 'An error was encountered during the on_headers event', + $easy->request, + $easy->response, + $easy->onHeadersException, + $ctx + ) + ); + } + + $sanitizedError = $ctx['error']; + + $message = \sprintf( + 'cURL error %s: %s (%s)', + $ctx['errno'], + $sanitizedError, + 'see https://curl.se/libcurl/c/libcurl-errors.html' + ); + + if ('' !== $sanitizedError) { + $redactedUriString = Psr7\Utils::redactUserInfo($uri)->__toString(); + if ($redactedUriString !== '' && false === \strpos($sanitizedError, $redactedUriString)) { + $message .= \sprintf(' for %s', $redactedUriString); + } + } + + // Create a connection exception if it was a specific error code. + $error = isset($connectionErrors[$easy->errno]) + ? new ConnectException($message, $easy->request, null, $ctx) + : new RequestException($message, $easy->request, $easy->response, null, $ctx); + + return P\Create::rejectionFor($error); + } + + private static function sanitizeCurlError(string $error, UriInterface $uri, ?string $proxy = null): string + { + if ('' === $error) { + return $error; + } + + $error = self::redactProxyUserInfo($error, $proxy); + + $baseUri = $uri->withQuery('')->withFragment(''); + $baseUriString = $baseUri->__toString(); + + if ('' === $baseUriString) { + return $error; + } + + $redactedUriString = Psr7\Utils::redactUserInfo($baseUri)->__toString(); + + return str_replace($baseUriString, $redactedUriString, $error); + } + + private static function redactProxyUserInfo(string $error, ?string $proxy): string + { + if ($proxy === null || $proxy === '' || \strpos($proxy, '@') === false) { + return $error; + } + + // The error message embeds the proxy string exactly as configured, so + // the userinfo needle is taken verbatim from the raw string: + // parse_url() and Psr7\Uri normalize the components, e.g. by rewriting + // raw control bytes to '_', which could make the replacement miss. + $proxyForParsing = \strpos($proxy, '://') === false ? 'http://'.$proxy : $proxy; + $remainder = \substr($proxyForParsing, \strpos($proxyForParsing, '://') + 3); + + if (\parse_url($proxyForParsing) === false) { + // Raw '/', '?', or '#' separators may sit inside the credentials + // of a proxy that defeats parse_url(), so the redaction cannot + // stop at the apparent authority. + $atPosition = \strrpos($remainder, '@'); + + if ($atPosition === false || $atPosition === 0) { + return $error; + } + + return \str_replace(\substr($remainder, 0, $atPosition).'@', '***@', $error); + } + + $authority = \substr($remainder, 0, \strcspn($remainder, '/?#')); + $atPosition = \strrpos($authority, '@'); + + if ($atPosition === false || $atPosition === 0) { + // A parseable proxy URL with '@' only past its authority, or with + // an empty userinfo, carries no credentials to redact. + return $error; + } + + $rawUserInfo = \substr($authority, 0, $atPosition); + + // Redact with the same policy Psr7\Utils::redactUserInfo() applies to + // request URIs, so the bundled psr7 version governs the redacted form. + $redactedUserInfo = '***'; + + try { + $proxyUri = new Uri($proxyForParsing); + $redactedUserInfo = Psr7\Utils::redactUserInfo($proxyUri)->getUserInfo(); + + if ($redactedUserInfo === $proxyUri->getUserInfo()) { + return $error; + } + } catch (\InvalidArgumentException $e) { + // Unparseable as a URI: fall back to redacting the whole userinfo. + } + + return \str_replace($rawUserInfo.'@', $redactedUserInfo.'@', $error); + } + + /** + * @param array $conf + */ + private static function forceFreshConnectionForAuthenticatedProxy(RequestInterface $request, array &$conf): void + { + $proxy = self::getEffectiveProxy($conf); + + if ($proxy === null || !self::requiresFreshConnectionForAuthenticatedProxy($request, $proxy, $conf)) { + return; + } + + $conf[\CURLOPT_FRESH_CONNECT] = true; + $conf[\CURLOPT_FORBID_REUSE] = true; + } + + /** + * @param array $conf + */ + private function isolateOpaqueShareAnonymousProxyTunnel(RequestInterface $request, array &$conf): void + { + if (!$this->opaqueShareConnectionCache || !CurlVersion::supportsShareConnectionCaches()) { + return; + } + + $proxy = self::getEffectiveProxy($conf); + if ( + $proxy === null + || !self::usesProxyTunnel($request, $conf) + || !self::isHttpProxyForConnectionReuse($proxy, $conf) + || self::hasAuthenticatedHttpProxyState($proxy, $conf) + ) { + return; + } + + // From libcurl 7.57.0 an opaque share handle can own a connection + // cache, and a tunnel seeded there with a literal Proxy-Authorization + // header is never keyed on credentials, so an anonymous request could + // inherit it on every later libcurl version. Requests carrying + // recognized credential state keep the version-gated channel + // safeguards above. + $conf[\CURLOPT_FRESH_CONNECT] = true; + $conf[\CURLOPT_FORBID_REUSE] = true; + } + + /** + * @param array $conf + */ + private static function assertFinalProxyOptionTypes(array $conf, bool $requiredCleartextMultiplex): void + { + if (\array_key_exists(\CURLOPT_PROXYTYPE, $conf) && !\is_int($conf[\CURLOPT_PROXYTYPE])) { + throw new \InvalidArgumentException('CURLOPT_PROXYTYPE must be an integer.'); + } + + foreach (['CURLOPT_PROXY', 'CURLOPT_NOPROXY', 'CURLOPT_PRE_PROXY'] as $name) { + if (!\defined($name)) { + continue; + } + + $option = (int) \constant($name); + if (\array_key_exists($option, $conf) && !\is_string($conf[$option])) { + if ($requiredCleartextMultiplex) { + throw new \InvalidArgumentException(\sprintf('The "multiplex" request option cannot be required when the final %s cURL option value is not a string.', $name)); + } + + throw new \InvalidArgumentException($name.' must be a string.'); + } + } + } + + /** + * @param array $conf + */ + private static function isolatePreProxyOnAffectedCurl(array &$conf): void + { + if (CurlVersion::supportsSocksProxyCredentialAwareConnectionReuse() || !\defined('CURLOPT_PRE_PROXY')) { + return; + } + + $option = (int) \constant('CURLOPT_PRE_PROXY'); + if (!\array_key_exists($option, $conf) || $conf[$option] === '') { + return; + } + + $conf[\CURLOPT_FRESH_CONNECT] = true; + $conf[\CURLOPT_FORBID_REUSE] = true; + } + + /** + * @param array $conf + */ + private static function getEffectiveProxy(array $conf): ?string + { + if (!\array_key_exists(\CURLOPT_PROXY, $conf)) { + return null; + } + + $proxy = $conf[\CURLOPT_PROXY]; + if (!\is_string($proxy) || $proxy === '') { + return null; + } + + // Only the exact raw wildcard is modeled here: libcurl treats '*' as + // bypass-all by whole-string comparison, without trimming or host matching. + if (\defined('CURLOPT_NOPROXY')) { + $noProxy = $conf[(int) \constant('CURLOPT_NOPROXY')] ?? null; + if (\is_string($noProxy) && $noProxy === '*') { + return null; + } + } + + return $proxy; + } + + /** + * @param array $conf + */ + private static function normalizeCurlHeaderOptions(array &$conf): void + { + $options = [\CURLOPT_HTTPHEADER => 'CURLOPT_HTTPHEADER']; + if (\defined('CURLOPT_PROXYHEADER')) { + $options[(int) \constant('CURLOPT_PROXYHEADER')] = 'CURLOPT_PROXYHEADER'; + } + + foreach ($options as $option => $label) { + if (!\array_key_exists($option, $conf) || !\is_array($conf[$option])) { + continue; + } + + $normalized = []; + foreach ($conf[$option] as $key => $entry) { + if (\is_object($entry) && \method_exists($entry, '__toString')) { + $entry = (string) $entry; + } elseif (\is_float($entry) && !\is_finite($entry)) { + $entry = \is_nan($entry) ? 'NAN' : ($entry > 0 ? 'INF' : '-INF'); + } elseif (\is_scalar($entry)) { + $entry = (string) $entry; + } else { + throw new \InvalidArgumentException(\sprintf('%s entries must be strings, stringable objects, or scalar values.', $label)); + } + + if (\strpbrk($entry, "\r\n") !== false) { + throw new \InvalidArgumentException(\sprintf('%s entries must not contain a carriage return or line feed.', $label)); + } + + $normalized[$key] = $entry; + } + + $conf[$option] = $normalized; + } + } + + private static function proxyScheme(string $proxy): ?string + { + $position = \strpos($proxy, '://'); + + return $position === false ? null : Psr7\Utils::asciiToLower(\substr($proxy, 0, $position)); + } + + /** + * @param array $conf + */ + private static function requiresFreshConnectionForAuthenticatedProxy(RequestInterface $request, string $proxy, array $conf): bool + { + // SOCKS authentication binds an identity to the connection itself, and + // below 7.69.0 an opaque configured share may already contain a SOCKS + // connection whose credential state Guzzle cannot inspect. Isolate + // authenticated and anonymous requests so neither can inherit it. + if (self::isSocksProxy($proxy, $conf)) { + return !CurlVersion::supportsSocksProxyCredentialAwareConnectionReuse(); + } + + if (!self::usesProxyTunnel($request, $conf) || !self::isHttpProxyForConnectionReuse($proxy, $conf)) { + return false; + } + + $proxyForParsing = \strpos($proxy, '://') === false ? 'http://'.$proxy : $proxy; + $proxyParts = \parse_url($proxyForParsing); + + if (!\is_array($proxyParts)) { + return false; + } + + if (self::hasCurlProxyAuthorizationHeader($conf)) { + return true; + } + + // A proxy client certificate or TLS-SRP authenticates the client to the + // HTTPS proxy at the TLS layer; libcurl ignored TLS-SRP before 7.83.1 + // (CVE-2022-27782), so an old build can reuse a tunnel across those + // identities. Force a fresh one, as the non-share signature path does. + if ( + !CurlVersion::supportsProxyTlsCredentialAwareConnectionReuse() + && self::hasCurlProxyTlsCredentials($conf) + ) { + return true; + } + + if (CurlVersion::supportsProxyCredentialAwareConnectionReuse()) { + return false; + } + + return \array_key_exists('user', $proxyParts) + || \array_key_exists('pass', $proxyParts) + || self::hasCurlProxyCredentials($conf); + } + + /** + * @param array $conf + */ + private static function hasAuthenticatedSocksProxyState(string $proxy, array $conf): bool + { + $proxyForParsing = \strpos($proxy, '://') === false ? 'http://'.$proxy : $proxy; + $proxyParts = \parse_url($proxyForParsing); + + if ( + \is_array($proxyParts) + && (\array_key_exists('user', $proxyParts) || \array_key_exists('pass', $proxyParts)) + ) { + return true; + } + + return self::hasCurlProxyCredentials($conf); + } + + /** + * @param array $conf + */ + private static function usesProxyTunnel(RequestInterface $request, array $conf): bool + { + $scheme = $request->getUri()->getScheme(); + + if ('https' === $scheme) { + return true; + } + + // An HTTP proxy auto-switches to a CONNECT tunnel when CONNECT_TO + // redirects the origin, so an http:// target with it set tunnels too. + if ('http' === $scheme && self::hasCurlConnectTo($conf)) { + return true; + } + + return \defined('CURLOPT_HTTPPROXYTUNNEL') + && \array_key_exists((int) \constant('CURLOPT_HTTPPROXYTUNNEL'), $conf) + && (bool) $conf[(int) \constant('CURLOPT_HTTPPROXYTUNNEL')]; + } + + /** + * @param array $conf + */ + private static function hasCurlConnectTo(array $conf): bool + { + if (!\defined('CURLOPT_CONNECT_TO')) { + return false; + } + + $option = (int) \constant('CURLOPT_CONNECT_TO'); + if (!\array_key_exists($option, $conf)) { + return false; + } + + $value = $conf[$option]; + + return \is_array($value) + ? $value !== [] + : $value !== null && $value !== false && $value !== ''; + } + + /** + * @param array $conf + */ + private static function isHttpProxyForConnectionReuse(string $proxy, array $conf): bool + { + if (\strpos($proxy, '://') !== false) { + $proxyParts = \parse_url($proxy); + + if (!\is_array($proxyParts) || !isset($proxyParts['scheme'])) { + return false; + } + + $proxyScheme = Psr7\Utils::asciiToLower($proxyParts['scheme']); + + return $proxyScheme === 'http' || $proxyScheme === 'https'; + } + + return !self::isSocksProxyType($conf[\CURLOPT_PROXYTYPE] ?? null); + } + + /** + * @param array $conf + */ + private static function isSocksProxy(string $proxy, array $conf): bool + { + $scheme = self::proxyScheme($proxy); + if ($scheme !== null) { + if (\in_array($scheme, ['socks', 'socks4', 'socks4a', 'socks5', 'socks5h'], true)) { + return true; + } + + // libcurl preserves a raw SOCKS CURLOPT_PROXYTYPE behind an http + // scheme, while every other scheme overrides the proxy type. + if ($scheme !== 'http') { + return false; + } + } + + return self::isSocksProxyType($conf[\CURLOPT_PROXYTYPE] ?? null); + } + + /** + * Computes the connection-reuse section signature for a SOCKS proxy. + * libcurl compares SOCKS credentials on connection reuse from 7.69.0 (curl + * #4835), so no sectioning is needed there. Older libcurl matches a SOCKS + * proxy by type, host, and port only, so every SOCKS request is sectioned + * by its credential state; hashing the credential-less state too keeps an + * unauthenticated request from inheriting an authenticated connection. + * + * @param array $conf + */ + private static function socksProxySignature(string $proxy, array $conf): ?string + { + if (CurlVersion::supportsSocksProxyCredentialAwareConnectionReuse()) { + return null; + } + + $credentialState = []; + foreach (['CURLOPT_PROXYUSERPWD', 'CURLOPT_PROXYUSERNAME', 'CURLOPT_PROXYPASSWORD', 'CURLOPT_PROXYTYPE'] as $name) { + $credentialState[$name] = \defined($name) + ? ($conf[(int) \constant($name)] ?? null) + : null; + } + + return \hash('sha256', \serialize(['socks', $proxy, $credentialState])); + } + + /** + * @param mixed $proxyType + */ + private static function isSocksProxyType($proxyType): bool + { + if (!\is_int($proxyType)) { + return false; + } + + foreach ([ + 'CURLPROXY_SOCKS4' => 4, + 'CURLPROXY_SOCKS5' => 5, + 'CURLPROXY_SOCKS4A' => 6, + 'CURLPROXY_SOCKS5_HOSTNAME' => 7, + ] as $name => $fallback) { + $value = \defined($name) ? (int) \constant($name) : $fallback; + if ($proxyType === $value) { + return true; + } + } + + return false; + } + + /** + * @param array $conf + */ + private static function hasCurlProxyCredentials(array $conf): bool + { + foreach (['CURLOPT_PROXYUSERPWD', 'CURLOPT_PROXYUSERNAME', 'CURLOPT_PROXYPASSWORD'] as $option) { + if (\defined($option) && \array_key_exists((int) \constant($option), $conf)) { + return true; + } + } + + return false; + } + + /** + * @param array $conf + */ + private static function hasCurlProxyTlsCredentials(array $conf): bool + { + foreach ([ + 'CURLOPT_PROXY_SSLCERT', + 'CURLOPT_PROXY_SSLCERT_BLOB', + 'CURLOPT_PROXY_TLSAUTH_USERNAME', + 'CURLOPT_PROXY_TLSAUTH_PASSWORD', + ] as $option) { + if (\defined($option) && \array_key_exists((int) \constant($option), $conf)) { + return true; + } + } + + return false; + } + + /** + * @param array $conf + */ + private static function hasCurlProxyAuthorizationHeader(array $conf): bool + { + return self::curlProxyAuthorizationHeaderValues($conf) !== []; + } + + /** + * @param array $conf + */ + private static function applyProxyAuthorizationHeaderHandling(RequestInterface $request, array &$conf): void + { + $proxy = self::getEffectiveProxy($conf); + if ($proxy === null || !self::isHttpProxyForConnectionReuse($proxy, $conf)) { + return; + } + + $httpHeaders = $conf[\CURLOPT_HTTPHEADER] ?? null; + $movedHeaders = []; + $originHeaders = []; + + if (\is_array($httpHeaders)) { + foreach ($httpHeaders as $header) { + if (\is_string($header) && self::isProxyAuthorizationHeaderLine($header)) { + $movedHeaders[] = $header; + + continue; + } + + $originHeaders[] = $header; + } + } + + if (CurlVersion::supportsProxyHeaderSeparation()) { + if ($movedHeaders !== []) { + $conf[\CURLOPT_HTTPHEADER] = $originHeaders; + self::appendCurlProxyHeaders($conf, $movedHeaders); + } + + // On libcurl 7.37.0-7.42.0 the default is CURLHEADER_UNIFIED. + if ($movedHeaders !== [] || self::hasCurlProxyHeaderOption($conf) || self::usesProxyTunnel($request, $conf)) { + $conf[(int) \constant('CURLOPT_HEADEROPT')] = (int) \constant('CURLHEADER_SEPARATE'); + } + + return; + } + + if (\is_array($httpHeaders) && self::proxyAuthorizationHeaderValuesFromList($httpHeaders) !== []) { + $conf[\CURLOPT_FRESH_CONNECT] = true; + $conf[\CURLOPT_FORBID_REUSE] = true; + } + } + + /** + * Routes the managed first-class Proxy-Authorization values to libcurl's + * proxy-only header channel, independently of Guzzle's proxy prediction: + * libcurl alone decides whether the proxy-only list is used for the + * actual transfer, so the credential can never reach an origin through + * CURLOPT_HTTPHEADER. Without proxy header separation support, values are + * safely omitted on known direct, bypassed, and SOCKS routes; a route that + * may use an HTTP(S) proxy is rejected before cURL initialization and + * network I/O. A deprecated raw CURLOPT_HTTPHEADER replacement suppresses + * every generated header, the managed values included. + * + * @param array $conf + * @param list $headers + */ + private static function applyManagedProxyAuthorization(RequestInterface $request, array &$conf, array $headers, bool $rawHttpHeadersReplaceManaged): void + { + if ($rawHttpHeadersReplaceManaged || $headers === []) { + return; + } + + if (!CurlVersion::supportsProxyHeaderSeparation()) { + $proxy = self::getEffectiveProxy($conf); + if ($proxy !== null && !self::isSocksProxy($proxy, $conf)) { + throw new RequestException('Proxy-Authorization request headers through a possible HTTP or HTTPS proxy require libcurl 7.37.0 or newer built with proxy header separation support.', $request); + } + + return; + } + + self::appendCurlProxyHeaders($conf, $headers); + $conf[(int) \constant('CURLOPT_HEADEROPT')] = (int) \constant('CURLHEADER_SEPARATE'); + } + + /** + * @return list + */ + private static function managedProxyAuthorizationHeaderLines(RequestInterface $request): array + { + $headers = []; + + foreach ($request->getHeader('Proxy-Authorization') as $value) { + $headers[] = $value === '' + ? 'Proxy-Authorization;' + : 'Proxy-Authorization: '.$value; + } + + return $headers; + } + + /** + * @param array $conf + * @param list $headers + */ + private static function appendCurlProxyHeaders(array &$conf, array $headers): void + { + $option = (int) \constant('CURLOPT_PROXYHEADER'); + + if (\array_key_exists($option, $conf)) { + if (!\is_array($conf[$option])) { + throw new \InvalidArgumentException('CURLOPT_PROXYHEADER must be an array when a Proxy-Authorization request header is routed to the proxy header channel.'); + } + + $headers = \array_merge($conf[$option], $headers); + } + + $conf[$option] = $headers; + } + + /** + * @param array $conf + */ + private static function hasCurlProxyHeaderOption(array $conf): bool + { + return \defined('CURLOPT_PROXYHEADER') + && \array_key_exists((int) \constant('CURLOPT_PROXYHEADER'), $conf); + } + + private static function isProxyAuthorizationHeaderLine(string $header): bool + { + $length = \strcspn($header, ':;'); + + if ($length === \strlen($header)) { + return false; + } + + return Psr7\Utils::caselessEquals(\trim(\substr($header, 0, $length), " \n\r\t\0\x0B"), 'Proxy-Authorization'); + } + + private static function proxyAuthorizationHeaderValue(string $header): ?string + { + $position = \strpos($header, ':'); + if ($position === false) { + return null; + } + + if (!Psr7\Utils::caselessEquals(\trim(\substr($header, 0, $position), " \n\r\t\0\x0B"), 'Proxy-Authorization')) { + return null; + } + + $value = \trim(\substr($header, $position + 1), " \n\r\t\0\x0B"); + + return $value === '' ? null : $value; + } + + /** + * @param mixed[] $headers + * + * @return list + */ + private static function proxyAuthorizationHeaderValuesFromList(array $headers): array + { + $values = []; + + foreach ($headers as $header) { + if (!\is_string($header)) { + continue; + } + + $value = self::proxyAuthorizationHeaderValue($header); + if ($value !== null) { + $values[] = $value; + } + } + + return $values; + } + + /** + * Computes the connection-reuse section signature for a proxy tunnel or + * SOCKS proxy, or null when the request does not require sectioning. * - * @param callable(RequestInterface, array): PromiseInterface $handler - * @param CurlFactoryInterface $factory Dictates how the handle is released + * @param array $conf */ - public static function finish(callable $handler, EasyHandle $easy, CurlFactoryInterface $factory): PromiseInterface + private static function proxyTunnelSignature(RequestInterface $request, array $conf): ?string { - if (isset($easy->options['on_stats'])) { - self::invokeStats($easy); + $proxy = self::getEffectiveProxy($conf); + if ($proxy === null) { + return null; } - if (!$easy->response || $easy->errno) { - return self::finishError($handler, $easy, $factory); + // SOCKS authentication binds an identity to the connection itself, for + // plain http:// requests as much as https://, so it sections ahead of + // the CONNECT tunnel domain checks. + if (self::isSocksProxy($proxy, $conf)) { + return self::socksProxySignature($proxy, $conf); } - // Return the response if it is present and there is no error. - $factory->release($easy); + if ( + !self::usesProxyTunnel($request, $conf) + || !self::isHttpProxyForConnectionReuse($proxy, $conf) + ) { + return null; + } - // Rewind the body of the response if possible. - $body = $easy->response->getBody(); - if ($body->isSeekable()) { - $body->rewind(); + $headerAuth = self::curlProxyAuthorizationHeaderValues($conf); + if ($headerAuth === [] && CurlVersion::supportsProxyCredentialAwareConnectionReuse()) { + // libcurl keys reuse on parsed proxy credentials only from 8.19.0, + // trusted from 8.20.0 (PROXY_CREDENTIAL_REUSE_VERSION); a literal + // Proxy-Authorization header is never keyed and always sections. + return self::DELEGATED_PROXY_TUNNEL_OWNER; } - return new FulfilledPromise($easy->response); - } + // Hash every proxy channel an old libcurl might not key reuse on. A + // changed signature only forces a fresh connection, never relaxes + // reuse, so over-covering is always safe; under-covering leaks. Proxy + // credentials are the channel CVE-2026-3784 missed; the proxy-TLS + // options are load-bearing on builds before the proxy-TLS reuse fixes + // (the proxy client cert is keyed from 7.52.0, libcurl's first + // HTTPS-proxy release; CVE-2016-5420 (7.50.1) is only the origin-cert + // precedent; TLS-SRP from 7.83.1, CVE-2022-27782) and harmless after. + // The private-key file and passphrase are hashed on this non-delegated + // path too, as fallback hardening: libcurl's mTLS private-key matching + // on reuse was incomplete before 8.21.0 (CVE-2026-8932). This does not + // cover the delegated path (the early return above) or configured share + // handles, so it is not a complete pre-8.21.0 mitigation. The key blob + // and cert/key type encodings (PROXY_SSLKEY_BLOB, PROXY_SSLKEYTYPE, + // PROXY_SSLCERTTYPE) are not hashed and are an accepted residual. + $credentialState = []; + foreach ([ + 'CURLOPT_PROXYUSERPWD', 'CURLOPT_PROXYUSERNAME', 'CURLOPT_PROXYPASSWORD', + 'CURLOPT_PROXYTYPE', + 'CURLOPT_PROXY_SSLCERT', 'CURLOPT_PROXY_SSLCERT_BLOB', 'CURLOPT_PROXY_SSLKEY', + 'CURLOPT_PROXY_KEYPASSWD', 'CURLOPT_PROXY_TLSAUTH_USERNAME', + 'CURLOPT_PROXY_TLSAUTH_PASSWORD', 'CURLOPT_PROXY_SSLVERSION', + ] as $name) { + $credentialState[$name] = \defined($name) + ? ($conf[(int) \constant($name)] ?? null) + : null; + } - private static function invokeStats(EasyHandle $easy): void - { - $curlStats = \curl_getinfo($easy->handle); - $curlStats['appconnect_time'] = \curl_getinfo($easy->handle, \CURLINFO_APPCONNECT_TIME); - $stats = new TransferStats( - $easy->request, - $easy->response, - $curlStats['total_time'], - $easy->errno, - $curlStats - ); - ($easy->options['on_stats'])($stats); + return \hash('sha256', \serialize([$proxy, $credentialState, $headerAuth])); } /** - * @param callable(RequestInterface, array): PromiseInterface $handler + * @param array $conf + * + * @return list */ - private static function finishError(callable $handler, EasyHandle $easy, CurlFactoryInterface $factory): PromiseInterface + private static function curlProxyAuthorizationHeaderValues(array $conf): array { - // Get error information and release the handle to the factory. - $ctx = [ - 'errno' => $easy->errno, - 'error' => \curl_error($easy->handle), - 'appconnect_time' => \curl_getinfo($easy->handle, \CURLINFO_APPCONNECT_TIME), - ] + \curl_getinfo($easy->handle); - $ctx[self::CURL_VERSION_STR] = \curl_version()['version']; - $factory->release($easy); - - // Retry when nothing is present or when curl failed to rewind. - if (empty($easy->options['_err_message']) && (!$easy->errno || $easy->errno == 65)) { - return self::retryFailedRewind($handler, $easy, $ctx); + if (!\defined('CURLOPT_PROXYHEADER')) { + return []; } - return self::createRejection($easy, $ctx); - } - - private static function createRejection(EasyHandle $easy, array $ctx): PromiseInterface - { - static $connectionErrors = [ - \CURLE_OPERATION_TIMEOUTED => true, - \CURLE_COULDNT_RESOLVE_HOST => true, - \CURLE_COULDNT_CONNECT => true, - \CURLE_SSL_CONNECT_ERROR => true, - \CURLE_GOT_NOTHING => true, - ]; - - if ($easy->createResponseException) { - return P\Create::rejectionFor( - new RequestException( - 'An error was encountered while creating the response', - $easy->request, - $easy->response, - $easy->createResponseException, - $ctx - ) - ); + $option = (int) \constant('CURLOPT_PROXYHEADER'); + if (!\array_key_exists($option, $conf)) { + return []; } - // If an exception was encountered during the onHeaders event, then - // return a rejected promise that wraps that exception. - if ($easy->onHeadersException) { - return P\Create::rejectionFor( - new RequestException( - 'An error was encountered during the on_headers event', - $easy->request, - $easy->response, - $easy->onHeadersException, - $ctx - ) - ); + $headers = $conf[$option]; + if (!\is_array($headers)) { + return []; } - $message = \sprintf( - 'cURL error %s: %s (%s)', - $ctx['errno'], - $ctx['error'], - 'see https://curl.haxx.se/libcurl/c/libcurl-errors.html' - ); - $uriString = (string) $easy->request->getUri(); - if ($uriString !== '' && false === \strpos($ctx['error'], $uriString)) { - $message .= \sprintf(' for %s', $uriString); - } + return self::proxyAuthorizationHeaderValuesFromList($headers); + } - // Create a connection exception if it was a specific error code. - $error = isset($connectionErrors[$easy->errno]) - ? new ConnectException($message, $easy->request, null, $ctx) - : new RequestException($message, $easy->request, $easy->response, null, $ctx); + private function discardIdleHandles(): void + { + foreach ($this->handles as $id => $handle) { + if (PHP_VERSION_ID < 80000) { + \curl_close($handle); + } - return P\Create::rejectionFor($error); + unset($this->handles[$id]); + } } /** @@ -218,33 +1896,126 @@ private static function createRejection(EasyHandle $easy, array $ctx): PromiseIn */ private function getDefaultConf(EasyHandle $easy): array { + $uri = $easy->request->getUri(); + $protocols = Utils::normalizeProtocols($easy->options['protocols'] ?? ['http', 'https']); + $scheme = $uri->getScheme(); + if (!\in_array($scheme, $protocols, true)) { + throw new RequestException(\sprintf('The scheme "%s" is not allowed by the protocols request option.', $scheme), $easy->request); + } + + if ($uri->getHost() === '') { + throw new RequestException('URI must include a scheme and host. Use an absolute URI, a network-path reference starting with //, or configure a base_uri.', $easy->request); + } + $conf = [ '_headers' => $easy->request->getHeaders(), \CURLOPT_CUSTOMREQUEST => $easy->request->getMethod(), - \CURLOPT_URL => (string) $easy->request->getUri()->withFragment(''), + \CURLOPT_URL => (string) $uri->withFragment(''), \CURLOPT_RETURNTRANSFER => false, \CURLOPT_HEADER => false, \CURLOPT_CONNECTTIMEOUT => 300, ]; if (\defined('CURLOPT_PROTOCOLS')) { - $conf[\CURLOPT_PROTOCOLS] = \CURLPROTO_HTTP | \CURLPROTO_HTTPS; + $conf[\CURLOPT_PROTOCOLS] = self::curlProtocolMask($protocols); } $version = $easy->request->getProtocolVersion(); - if ($version == 1.1) { + $multiplex = self::normalizeMultiplex($easy->options); + + if ('2' === $version || '2.0' === $version) { + if (\in_array($multiplex, [Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { + self::assertRequiredMultiplexSupported($easy); + // New HTTP/2 connections cannot negotiate HTTP/1.x here, and + // the 8.14.0 floor's version-aware reuse matching keeps + // reused connections on HTTP/2 as well. + $conf[\CURLOPT_HTTP_VERSION] = (int) \constant('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE'); + } else { + $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0; + } + + if (\in_array($multiplex, [Multiplexing::WAIT, Multiplexing::REQUIRE_WAIT], true) && CurlVersion::supportsMultiplex()) { + // Wait for a connection that is still being established to the + // same origin to reveal whether it can be multiplexed instead + // of immediately opening another connection. + $conf[(int) \constant('CURLOPT_PIPEWAIT')] = true; + } + } elseif ('1.1' === $version) { + if (\in_array($multiplex, [Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { + throw new ConnectException(\sprintf('The "multiplex" request option cannot be required for HTTP/%s requests; use protocol version 2.', $version), $easy->request); + } $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1; - } elseif ($version == 2.0) { - $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0; } else { + if (\in_array($multiplex, [Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { + throw new ConnectException(\sprintf('The "multiplex" request option cannot be required for HTTP/%s requests; use protocol version 2.', $version), $easy->request); + } $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_0; } return $conf; } + /** + * @param string[] $protocols + */ + private static function curlProtocolMask(array $protocols): int + { + $mask = 0; + + if (\in_array('http', $protocols, true)) { + $mask |= \CURLPROTO_HTTP; + } + + if (\in_array('https', $protocols, true)) { + $mask |= \CURLPROTO_HTTPS; + } + + return $mask; + } + + /** + * @param mixed $type + */ + private static function normalizeTlsFileType(string $option, $type): string + { + if (!\is_string($type) || $type === '') { + throw new \InvalidArgumentException(\sprintf('%s must be a non-empty string', $option)); + } + + return Psr7\Utils::asciiToUpper($type); + } + + private static function shouldValidateSslKeyFile(?string $type): bool + { + return $type !== 'ENG' && $type !== 'PROV'; + } + private function applyMethod(EasyHandle $easy, array &$conf): void { + if ($easy->request->getMethod() === 'HEAD') { + // libcurl stops at HEAD response headers only when CURLOPT_NOBODY + // is set; CURLOPT_CUSTOMREQUEST changes only the method string. + // NOBODY also suppresses request upload, so strip non-zero body + // length, transfer coding, and a 100-continue expectation. + $conf[\CURLOPT_CUSTOMREQUEST] = null; + $conf[\CURLOPT_NOBODY] = true; + unset( + $conf[\CURLOPT_WRITEFUNCTION], + $conf[\CURLOPT_READFUNCTION], + $conf[\CURLOPT_FILE], + $conf[\CURLOPT_INFILE] + ); + if (\trim($easy->request->getHeaderLine('Content-Length'), " \n\r\t\0\x0B") !== '0') { + $this->removeHeader('Content-Length', $conf); + } + $this->removeHeader('Transfer-Encoding', $conf); + if (Psr7\Utils::caselessEquals(\trim($easy->request->getHeaderLine('Expect'), " \n\r\t\0\x0B"), '100-continue')) { + $this->removeHeader('Expect', $conf); + } + + return; + } + $body = $easy->request->getBody(); $size = $body->getSize(); @@ -256,18 +2027,10 @@ private function applyMethod(EasyHandle $easy, array &$conf): void $method = $easy->request->getMethod(); if ($method === 'PUT' || $method === 'POST') { - // See https://tools.ietf.org/html/rfc7230#section-3.3.2 + // See https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2 if (!$easy->request->hasHeader('Content-Length')) { $conf[\CURLOPT_HTTPHEADER][] = 'Content-Length: 0'; } - } elseif ($method === 'HEAD') { - $conf[\CURLOPT_NOBODY] = true; - unset( - $conf[\CURLOPT_WRITEFUNCTION], - $conf[\CURLOPT_READFUNCTION], - $conf[\CURLOPT_FILE], - $conf[\CURLOPT_INFILE] - ); } } @@ -294,8 +2057,19 @@ private function applyBody(RequestInterface $request, array $options, array &$co if ($body->isSeekable()) { $body->rewind(); } - $conf[\CURLOPT_READFUNCTION] = static function ($ch, $fd, $length) use ($body) { - return $body->read($length); + $remaining = $size; + $conf[\CURLOPT_READFUNCTION] = static function ($ch, $fd, $length) use ($body, &$remaining) { + if ($remaining === 0) { + return ''; + } + + $limit = $remaining === null ? $length : \min($length, $remaining); + $data = $body->read($limit); + if ($remaining !== null) { + $remaining -= \strlen($data); + } + + return $data; }; } @@ -313,6 +2087,17 @@ private function applyBody(RequestInterface $request, array $options, array &$co private function applyHeaders(EasyHandle $easy, array &$conf): void { foreach ($conf['_headers'] as $name => $values) { + // A first-class Proxy-Authorization header is proxy-scoped and + // must never be generated in the origin header list; managed + // handling routes it to CURLOPT_PROXYHEADER or safely omits it on + // a legacy non-HTTP-proxy route. The + // caselessEquals() helper is locale-independent, unlike + // strcasecmp(), so a locale cannot make this match miss and + // re-leak the credential. + if (Psr7\Utils::caselessEquals((string) $name, 'Proxy-Authorization')) { + continue; + } + foreach ($values as $value) { $value = (string) $value; if ($value === '') { @@ -340,7 +2125,7 @@ private function applyHeaders(EasyHandle $easy, array &$conf): void private function removeHeader(string $name, array &$options): void { foreach (\array_keys($options['_headers']) as $key) { - if (!\strcasecmp($key, $name)) { + if (Psr7\Utils::caselessEquals((string) $key, $name)) { unset($options['_headers'][$key]); return; @@ -382,27 +2167,29 @@ private function applyHandlerOptions(EasyHandle $easy, array &$conf): void } } - if (!isset($options['curl'][\CURLOPT_ENCODING]) && !empty($options['decode_content'])) { + if (!isset($options['curl'][\CURLOPT_ENCODING]) && isset($options['decode_content']) && $options['decode_content'] !== false) { $accept = $easy->request->getHeaderLine('Accept-Encoding'); - if ($accept) { + if ($accept !== '') { $conf[\CURLOPT_ENCODING] = $accept; } else { // The empty string enables all available decoders and implicitly // sets a matching 'Accept-Encoding' header. $conf[\CURLOPT_ENCODING] = ''; - // But as the user did not specify any acceptable encodings we need - // to overwrite this implicit header with an empty one. + // But as the user did not specify any encoding preference, + // let's leave it up to server by preventing curl from sending + // the header, which will be interpreted as 'Accept-Encoding: *'. + // https://www.rfc-editor.org/rfc/rfc9110#field.accept-encoding $conf[\CURLOPT_HTTPHEADER][] = 'Accept-Encoding:'; } } if (!isset($options['sink'])) { // Use a default temp stream if no sink was set. - $options['sink'] = \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7\Utils::tryFopen('php://temp', 'w+'); + $options['sink'] = Psr7\Utils::tryFopen('php://temp', 'w+'); } $sink = $options['sink']; if (!\is_string($sink)) { - $sink = \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7\Utils::streamFor($sink); + $sink = Psr7\Utils::streamFor($sink); } elseif (!\is_dir(\dirname($sink))) { // Ensure that the directory exists before failing in curl. throw new \RuntimeException(\sprintf('Directory %s does not exist for sink value of %s', \dirname($sink), $sink)); @@ -434,82 +2221,86 @@ private function applyHandlerOptions(EasyHandle $easy, array &$conf): void $conf[\CURLOPT_CONNECTTIMEOUT_MS] = $options['connect_timeout'] * 1000; } - if ($timeoutRequiresNoSignal && \strtoupper(\substr(\PHP_OS, 0, 3)) !== 'WIN') { + if ($timeoutRequiresNoSignal && Psr7\Utils::asciiToUpper(\substr(\PHP_OS, 0, 3)) !== 'WIN') { $conf[\CURLOPT_NOSIGNAL] = true; } - if (isset($options['proxy'])) { - if (!\is_array($options['proxy'])) { - $conf[\CURLOPT_PROXY] = $options['proxy']; - } else { - $scheme = $easy->request->getUri()->getScheme(); - if (isset($options['proxy'][$scheme])) { - $host = $easy->request->getUri()->getHost(); - if (isset($options['proxy']['no']) && Utils::isHostInNoProxy($host, $options['proxy']['no'])) { - unset($conf[\CURLOPT_PROXY]); - } else { - $conf[\CURLOPT_PROXY] = $options['proxy'][$scheme]; - } - } - } + // Always pin CURLOPT_PROXY (and CURLOPT_NOPROXY when available) so + // that libcurl never falls back to reading proxy environment + // variables itself. When the proxy request option makes no decision, + // the environment is resolved here with libcurl's own semantics. + [$proxyConf, $noProxyConf] = self::resolveProxy($easy->request, $options); + self::assertResolvedProxySupported($easy->request, $proxyConf); + + $conf[\CURLOPT_PROXY] = $proxyConf; + if (\defined('CURLOPT_NOPROXY')) { + $conf[(int) \constant('CURLOPT_NOPROXY')] = $noProxyConf; } - if (isset($options['crypto_method'])) { - if (\STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT === $options['crypto_method']) { - if (!defined('CURL_SSLVERSION_TLSv1_0')) { - throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.0 not supported by your version of cURL'); - } - $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_0; - } elseif (\STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT === $options['crypto_method']) { - if (!defined('CURL_SSLVERSION_TLSv1_1')) { - throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.1 not supported by your version of cURL'); - } - $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_1; - } elseif (\STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT === $options['crypto_method']) { - if (!defined('CURL_SSLVERSION_TLSv1_2')) { - throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.2 not supported by your version of cURL'); - } - $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_2; - } elseif (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT === $options['crypto_method']) { - if (!defined('CURL_SSLVERSION_TLSv1_3')) { - throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.3 not supported by your version of cURL'); - } - $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_3; - } else { - throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided'); - } + $this->applyTlsVersionRange($easy, $conf); + + $certType = null; + if (isset($options['cert_type'])) { + $certType = self::normalizeTlsFileType('cert_type', $options['cert_type']); + $conf[\CURLOPT_SSLCERTTYPE] = $certType; } if (isset($options['cert'])) { $cert = $options['cert']; if (\is_array($cert)) { - $conf[\CURLOPT_SSLCERTPASSWD] = $cert[1]; + if (!isset($cert[0]) || !\is_string($cert[0])) { + throw new \InvalidArgumentException('Invalid cert request option'); + } + if (isset($cert[1])) { + if (!\is_string($cert[1])) { + throw new \InvalidArgumentException('Invalid cert request option'); + } + $conf[\CURLOPT_SSLCERTPASSWD] = $cert[1]; + } $cert = $cert[0]; } + if (!\is_string($cert)) { + throw new \InvalidArgumentException('Invalid cert request option'); + } if (!\file_exists($cert)) { throw new \InvalidArgumentException("SSL certificate not found: {$cert}"); } // OpenSSL (versions 0.9.3 and later) also support "P12" for PKCS#12-encoded files. // see https://curl.se/libcurl/c/CURLOPT_SSLCERTTYPE.html $ext = pathinfo($cert, \PATHINFO_EXTENSION); - if (preg_match('#^(der|p12)$#i', $ext)) { - $conf[\CURLOPT_SSLCERTTYPE] = strtoupper($ext); + if ($certType === null && preg_match('#^(der|p12)$#iD', $ext)) { + $conf[\CURLOPT_SSLCERTTYPE] = Psr7\Utils::asciiToUpper($ext); } $conf[\CURLOPT_SSLCERT] = $cert; } + $sslKeyType = null; + if (isset($options['ssl_key_type'])) { + $sslKeyType = self::normalizeTlsFileType('ssl_key_type', $options['ssl_key_type']); + $conf[\CURLOPT_SSLKEYTYPE] = $sslKeyType; + } + if (isset($options['ssl_key'])) { if (\is_array($options['ssl_key'])) { - if (\count($options['ssl_key']) === 2) { - [$sslKey, $conf[\CURLOPT_SSLKEYPASSWD]] = $options['ssl_key']; - } else { - [$sslKey] = $options['ssl_key']; + if (!isset($options['ssl_key'][0]) || !\is_string($options['ssl_key'][0])) { + throw new \InvalidArgumentException('Invalid ssl_key request option'); } + if (isset($options['ssl_key'][1])) { + if (!\is_string($options['ssl_key'][1])) { + throw new \InvalidArgumentException('Invalid ssl_key request option'); + } + $conf[\CURLOPT_SSLKEYPASSWD] = $options['ssl_key'][1]; + } + $sslKey = $options['ssl_key'][0]; } $sslKey = $sslKey ?? $options['ssl_key']; - if (!\file_exists($sslKey)) { + if (!\is_string($sslKey)) { + throw new \InvalidArgumentException('Invalid ssl_key request option'); + } + + if (self::shouldValidateSslKeyFile($sslKeyType) && !\file_exists($sslKey)) { throw new \InvalidArgumentException("SSL private key not found: {$sslKey}"); } $conf[\CURLOPT_SSLKEY] = $sslKey; @@ -532,6 +2323,122 @@ private function applyHandlerOptions(EasyHandle $easy, array &$conf): void } } + private function applyTlsVersionRange(EasyHandle $easy, array &$conf): void + { + $options = $easy->options; + $cryptoMethod = $options['crypto_method'] ?? null; + $cryptoMethodMax = $options['crypto_method_max'] ?? null; + + if ($cryptoMethod === null && $cryptoMethodMax === null) { + return; + } + + $protocolVersion = $easy->request->getProtocolVersion(); + $isHttp2 = '2' === $protocolVersion || '2.0' === $protocolVersion; + + if ($isHttp2 && $cryptoMethodMax !== null && TlsVersion::ordinal('crypto_method_max', $cryptoMethodMax) < 12) { + throw new \InvalidArgumentException( + 'Invalid crypto_method_max request option: HTTP/2 requires TLS 1.2 or higher' + ); + } + + if ($isHttp2 && $cryptoMethod !== null && TlsVersion::ordinal('crypto_method', $cryptoMethod) < 12) { + $cryptoMethod = \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; + } + + TlsVersion::assertRange($cryptoMethod, $cryptoMethodMax); + + $sslVersion = $cryptoMethod === null + ? \CURL_SSLVERSION_DEFAULT + : self::curlMinSslVersion($cryptoMethod); + + if ($cryptoMethodMax !== null) { + $sslVersion |= self::curlMaxSslVersion($cryptoMethodMax); + } + + $conf[\CURLOPT_SSLVERSION] = $sslVersion; + } + + /** + * @param mixed $value + */ + private static function curlMinSslVersion($value): int + { + if ($value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT) { + return \CURL_SSLVERSION_TLSv1_0; + } + + if ($value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT) { + return \CURL_SSLVERSION_TLSv1_1; + } + + if ($value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT) { + if (!CurlVersion::supportsTls12()) { + throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.2 not supported by your version of cURL'); + } + + return \CURL_SSLVERSION_TLSv1_2; + } + + if (\defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && $value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT) { + if (!CurlVersion::supportsTls13()) { + throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.3 not supported by your version of cURL'); + } + + return \CURL_SSLVERSION_TLSv1_3; + } + + throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided'); + } + + /** + * @param mixed $value + */ + private static function curlMaxSslVersion($value): int + { + if ($value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT) { + return self::requireCurlMaxSslVersion('CURL_SSLVERSION_MAX_TLSv1_0'); + } + + if ($value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT) { + return self::requireCurlMaxSslVersion('CURL_SSLVERSION_MAX_TLSv1_1'); + } + + if ($value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT) { + return self::requireCurlMaxSslVersion('CURL_SSLVERSION_MAX_TLSv1_2'); + } + + if (\defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && $value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT) { + return self::requireCurlMaxSslVersion('CURL_SSLVERSION_MAX_TLSv1_3'); + } + + throw new \InvalidArgumentException('Invalid crypto_method_max request option: unknown version provided'); + } + + private static function requireCurlMaxSslVersion(string $constant): int + { + if (\defined($constant)) { + /** @var int */ + return \constant($constant); + } + + throw new \InvalidArgumentException( + 'Invalid crypto_method_max request option: maximum TLS version control is not supported by your version of cURL' + ); + } + + private static function validateRequestUriScheme(RequestInterface $request): void + { + $scheme = $request->getUri()->getScheme(); + if ($scheme === '') { + throw new RequestException('URI must include a scheme and host. Use an absolute URI, a network-path reference starting with //, or configure a base_uri.', $request); + } + + if (!\in_array($scheme, ['http', 'https'], true)) { + throw new RequestException(\sprintf("The scheme '%s' is not supported.", $scheme), $request); + } + } + /** * This function ensures that a response was set on a transaction. If one * was not set, then the request is retried if possible. This error @@ -579,6 +2486,24 @@ private static function retryFailedRewind(callable $handler, EasyHandle $easy, a return $handler($easy->request, $easy->options); } + /** + * Parses validated trailer field lines into an associative array keyed by + * lowercased field name, preserving first-occurrence key order and wire + * value order. + */ + private static function headersFromTrailerLines(array $lines): array + { + $headers = []; + + foreach ($lines as $line) { + [$name, $value] = \explode(':', $line, 2); + $name = Psr7\Utils::asciiToLower(\trim($name, " \n\r\t\0\x0B")); + $headers[$name][] = \trim($value, " \n\r\t\0\x0B"); + } + + return $headers; + } + private function createHeaderFn(EasyHandle $easy): callable { if (isset($easy->options['on_headers'])) { @@ -591,17 +2516,29 @@ private function createHeaderFn(EasyHandle $easy): callable $onHeaders = null; } + $startingResponse = false; + $collectingTrailers = false; + $retainTrailers = isset($easy->options['on_trailers']); + return static function ($ch, $h) use ( $onHeaders, $easy, - &$startingResponse + &$startingResponse, + &$collectingTrailers, + $retainTrailers ) { - $value = \trim($h); - if ($value === '') { + $value = \trim($h, " \n\r\t\0\x0B"); + if ($h === "\r\n" || $h === "\n" || $h === "\r" || $h === '') { + if ($collectingTrailers) { + // A blank line ends the trailer section; the response has + // already been created. + return \strlen($h); + } $startingResponse = true; try { $easy->createResponse(); - } catch (\Exception $e) { + } catch (\Throwable $e) { + $easy->response = null; $easy->createResponseException = $e; return -1; @@ -609,7 +2546,7 @@ private function createHeaderFn(EasyHandle $easy): callable if ($onHeaders !== null) { try { $onHeaders($easy->response); - } catch (\Exception $e) { + } catch (\Throwable $e) { // Associate the exception with the handle and trigger // a curl header write error by returning 0. $easy->onHeadersException = $e; @@ -617,9 +2554,22 @@ private function createHeaderFn(EasyHandle $easy): callable return -1; } } - } elseif ($startingResponse) { + } elseif ($startingResponse || $collectingTrailers) { + if ($easy->response !== null && !HeaderProcessor::isStatusLineCandidate($h)) { + // Trailer fields arrive through the header callback after + // the body; a new header block always begins with a status + // line. + $collectingTrailers = true; + + if ($retainTrailers && HeaderProcessor::isValidHeaderFieldLine($h)) { + $easy->trailers[] = $value; + } + } else { + $collectingTrailers = false; + $easy->trailers = []; + $easy->headers = [$value]; + } $startingResponse = false; - $easy->headers = [$value]; } else { $easy->headers[] = $value; } @@ -630,9 +2580,6 @@ private function createHeaderFn(EasyHandle $easy): callable public function __destruct() { - foreach ($this->handles as $id => $handle) { - \curl_close($handle); - unset($this->handles[$id]); - } + $this->discardIdleHandles(); } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/CurlHandler.php b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlHandler.php index 0a673f0f..94a9edc4 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Handler/CurlHandler.php +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlHandler.php @@ -3,6 +3,7 @@ namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Handler; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\TransportSharing; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface; /** @@ -16,30 +17,70 @@ */ class CurlHandler { + private const KNOWN_CONSTRUCTOR_OPTIONS = [ + 'handle_factory' => true, + 'transport_sharing' => true, + ]; + /** * @var CurlFactoryInterface */ private $factory; + /** + * @var CurlShareHandleState|null + */ + private $shareHandleState; + /** * Accepts an associative array of options: * * - handle_factory: Optional curl factory used to create cURL handles. + * - transport_sharing: Optional transport sharing mode. * - * @param array{handle_factory?: ?CurlFactoryInterface} $options Array of options to use with the handler + * @param array{handle_factory?: ?CurlFactoryInterface, transport_sharing?: mixed} $options Array of options to use with the handler */ public function __construct(array $options = []) { - $this->factory = $options['handle_factory'] - ?? new CurlFactory(3); + foreach ($options as $name => $_) { + if (!isset(self::KNOWN_CONSTRUCTOR_OPTIONS[$name])) { + \trigger_deprecation('guzzlehttp/guzzle', '7.14', \sprintf('The "%s" CurlHandler constructor option is unknown; guzzlehttp/guzzle 8.0 will reject unknown constructor options.', (string) $name)); + } + } + + CurlShareHandleState::assertNoRequiredSharingCustomFactoryConflict($options, 'CurlHandler'); + $transportSharing = $options['transport_sharing'] ?? null; + $sharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing'); + + if (\array_key_exists('handle_factory', $options) && $options['handle_factory'] !== null) { + $this->shareHandleState = null; + $this->factory = $options['handle_factory']; + + return; + } + + $this->shareHandleState = $sharingMode !== TransportSharing::NONE + ? CurlShareHandleState::fromOption($transportSharing) + : null; + + $this->factory = $this->shareHandleState !== null + ? new CurlFactory(3, $this->shareHandleState->mode, $this->shareHandleState) + : new CurlFactory(3); } public function __invoke(RequestInterface $request, array $options): PromiseInterface { + HostValidator::assertRequestHost($request); + if (isset($options['delay'])) { \usleep($options['delay'] * 1000); } + // A Multiplexing::NONE request option holds unconditionally here: + // transport sharing never shares the connection cache on this + // branch, and nothing else executes during the blocking curl_exec(), + // so the transfer cannot share its connection with a concurrent + // transfer. $easy = $this->factory->create($request, $options); \curl_exec($easy->handle); $easy->errno = \curl_errno($easy->handle); diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/CurlMultiHandler.php b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlMultiHandler.php index ea01c61b..c6d2dac2 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Handler/CurlMultiHandler.php +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlMultiHandler.php @@ -2,9 +2,14 @@ namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Handler; +use Closure; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\RequestException; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Multiplexing; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise as P; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\Promise; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\TransportSharing; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Utils; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface; @@ -19,11 +24,41 @@ */ class CurlMultiHandler { + private const KNOWN_CONSTRUCTOR_OPTIONS = [ + 'handle_factory' => true, + 'max_host_connections' => true, + 'max_total_connections' => true, + 'multiplex' => true, + 'options' => true, + 'select_timeout' => true, + 'transport_sharing' => true, + ]; + + private const CONNECTION_CAP_OPTIONS = [ + 'max_host_connections' => 'CURLMOPT_MAX_HOST_CONNECTIONS', + 'max_total_connections' => 'CURLMOPT_MAX_TOTAL_CONNECTIONS', + ]; + + /** + * cURL options that isolate a transfer from foreign proxy tunnel + * connections. Failing to apply either one would fall open into + * credential-bearing connection reuse. + */ + private const PROXY_TUNNEL_ISOLATION_OPTIONS = [ + 'CURLOPT_FRESH_CONNECT', + 'CURLOPT_FORBID_REUSE', + ]; + /** * @var CurlFactoryInterface */ private $factory; + /** + * @var CurlShareHandleState|null + */ + private $shareHandleState; + /** * @var int */ @@ -53,32 +88,190 @@ class CurlMultiHandler */ private $options = []; + /** + * @var array Native options derived from first-class + * constructor options; failing to apply one is an + * error rather than a compatibility warning. + */ + private $requiredOptions = []; + + /** + * @var bool Whether any connection cap constructor option was applied + */ + private $connectionCapsApplied = false; + + /** + * @var bool Whether the "multiplex" constructor option disabled + * multiplexing on this handler's multi handle + */ + private $multiplexDisabled = false; + + /** + * @var bool Whether a custom "handle_factory" constructor option supplies + * the easy handles + */ + private $customHandleFactory = false; + /** @var resource|\CurlMultiHandle */ private $_mh; + /** + * @var int Depth of nested guarded native operations (execution and + * handle removal, both of which can run user callbacks). A + * callback can re-enter tick(), and the nested frame must not + * clear the outer frame's guard; deferred work stays parked + * until the outermost frame unwinds. + */ + private $multiExecDepth = 0; + + /** + * @var bool Guards finishDeferredWork() against re-entry from the + * guarded native removals it performs while flushing. + */ + private $finishingDeferredWork = false; + + /** + * @var array + */ + private $deferredCancels = []; + + /** + * @var array Wait tokens of requests created from inside + * a cURL callback, keyed by handle id; native + * attachment is deferred until the outermost + * native execution unwinds. + */ + private $deferredAdds = []; + + /** + * @var string|null Owner signature of the proxy tunnels the multi handle's + * connection cache may hold + */ + private $proxyTunnelOwner; + + /** @var array Count of attached transfers per proxy tunnel signature. */ + private $activeProxyTunnelSignatures = []; + + /** @var array Maps an attached handle id to its proxy tunnel signature. */ + private $activeProxyTunnelHandles = []; + + /** + * @var int Depth of nested processMessages() calls. Guards against + * multi-handle recreation re-entrancy from processMessages (a + * retried transfer re-invokes the handler); a depth is tracked + * because a completion callback can re-enter tick(). + */ + private $messageProcessingDepth = 0; + /** * This handler accepts the following options: * * - handle_factory: An optional factory used to create curl handles + * - transport_sharing: Optional transport sharing mode. * - select_timeout: Optional timeout (in seconds) to block before timing * out while selecting curl handles. Defaults to 1 second. + * - max_host_connections: Optional maximum concurrent connections per host. + * - max_total_connections: Optional maximum concurrent connections overall. + * - multiplex: Optional Multiplexing::NONE to disallow multiplexing on + * this handler's multi handle. The eager, wait, and required modes are + * request options, not handler options; Multiplexing::NONE is also + * conditionally accepted as a request option value. * - options: An associative array of CURLMOPT_* options and * corresponding values for curl_multi_setopt() */ public function __construct(array $options = []) { - $this->factory = $options['handle_factory'] ?? new CurlFactory(50); + foreach ($options as $name => $_) { + if (!isset(self::KNOWN_CONSTRUCTOR_OPTIONS[$name])) { + \trigger_deprecation('guzzlehttp/guzzle', '7.14', \sprintf('The "%s" CurlMultiHandler constructor option is unknown; guzzlehttp/guzzle 8.0 will reject unknown constructor options.', (string) $name)); + } + } + + $handlerMultiplex = $options['multiplex'] ?? null; + if (null !== $handlerMultiplex && Multiplexing::NONE !== $handlerMultiplex) { + if (\in_array($handlerMultiplex, [Multiplexing::EAGER, Multiplexing::WAIT, Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { + throw new \InvalidArgumentException('The "multiplex" CurlMultiHandler option only accepts Multiplexing::NONE; the eager, wait, and required modes are request options.'); + } + + throw new \InvalidArgumentException(\sprintf('The "multiplex" CurlMultiHandler option must be null or Multiplexing::NONE; received %s.', \get_debug_type($handlerMultiplex))); + } + $this->multiplexDisabled = null !== $handlerMultiplex; + + if ($this->multiplexDisabled && !\defined('CURLMOPT_PIPELINING')) { + // ext-curl only defines the constant when built against libcurl + // 7.16 or newer headers, and such builds compile out the matching + // curl_multi_setopt() case, so the guarantee cannot be applied. + throw new \InvalidArgumentException('The "multiplex" CurlMultiHandler option requires CURLMOPT_PIPELINING, but it is not available in the installed PHP cURL extension.'); + } + + CurlShareHandleState::assertNoRequiredSharingCustomFactoryConflict($options, 'CurlMultiHandler'); + $transportSharing = $options['transport_sharing'] ?? null; + $sharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing'); + + if (\array_key_exists('handle_factory', $options) && $options['handle_factory'] !== null) { + $this->shareHandleState = null; + $this->factory = $options['handle_factory']; + $this->customHandleFactory = true; + } else { + $this->shareHandleState = $sharingMode !== TransportSharing::NONE + ? CurlShareHandleState::fromOption($transportSharing) + : null; + + $this->factory = $this->shareHandleState !== null + ? new CurlFactory(50, $this->shareHandleState->mode, $this->shareHandleState) + : new CurlFactory(50); + } if (isset($options['select_timeout'])) { - $this->selectTimeout = $options['select_timeout']; + $selectTimeout = $options['select_timeout']; + if (!\is_int($selectTimeout) && !\is_float($selectTimeout) && (!\is_string($selectTimeout) || !\is_numeric($selectTimeout))) { + \trigger_deprecation('guzzlehttp/guzzle', '7.14', 'Passing a non-numeric "select_timeout" CurlMultiHandler option is deprecated; guzzlehttp/guzzle 8.0 will reject it.'); + } else { + $seconds = (float) $selectTimeout; + if (!\is_finite($seconds) || $seconds < 0 || ($seconds > 0 && (int) ($seconds * 1000) === 0)) { + \trigger_deprecation('guzzlehttp/guzzle', '7.14', 'Passing a "select_timeout" CurlMultiHandler option that is not 0 or greater than or equal to 0.001 seconds is deprecated; guzzlehttp/guzzle 8.0 will reject it.'); + } + } + + $this->selectTimeout = $selectTimeout; } elseif ($selectTimeout = Utils::getenv('GUZZLE_CURL_SELECT_TIMEOUT')) { - @trigger_error('Since Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle 7.2.0: Using environment variable GUZZLE_CURL_SELECT_TIMEOUT is deprecated. Use option "select_timeout" instead.', \E_USER_DEPRECATED); + \trigger_deprecation('guzzlehttp/guzzle', '7.2', 'The GUZZLE_CURL_SELECT_TIMEOUT environment variable is deprecated; use the "select_timeout" option instead.'); $this->selectTimeout = (int) $selectTimeout; } else { $this->selectTimeout = 1; } - $this->options = $options['options'] ?? []; + $multiOptions = $options['options'] ?? []; + if (\is_array($multiOptions)) { + self::rejectConnectionCapOptionConflicts($options, $multiOptions); + + if ($this->multiplexDisabled && \array_key_exists(\CURLMOPT_PIPELINING, $multiOptions)) { + // Key presence alone conflicts, even with an agreeing value: + // the named option is the single multiplexing authority. + throw new \InvalidArgumentException('multiplex conflicts with a CURLMOPT_PIPELINING entry in the "options" array.'); + } + + self::triggerConflictingCurlMultiOptionDeprecations($multiOptions); + } elseif (self::hasConnectionCapOption($options)) { + throw new \InvalidArgumentException('options must be an array of cURL multi options when using connection cap options.'); + } elseif ($this->multiplexDisabled) { + throw new \InvalidArgumentException('options must be an array of cURL multi options when using the "multiplex" option.'); + } + + $this->options = $multiOptions; + + if (\is_array($multiOptions)) { + $this->addConnectionCapOptions($options); + + if ($this->multiplexDisabled) { + // CURLPIPE_NOTHING; the constant itself needs libcurl 7.43 + // headers, newer than the oldest supported runtimes. The + // option is required: a handler-wide guarantee must fail + // closed rather than warn like the deprecated raw options. + $this->options[\CURLMOPT_PIPELINING] = 0; + $this->requiredOptions[\CURLMOPT_PIPELINING] = true; + } + } // unsetting the property forces the first access to go through // __get(). @@ -90,8 +283,9 @@ public function __construct(array $options = []) * * @return resource|\CurlMultiHandle * - * @throws \BadMethodCallException when another field as `_mh` will be gotten - * @throws \RuntimeException when curl can not initialize a multi handle + * @throws \BadMethodCallException when another field as `_mh` will be gotten + * @throws \RuntimeException when curl can not initialize a multi handle + * @throws \InvalidArgumentException when a required cURL multi option cannot be applied */ public function __get($name) { @@ -105,163 +299,1154 @@ public function __get($name) throw new \RuntimeException('Can not initialize curl multi handle.'); } - $this->_mh = $multiHandle; + try { + foreach ($this->options as $option => $value) { + if (true === @curl_multi_setopt($multiHandle, $option, $value)) { + continue; + } - foreach ($this->options as $option => $value) { - // A warning is raised in case of a wrong option. - curl_multi_setopt($this->_mh, $option, $value); + if (isset($this->requiredOptions[$option])) { + // A first-class option such as a connection cap must + // never be silently dropped. + throw new \InvalidArgumentException(\sprintf('Unable to apply the cURL multi option %s; it was rejected by the runtime libcurl.', self::formatCurlMultiOption($option))); + } + + \trigger_error(\sprintf('Unable to apply the cURL multi option %s; it was ignored by the runtime libcurl.', self::formatCurlMultiOption($option)), \E_USER_WARNING); + } + } catch (\Throwable $e) { + // Do not publish a partially configured handle; a later access + // retries the initialization from scratch. + try { + \curl_multi_close($multiHandle); + } catch (\Throwable $ignored) { + // Preserve the original failure. + } + + throw $e; } + $this->_mh = $multiHandle; + return $this->_mh; } public function __destruct() { if (isset($this->_mh)) { - \curl_multi_close($this->_mh); - unset($this->_mh); + try { + \curl_multi_close($this->_mh); + } catch (\Throwable $e) { + // Destructors must not throw. + } finally { + unset($this->_mh); + } } } public function __invoke(RequestInterface $request, array $options): PromiseInterface { + HostValidator::assertRequestHost($request); + + if ($this->connectionCapsApplied + && \defined('CURLOPT_SHARE') + && isset($options['curl']) + && \is_array($options['curl']) + && \array_key_exists((int) \constant('CURLOPT_SHARE'), $options['curl']) + ) { + // Key presence alone conflicts: Guzzle cannot verify that a + // caller-managed shared connection pool honors the caps. + throw new \InvalidArgumentException('The request-level CURLOPT_SHARE cURL option cannot be combined with CurlMultiHandler connection cap options because Guzzle cannot verify that an external shared connection pool honors cURL multi connection caps.'); + } + $easy = $this->factory->create($request, $options); + + try { + $this->rejectMultiplexPipeliningConflict($easy, $options); + $this->applyMultiplexNone($easy, $options); + $this->applyProxyTunnelOwnership($easy); + } catch (\Throwable $e) { + try { + $this->factory->release($easy); + } catch (\Throwable $releaseFailure) { + // Preserve the original failure. + } + + throw $e; + } + $id = (int) $easy->handle; + $waitToken = new \stdClass(); + + $promise = null; $promise = new Promise( - [$this, 'execute'], - function () use ($id) { - return $this->cancel($id); + function () use ($id, $waitToken, $easy, &$promise): void { + // Waiting cannot drive native cURL while a callback has the + // multi handle busy; fail the wait promptly instead of + // self-deadlocking. + $idReused = $this->multiExecDepth > 0 + ? $this->failNestedWait($id, $waitToken) + : $this->executeUntil($id, $waitToken); + + // Settling can be queued, and guzzlehttp/promises drains the + // queue before deciding a wait function achieved nothing. + P\Utils::queue()->run(); + + // Never null: assigned below before any wait can invoke this. + /** @var Promise $promise */ + if (!P\Is::pending($promise)) { + return; + } + + // Neither path guarantees the transfer settled, and returning + // while pending makes guzzlehttp/promises reject with a bare + // string naming nothing. + $stalled = $idReused + ? 'its native cURL handle ID was reused by another request' + : 'its entry was removed without settling'; + + $message = \sprintf('Waiting on cURL multi handler transfer %d cannot make progress (%s).', $id, $stalled); + + // The entry is gone or belongs to another request, so + // attribute from this easy handle. + $promise->reject(new RequestException($message, $easy->request, $easy->response)); + }, + function () use ($id, $waitToken) { + return $this->cancel($id, $waitToken); } ); - $this->addRequest(['easy' => $easy, 'deferred' => $promise]); + $entry = ['easy' => $easy, 'deferred' => $promise, 'wait_token' => $waitToken]; + + try { + $this->addRequest($entry); + } catch (\Throwable $e) { + throw $this->discardPendingRequest($id, $entry, $e); + } return $promise; } + /** + * The "multiplex" request option sets CURLOPT_PIPEWAIT, which libcurl + * ignores entirely when the multi handle's CURLMOPT_PIPELINING option + * disables multiplexing, so an explicit request for multiplexing on a + * handler configured against it is a configuration error. The required + * family conflicts marker-independently: a required guarantee on a handler + * that disables multiplexing is contradictory even when the transfer would + * not wait. A raw CURLOPT_PIPEWAIT cURL option conflicts with every + * explicit mode on this handler, where waiting is operationally + * meaningful: whatever its value, it is a second wait/eager authority + * applied after the mode's own decision. + */ + private function rejectMultiplexPipeliningConflict(EasyHandle $easy, array $options): void + { + $multiplex = $options['multiplex'] ?? null; + + if (null === $multiplex) { + return; + } + + if (\defined('CURLOPT_PIPEWAIT') + && isset($options['curl']) + && \is_array($options['curl']) + && \array_key_exists((int) \constant('CURLOPT_PIPEWAIT'), $options['curl']) + ) { + // Key presence alone conflicts, and it must be rejected before + // the marker below is consulted: the marker reflects the final + // merged configuration, which the raw value has falsified. + throw new \InvalidArgumentException('The "multiplex" request option cannot be combined with the raw CURLOPT_PIPEWAIT cURL option on the cURL multi handler; remove the raw option.'); + } + + if (Multiplexing::WAIT === $multiplex && !$easy->usesPipewait) { + // Explicit wait only conflicts when the transfer would actually + // wait; an HTTP/1.1 wait request never sets the marker. + return; + } + + if (!\in_array($multiplex, [Multiplexing::WAIT, Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { + return; + } + + if ($this->multiplexDisabled) { + // Checked before the raw option: the handler wrote its own + // CURLMOPT_PIPELINING value when "multiplex" disabled it. + throw new \InvalidArgumentException('The "multiplex" request option cannot be combined with a CurlMultiHandler whose "multiplex" option is Multiplexing::NONE; remove the handler option or set the request option to "eager".'); + } + + if (!\is_array($this->options) || !\array_key_exists(\CURLMOPT_PIPELINING, $this->options)) { + // A legacy non-array "options" value is tolerated by the + // constructor and cannot contain the option. + return; + } + + $pipelining = $this->options[\CURLMOPT_PIPELINING]; + if (!\is_scalar($pipelining)) { + // ext-curl derives the integer mask from non-scalar values with + // type-dependent zval semantics, so the effective mask cannot be + // predicted here; require an explicit integer instead. + throw new \InvalidArgumentException('The CurlMultiHandler CURLMOPT_PIPELINING option must be an integer when combined with the "multiplex" request option.'); + } + + $multiplexBit = \defined('CURLPIPE_MULTIPLEX') ? \CURLPIPE_MULTIPLEX : 2; + if (((int) $pipelining & $multiplexBit) !== 0) { + return; + } + + throw new \InvalidArgumentException('The "multiplex" request option cannot be combined with a CurlMultiHandler CURLMOPT_PIPELINING option that disables multiplexing; set CURLMOPT_PIPELINING to CURLPIPE_MULTIPLEX, remove the option, or set the "multiplex" option to "eager".'); + } + + /** + * A Multiplexing::NONE request option is a sole-use guarantee: the + * transfer must not share its connection with any concurrent transfer. + * It holds structurally on a handler whose "multiplex" option is + * Multiplexing::NONE, and for HTTP/1.x transfers, which never join a + * multiplexed connection and open connections nothing can join. An + * HTTP/2 request on a handler that multiplexes is rejected, as is any + * configuration under which the guarantee cannot be verified (custom + * handle factories control the native handle) or cannot be hardened + * (challenge-response authentication retries and Expect 417 retries + * re-enter connection selection as internal follows, which disarm + * CURLOPT_FRESH_CONNECT). A raw CURLMOPT_PIPELINING multi option, and + * deprecated-but-applied raw cURL options that can defeat the declared + * protocol version, retry through internal follows, or replace the + * managed header list, are rejected by key presence. On runtimes whose + * matcher can hand an HTTP/1.x transfer an idle multiplexed connection + * (below libcurl 7.77.0, and 8.11.0-8.12.1), accepted transfers force + * a fresh connection. + */ + private function applyMultiplexNone(EasyHandle $easy, array $options): void + { + if (Multiplexing::NONE !== ($options['multiplex'] ?? null) || $this->multiplexDisabled) { + return; + } + + if (\defined('CURLMOPT_PIPELINING') && \is_array($this->options) && \array_key_exists(\CURLMOPT_PIPELINING, $this->options)) { + // Key presence alone conflicts, matching the constructor's rule + // for the named option: raw multi options that fail to apply only + // warn (they are not in requiredOptions), so even an agreeing + // zero mask cannot prove the guarantee. is_array: legacy non-array + // "options" values are deprecated but still stored. + throw new \InvalidArgumentException('The "multiplex" request option cannot be Multiplexing::NONE alongside a raw CURLMOPT_PIPELINING cURL multi option; replace the raw option with the "multiplex" cURL multi handler option.'); + } + + if ($this->customHandleFactory) { + throw new \InvalidArgumentException('The "multiplex" request option can only be Multiplexing::NONE on a CurlMultiHandler with a custom "handle_factory" when the handler\'s own "multiplex" option is Multiplexing::NONE, because the guarantee is enforced against the native easy handle the factory controls.'); + } + + $version = $easy->request->getProtocolVersion(); + if ('2' === $version || '2.0' === $version) { + throw new \InvalidArgumentException('The "multiplex" request option can only be Multiplexing::NONE for an HTTP/1.x request on a CurlMultiHandler that permits multiplexing; set the "multiplex" client or CurlMultiHandler constructor option to Multiplexing::NONE to disable multiplexing for every transfer, or send the request with its "version" option set to "1.1".'); + } + + if (isset($options['curl']) && \is_array($options['curl'])) { + foreach (['CURLOPT_HTTP_VERSION', 'CURLOPT_HTTPAUTH', 'CURLOPT_PROXYAUTH', 'CURLOPT_FOLLOWLOCATION', 'CURLOPT_HTTPHEADER', 'CURLOPT_ALTSVC', 'CURLOPT_ALTSVC_CTRL', 'CURLOPT_PROXYTYPE'] as $constant) { + if (\defined($constant) && \array_key_exists((int) \constant($constant), $options['curl'])) { + // Key presence alone conflicts. A raw CURLOPT_HTTP_VERSION + // overrides the declared version after the factory + // mapping, and raw alt-svc options or an HTTPS2 proxy + // type can put a declared-HTTP/1.x transfer on a joinable + // HTTP/2 connection; raw challenge-response + // authentication (origin 401 or proxy 407) and native + // redirects re-enter connection selection as internal + // follows, which disarm CURLOPT_FRESH_CONNECT, so the + // hardening below cannot cover them; a raw + // CURLOPT_HTTPHEADER replaces the managed header list, + // including the Expect suppression the check below + // relies on. + throw new \InvalidArgumentException(\sprintf('The "multiplex" request option cannot be Multiplexing::NONE combined with the raw %s cURL option on a CurlMultiHandler that permits multiplexing; remove the raw option, or set the "multiplex" client or CurlMultiHandler constructor option to Multiplexing::NONE.', $constant)); + } + } + } + + if (Psr7\Utils::caselessContains($easy->request->getHeaderLine('Expect'), '100-continue')) { + // libcurl arms its Expect handling by a caseless substring scan + // of the header value (Curl_compareheader), so any value + // containing 100-continue can make a 417 response retry as an + // internal follow, which disarms CURLOPT_FRESH_CONNECT; requests + // without the header are safe because the factory suppresses + // libcurl's automatic Expect. + throw new \InvalidArgumentException('The "multiplex" request option cannot be Multiplexing::NONE for a request carrying an "Expect: 100-continue" header on a CurlMultiHandler that permits multiplexing; remove the explicitly supplied "Expect" header, set the "expect" request option to false to prevent it being added automatically, or set the "multiplex" client or CurlMultiHandler constructor option to Multiplexing::NONE.'); + } + + if (CurlVersion::supportsHttpVersionReuseMatching()) { + return; + } + + // Unqualified curl_setopt so the test bootstrap shadow records it. + if (true !== curl_setopt($easy->handle, \CURLOPT_FRESH_CONNECT, true)) { + // The hardening is the guarantee on these runtimes; failing to + // apply it must fail closed, mirroring applyCurlOptions(). + throw new \InvalidArgumentException('Unable to set cURL option CURLOPT_FRESH_CONNECT.'); + } + } + + /** + * @param array $options + */ + private static function triggerConflictingCurlMultiOptionDeprecations(array $options): void + { + if ($options === []) { + return; + } + + $conflictingOptions = self::conflictingCurlMultiOptions(); + $sinceOverrides = self::conflictingCurlMultiOptionSinceOverrides(); + foreach ($options as $option => $_) { + if (\array_key_exists($option, $conflictingOptions)) { + \trigger_deprecation('guzzlehttp/guzzle', $sinceOverrides[$option] ?? '7.14', \sprintf('Passing %s in the cURL multi handler "options" is deprecated; guzzlehttp/guzzle 8.0 will reject this option. Use %s instead.', self::formatCurlMultiOption($option), $conflictingOptions[$option])); + } + } + } + + /** + * @return array + */ + private static function conflictingCurlMultiOptionSinceOverrides(): array + { + if (!\defined('CURLMOPT_PIPELINING')) { + // Matches conflictingCurlMultiOptions(): ext-curl builds against + // pre-7.16 libcurl headers do not define the constant. + return []; + } + + return [\CURLMOPT_PIPELINING => '7.15']; + } + + /** + * @param array $options + */ + private static function hasConnectionCapOption(array $options): bool + { + foreach (self::CONNECTION_CAP_OPTIONS as $name => $_) { + if (($options[$name] ?? null) !== null) { + return true; + } + } + + return false; + } + + /** + * @param array $constructorOptions + * @param array $multiOptions + */ + private static function rejectConnectionCapOptionConflicts(array $constructorOptions, array $multiOptions): void + { + foreach (self::CONNECTION_CAP_OPTIONS as $name => $constant) { + if (($constructorOptions[$name] ?? null) === null || !\defined($constant)) { + continue; + } + + $option = \constant($constant); + if (\array_key_exists($option, $multiOptions)) { + throw new \InvalidArgumentException(\sprintf('%s conflicts with a %s entry in the "options" array.', $name, $constant)); + } + } + } + + /** + * @param array $options + */ + private function addConnectionCapOptions(array $options): void + { + foreach (self::CONNECTION_CAP_OPTIONS as $name => $constant) { + $value = $options[$name] ?? null; + if ($value === null) { + continue; + } + + if (!\is_int($value) || $value < 1) { + throw new \InvalidArgumentException(\sprintf('%s must be a positive integer.', $name)); + } + + CurlVersion::ensureConnectionCapsSupported($name); + + $option = \constant($constant); + if (\array_key_exists($option, $this->options)) { + throw new \InvalidArgumentException(\sprintf('%s conflicts with a %s entry in the "options" array.', $name, $constant)); + } + + $this->options[$option] = $value; + $this->requiredOptions[$option] = true; + $this->connectionCapsApplied = true; + } + } + + /** + * @param int|string $option + */ + private static function formatCurlMultiOption($option): string + { + if (!\is_int($option)) { + return \sprintf('"%s"', $option); + } + + static $names = null; + + if (null === $names) { + $names = []; + foreach (\get_defined_constants(true)['curl'] ?? [] as $name => $value) { + if (\is_int($value) && \strpos($name, 'CURLMOPT_') === 0 && !isset($names[$value])) { + $names[$value] = $name; + } + } + } + + if (isset($names[$option])) { + return \sprintf('%s (%d)', $names[$option], $option); + } + + return (string) $option; + } + + /** + * @return array + */ + private static function conflictingCurlMultiOptions(): array + { + static $options = null; + + if ($options !== null) { + return $options; + } + + $options = []; + + self::addConflictingCurlMultiOption($options, 'CURLMOPT_MAX_HOST_CONNECTIONS', 'the "max_host_connections" client option or cURL multi handler option'); + self::addConflictingCurlMultiOption($options, 'CURLMOPT_MAX_TOTAL_CONNECTIONS', 'the "max_total_connections" client option or cURL multi handler option'); + self::addConflictingCurlMultiOption($options, 'CURLMOPT_PIPELINING', 'Multiplexing::NONE via the "multiplex" cURL multi handler or client option to disable multiplexing, or remove the raw option for the runtime default (multiplexing defaults on from libcurl 7.62, except 7.65.0 and 7.65.1)'); + + return $options; + } + + /** + * @param array $options + */ + private static function addConflictingCurlMultiOption(array &$options, string $constant, string $replacement): void + { + if (!\defined($constant)) { + return; + } + + $value = \constant($constant); + if (\is_int($value)) { + $options[$value] = $replacement; + } + } + + /** + * Isolates the connection cache when the request's proxy tunnel section + * differs from the one the multi handle's cache may already hold. + */ + private function applyProxyTunnelOwnership(EasyHandle $easy): void + { + $signature = $easy->proxyTunnelSignature; + if ($signature === null || $signature === $this->proxyTunnelOwner) { + return; + } + + if ($this->proxyTunnelOwner === null) { + // No in-domain transfer has ever run on this multi handle: latch + // the owner without destroying pooled direct connections. + $this->proxyTunnelOwner = $signature; + + return; + } + + if ( + $this->handles === [] + && 0 === $this->multiExecDepth + && 0 === $this->messageProcessingDepth + && $this->deferredCancels === [] + ) { + // Idle: hand the connection cache over by recreating the multi + // handle (unsetting re-arms the lazy __get initializer, which + // re-applies the CURLMOPT_* options). + if (isset($this->_mh)) { + \curl_multi_close($this->_mh); + unset($this->_mh); + } + $this->proxyTunnelOwner = $signature; + + return; + } + + // Busy: isolate this transfer from the owner's pooled tunnels. + $this->isolateProxyTunnelTransfer($easy); + } + + private function addCurlHandle(EasyHandle $easy): void + { + $this->isolateFromForeignActiveProxyTunnel($easy); + + // Unqualified curl_multi_add_handle so the test bootstrap shadow can + // override the result. + $result = curl_multi_add_handle($this->_mh, $easy->handle); + + if (\CURLM_OK !== $result) { + if (\PHP_VERSION_ID < 80226 || (\PHP_VERSION_ID >= 80300 && \PHP_VERSION_ID < 80314)) { + // Before PHP 8.2.26 and 8.3.14, ext-curl kept the easy handle + // in its multi bookkeeping even when the native add failed + // (https://github.com/php/php-src/pull/16302); remove it so + // the handle can be pooled or closed safely. + \curl_multi_remove_handle($this->_mh, $easy->handle); + } + + throw new RequestException(\sprintf('Unable to add the cURL handle to the cURL multi handler: %s (%d).', (string) \curl_multi_strerror($result), $result), $easy->request); + } + + $this->markProxyTunnelActive($easy); + + $id = (int) $easy->handle; + if (isset($this->handles[$id])) { + $this->handles[$id]['attached'] = true; + } + } + + /** + * @param resource|\CurlHandle $handle + */ + private function removeCompletedHandleFromMulti(int $id, $handle): void + { + $this->removeHandleFromMulti($handle); + $this->unmarkProxyTunnelActiveById($id); + } + + /** + * Removes a transfer from the multi handle under the native execution + * guard: removing a still-running transfer performs a final progress + * update that can run a user progress callback. + * + * @param resource|\CurlHandle $handle + */ + private function removeHandleFromMulti($handle): void + { + ++$this->multiExecDepth; + + try { + \curl_multi_remove_handle($this->_mh, $handle); + } finally { + --$this->multiExecDepth; + $this->finishDeferredWork(); + } + } + + private function isolateFromForeignActiveProxyTunnel(EasyHandle $easy): void + { + $signature = $easy->proxyTunnelSignature; + + if ($signature === null || $this->activeProxyTunnelSignatures === []) { + return; + } + + if (\count($this->activeProxyTunnelSignatures) === 1 && isset($this->activeProxyTunnelSignatures[$signature])) { + return; + } + + $this->isolateProxyTunnelTransfer($easy); + } + + private function isolateProxyTunnelTransfer(EasyHandle $easy): void + { + foreach (self::PROXY_TUNNEL_ISOLATION_OPTIONS as $name) { + try { + // Unqualified curl_setopt so the test bootstrap shadow records it. + $applied = curl_setopt($easy->handle, (int) \constant($name), true); + } catch (\Throwable $e) { + throw new RequestException(self::proxyTunnelIsolationFailureMessage($name), $easy->request, null, $e); + } + + if (true !== $applied) { + throw new RequestException(self::proxyTunnelIsolationFailureMessage($name), $easy->request); + } + } + } + + private static function proxyTunnelIsolationFailureMessage(string $name): string + { + return \sprintf('Unable to apply the %s cURL option required to isolate the transfer from foreign proxy tunnel connections.', $name); + } + + private function markProxyTunnelActive(EasyHandle $easy): void + { + $signature = $easy->proxyTunnelSignature; + if ($signature === null) { + return; + } + + $id = (int) $easy->handle; + if (isset($this->activeProxyTunnelHandles[$id])) { + if ($this->activeProxyTunnelHandles[$id] === $signature) { + return; + } + + $this->unmarkProxyTunnelActiveById($id); + } + + $this->activeProxyTunnelHandles[$id] = $signature; + $this->activeProxyTunnelSignatures[$signature] = ($this->activeProxyTunnelSignatures[$signature] ?? 0) + 1; + } + + private function unmarkProxyTunnelActive(EasyHandle $easy): void + { + $this->unmarkProxyTunnelActiveById((int) $easy->handle); + } + + private function unmarkProxyTunnelActiveById(int $id): void + { + if (!isset($this->activeProxyTunnelHandles[$id])) { + return; + } + + $signature = $this->activeProxyTunnelHandles[$id]; + unset($this->activeProxyTunnelHandles[$id]); + + if (!isset($this->activeProxyTunnelSignatures[$signature])) { + return; + } + + --$this->activeProxyTunnelSignatures[$signature]; + + if ($this->activeProxyTunnelSignatures[$signature] <= 0) { + unset($this->activeProxyTunnelSignatures[$signature]); + } + } + /** * Ticks the curl event loop. */ public function tick(): void { - // Add any delayed handles if needed. - if ($this->delays) { + $this->tickFor(null, null); + } + + /** + * Ticks the curl event loop, returning before the blocking select if the + * targeted transfer has settled, been canceled, or been replaced by a + * request that reused its native handle ID. + */ + private function tickFor(?int $targetId, ?object $waitToken): void + { + // Add any delayed handles if needed. Attachment is skipped while a + // callback has native execution busy; the outer frame attaches due + // transfers once it unwinds. + if ($this->delays && 0 === $this->multiExecDepth) { $currentTime = Utils::currentTime(); foreach ($this->delays as $id => $delay) { if ($currentTime >= $delay) { + $entry = $this->handles[$id]; unset($this->delays[$id]); - \curl_multi_add_handle( - $this->_mh, - $this->handles[$id]['easy']->handle - ); + + try { + $this->addCurlHandle($entry['easy']); + } catch (\Throwable $e) { + // The promise has already escaped, so reject it + // rather than throw. + $rejection = $this->discardPendingRequest($id, $entry, $e); + if (P\Is::pending($entry['deferred'])) { + $entry['deferred']->reject($rejection); + } + } } } } - // Step through the task queue which may add additional requests. - P\Utils::queue()->run(); + // Run curl_multi_exec in the queue to enable other async tasks to + // run, surface completions, and drain any work they queued so a + // ready cancellation or new transfer is not held behind the select. + do { + P\Utils::queue()->add(Closure::fromCallable([$this, 'tickInQueue'])); + + // Step through the task queue which may add additional requests. + P\Utils::queue()->run(); + + if ($this->multiExecDepth > 0) { + // A cURL callback re-entered the handler while native + // execution is running; the outer frame drives native cURL + // once it unwinds. + return; + } + + if (isset($this->_mh)) { + $this->processMessages(); + } + } while (!P\Utils::queue()->isEmpty()); + + if (!isset($this->_mh)) { + // Nothing is attached natively (or initialization just failed); + // there is nothing to run and nothing to recreate the handle for. + return; + } + + if ($targetId !== null && !$this->hasRequest($targetId, $waitToken)) { + return; + } - if ($this->active && \curl_multi_select($this->_mh, $this->selectTimeout) === -1) { + if ($this->active && \curl_multi_select($this->_mh, $this->effectiveSelectTimeout()) === -1) { // Perform a usleep if a select returns -1. // See: https://bugs.php.net/bug.php?id=61141 \usleep(250); } - while (\curl_multi_exec($this->_mh, $this->active) === \CURLM_CALL_MULTI_PERFORM) { - } + do { + $exec = $this->executeMulti(); + + // Prevent busy looping for slow HTTP requests. + if ($exec === \CURLM_CALL_MULTI_PERFORM) { + \curl_multi_select($this->_mh, $this->effectiveSelectTimeout()); + } + } while ($exec === \CURLM_CALL_MULTI_PERFORM); $this->processMessages(); } + /** + * Runs \curl_multi_exec() inside the event loop, to prevent busy looping + */ + private function tickInQueue(): void + { + if ($this->multiExecDepth > 0) { + // A cURL callback re-entered the handler while native execution + // is running; the outer frame drives native cURL once it unwinds. + return; + } + + if (!isset($this->_mh)) { + // Nothing is attached natively (or initialization just failed); + // there is nothing to run and nothing to recreate the handle for. + return; + } + + $exec = $this->executeMulti(); + + if ($exec === \CURLM_CALL_MULTI_PERFORM) { + \curl_multi_select($this->_mh, 0); + P\Utils::queue()->add(Closure::fromCallable([$this, 'tickInQueue'])); + } + } + + /** + * @phpstan-impure + */ + private function executeMulti(): int + { + ++$this->multiExecDepth; + + try { + return \curl_multi_exec($this->_mh, $this->active); + } finally { + --$this->multiExecDepth; + $this->finishDeferredWork(); + } + } + + /** + * Flushes cancels and attachments deferred while the multi handle was + * busy executing transfers or removing a handle. + */ + private function finishDeferredWork(): void + { + if ($this->multiExecDepth > 0 || $this->finishingDeferredWork) { + // A nested frame (a cURL callback re-entered the handler) must + // not flush while an outer frame is still using the multi + // handle; the outermost frame flushes once it unwinds. + return; + } + + $this->finishingDeferredWork = true; + + try { + $failure = null; + + // Removing a cancelled transfer runs its final progress update, + // whose callback can cancel other transfers or create requests; + // drain until no deferred work remains. + do { + $this->cleanupDeferredCancels($failure); + $this->flushDeferredAdds(); + } while ($this->deferredCancels !== [] || $this->deferredAdds !== []); + + if ($failure !== null) { + throw $failure; + } + } finally { + $this->finishingDeferredWork = false; + } + } + /** * Runs until all outstanding connections have completed. */ public function execute(): void { + if ($this->multiExecDepth > 0) { + // Native cURL cannot be driven while a callback has it busy, so + // the loop would spin without ever progressing. + throw new \LogicException('Cannot run the cURL multi event loop from inside a cURL callback; the callback must return before transfers can progress.'); + } + $queue = P\Utils::queue(); while ($this->handles || !$queue->isEmpty()) { - // If there are no transfers, then sleep for the next delay - if (!$this->active && $this->delays) { + // If there are no transfers, then sleep for the next delay, + // unless ready queue work could change what is pending. + if (!$this->active && $this->delays && $queue->isEmpty()) { \usleep($this->timeToNext()); } $this->tick(); } } + /** + * Runs the event loop until the given transfer has finished, so waiting + * on a promise does not wait for every other transfer on the handler + * like execute() does. + * + * The native cURL handle ID can be reused by a request created from a + * completion callback, so the wait token guards against waiting on an + * unrelated transfer that inherited the ID. + * + * @return bool Whether another request had reused the native cURL handle + * ID by the time the loop stopped + */ + private function executeUntil(int $id, object $waitToken): bool + { + $queue = P\Utils::queue(); + + while ($this->hasRequest($id, $waitToken)) { + // If the transfer is delayed, then sleep until it is due, unless + // ready queue work could cancel or replace it first. + if (!$this->active && isset($this->delays[$id]) && $queue->isEmpty()) { + \usleep($this->timeToNext()); + } + $this->tickFor($id, $waitToken); + } + + // Sample before the drain below, which can add or remove an entry + // under this ID and so rewrite the answer. + $idReused = isset($this->handles[$id]); + + if (!$queue->isEmpty()) { + $queue->run(); + } + + return $idReused; + } + + /** + * Checks that the request with the given handle ID is still pending and, + * when a wait token is given, has not been replaced by a request that + * reused the ID. + */ + private function hasRequest(int $id, ?object $waitToken = null): bool + { + if (!isset($this->handles[$id])) { + return false; + } + + return $waitToken === null || ($this->handles[$id]['wait_token'] ?? null) === $waitToken; + } + private function addRequest(array $entry): void { $easy = $entry['easy']; $id = (int) $easy->handle; + $entry['attached'] = false; + + $displaced = $this->handles[$id] ?? null; + if ($displaced !== null) { + // Never silently discard a tracked entry; settle it first. + unset($this->handles[$id], $this->delays[$id], $this->deferredAdds[$id]); + if (P\Is::pending($displaced['deferred'])) { + $message = \sprintf('cURL multi handler transfer %d was displaced by another request that reused its native cURL handle ID.', $id); + $displaced['deferred']->reject(new RequestException($message, $displaced['easy']->request, $displaced['easy']->response)); + } + } + $this->handles[$id] = $entry; - if (empty($easy->options['delay'])) { - \curl_multi_add_handle($this->_mh, $easy->handle); - } else { + + if (!empty($easy->options['delay'])) { $this->delays[$id] = Utils::currentTime() + ($easy->options['delay'] / 1000); + } elseif ($this->multiExecDepth > 0) { + // A request created from inside a cURL callback cannot be added + // natively while curl_multi_exec() is running; libcurl 7.59+ + // rejects the recursive call. Attach it once the outermost + // native execution unwinds. + $this->deferredAdds[$id] = $entry['wait_token'] ?? null; + } else { + $this->addCurlHandle($easy); + } + } + + /** + * Rolls back a request that can no longer be attached, releasing the + * easy handle exactly once and preserving the original failure. + * + * @param array{easy: EasyHandle, deferred: Promise, wait_token?: object|null, attached?: bool} $entry + */ + private function discardPendingRequest(int $id, array $entry, \Throwable $failure): \Throwable + { + unset($this->handles[$id], $this->delays[$id], $this->deferredAdds[$id]); + + try { + $this->factory->release($entry['easy']); + } catch (\Throwable $e) { + // Preserve the original failure. + } + + return $failure; + } + + /** + * Fails a synchronous wait attempted from inside a cURL callback, where + * native execution cannot progress until the callback returns. + * + * @return bool Whether another request had reused the native cURL handle + * ID, which only matters when no transfer was left to fail + */ + private function failNestedWait(int $id, object $token): bool + { + if (!$this->hasRequest($id, $token)) { + // Nothing left to fail, so report which way the entry went. + return isset($this->handles[$id]); + } + + $entry = $this->handles[$id]; + $failure = new RequestException('Cannot synchronously wait for a transfer from inside a cURL callback on the same cURL multi handler; the callback must return before the transfer can progress.', $entry['easy']->request, $entry['easy']->response); + + if (!empty($entry['attached'])) { + // Native removal must wait until the outermost execution unwinds. + unset($this->handles[$id], $this->delays[$id], $this->deferredAdds[$id]); + $this->deferredCancels[$id] = ['easy' => $entry['easy'], 'attached' => true]; + } else { + $this->discardPendingRequest($id, $entry, $failure); + } + + $entry['deferred']->reject($failure); + + return false; + } + + /** + * Attaches requests whose native attachment was deferred because they + * were created from inside a cURL callback. + */ + private function flushDeferredAdds(): void + { + if ($this->deferredAdds === []) { + return; + } + + $adds = $this->deferredAdds; + $this->deferredAdds = []; + + foreach ($adds as $id => $token) { + if (!$this->hasRequest($id, $token)) { + // Cancelled or replaced while the attachment was deferred. + continue; + } + + $entry = $this->handles[$id]; + + try { + $this->addCurlHandle($entry['easy']); + } catch (\Throwable $e) { + // The promise has already escaped, so reject it rather than + // throw. User code may have settled it directly; a settled + // promise must not abort the rest of the snapshot. + $rejection = $this->discardPendingRequest($id, $entry, $e); + if (P\Is::pending($entry['deferred'])) { + $entry['deferred']->reject($rejection); + } + } } } /** * Cancels a handle from sending and removes references to it. * - * @param int $id Handle ID to cancel and remove. + * @param int $id Handle ID to cancel and remove. + * @param object|null $waitToken Identity token that must still match the + * entry when given. * * @return bool True on success, false on failure. */ - private function cancel($id): bool + private function cancel($id, ?object $waitToken = null): bool { if (!is_int($id)) { - trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an integer to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); + \trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); } - // Cannot cancel if it has been processed. - if (!isset($this->handles[$id])) { + // Cannot cancel if it has been processed or replaced by a request + // that reused the native handle ID. + if (!isset($this->handles[$id]) || ($waitToken !== null && ($this->handles[$id]['wait_token'] ?? null) !== $waitToken)) { return false; } - $handle = $this->handles[$id]['easy']->handle; - unset($this->delays[$id], $this->handles[$id]); - \curl_multi_remove_handle($this->_mh, $handle); - \curl_close($handle); + $entry = $this->handles[$id]; + $easy = $entry['easy']; + $attached = !empty($entry['attached']); + unset($this->delays[$id], $this->deferredAdds[$id], $this->handles[$id]); + + if ($this->multiExecDepth > 0) { + $this->deferredCancels[$id] = ['easy' => $easy, 'attached' => $attached]; + + return true; + } + + $this->cleanupCancelledHandle($easy, $attached); return true; } - private function processMessages(): void + private function cleanupDeferredCancels(?\Throwable &$failure): void { - while ($done = \curl_multi_info_read($this->_mh)) { - if ($done['msg'] !== \CURLMSG_DONE) { - // if it's not done, then it would be premature to remove the handle. ref https://github.com/guzzle/guzzle/pull/2892#issuecomment-945150216 - continue; + if ($this->deferredCancels === []) { + return; + } + + $entries = $this->deferredCancels; + $this->deferredCancels = []; + + foreach ($entries as $entry) { + try { + $this->cleanupCancelledHandle($entry['easy'], $entry['attached']); + } catch (\Throwable $e) { + // A final progress update can run a throwing user callback; + // clean the remaining entries and surface the first failure + // once the drain completes. + if ($failure === null) { + $failure = $e; + } } - $id = (int) $done['handle']; - \curl_multi_remove_handle($this->_mh, $done['handle']); + } + } - if (!isset($this->handles[$id])) { - // Probably was cancelled. - continue; + private function cleanupCancelledHandle(EasyHandle $easy, bool $attached): void + { + $handle = $easy->handle; + $failure = null; + + if ($attached) { + try { + $this->removeHandleFromMulti($handle); + } catch (\Throwable $e) { + // The native detach completes even when its final progress + // callback throws; finish this entry before rethrowing. + $failure = $e; } + } - $entry = $this->handles[$id]; - unset($this->handles[$id], $this->delays[$id]); - $entry['easy']->errno = $done['result']; - $entry['deferred']->resolve( - CurlFactory::finish($this, $entry['easy'], $this->factory) - ); + $this->unmarkProxyTunnelActive($easy); + + if (PHP_VERSION_ID < 80000) { + try { + \curl_close($handle); + } catch (\Throwable $e) { + // An error handler can promote the close warning; keep the + // first failure. + if ($failure === null) { + $failure = $e; + } + } + } + + if ($failure !== null) { + throw $failure; } } - private function timeToNext(): int + private function processMessages(): void + { + // CurlFactory::finish can retry a transfer by re-invoking this handler + // from inside this loop; the guard keeps that re-entry from recreating + // the multi handle mid-iteration (see applyProxyTunnelOwnership). A + // depth is tracked because a completion callback can re-enter tick(), + // and the nested frame must not clear the outer loop's guard. + ++$this->messageProcessingDepth; + + try { + while ($done = \curl_multi_info_read($this->_mh)) { + if ($done['msg'] !== \CURLMSG_DONE) { + // if it's not done, then it would be premature to remove the handle. ref https://github.com/guzzle/guzzle/pull/2892#issuecomment-945150216 + continue; + } + if (!isset($done['handle'])) { + // Work around a PHP issue where cancelled transfers may omit the handle. + // Remove this once we no longer support PHP versions before the fix in + // https://github.com/php/php-src/pull/16302. + continue; + } + $id = (int) $done['handle']; + $this->removeCompletedHandleFromMulti($id, $done['handle']); + + if (!isset($this->handles[$id])) { + // Probably was cancelled. + continue; + } + + $entry = $this->handles[$id]; + unset($this->handles[$id], $this->delays[$id]); + $entry['easy']->errno = $done['result']; + + // finish() can run completion callbacks that cancel this + // promise; a settled promise must not be settled again. + try { + $result = CurlFactory::finish($this, $entry['easy'], $this->factory); + } catch (\Throwable $e) { + if (P\Is::pending($entry['deferred'])) { + $entry['deferred']->reject($e); + } + + continue; + } + + if (P\Is::pending($entry['deferred'])) { + $entry['deferred']->resolve($result); + } + } + } finally { + --$this->messageProcessingDepth; + } + } + + /** + * Bounds a blocking select by the earliest pending request delay so a + * delayed transfer becoming due does not wait out an unrelated + * transfer's full select timeout. + * + * @return float|int + */ + private function effectiveSelectTimeout() + { + if ($this->delays === []) { + return $this->selectTimeout; + } + + return \min($this->selectTimeout, $this->secondsToNext()); + } + + /** + * @return float Seconds until the earliest pending delay is due + */ + private function secondsToNext(): float { $currentTime = Utils::currentTime(); - $nextTime = \PHP_INT_MAX; + $nextTime = \PHP_FLOAT_MAX; foreach ($this->delays as $time) { if ($time < $nextTime) { $nextTime = $time; } } - return ((int) \max(0, $nextTime - $currentTime)) * 1000000; + return \max(0.0, $nextTime - $currentTime); + } + + private function timeToNext(): int + { + // PHP_INT_MAX first: min() then returns the int operand whenever the + // microseconds exceed it, so the cast never sees an oversized float. + return (int) \min(\PHP_INT_MAX, $this->secondsToNext() * 1000000); } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/CurlShareHandleState.php b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlShareHandleState.php new file mode 100644 index 00000000..1a5c8ad7 --- /dev/null +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlShareHandleState.php @@ -0,0 +1,182 @@ +mode = $mode; + $this->handle = $handle; + } + + /** + * @param mixed $sharing + */ + public static function fromOption($sharing): ?self + { + if ($sharing instanceof self) { + return $sharing; + } + + $mode = self::normalizeMode($sharing, 'transport_sharing'); + if ($mode === TransportSharing::NONE) { + return null; + } + + if ($mode === TransportSharing::HANDLER_PREFER) { + return self::createHandlerShareOrNull($mode); + } + + return self::createHandlerShare($mode); + } + + /** + * @param mixed $sharing + */ + public static function normalizeMode($sharing, string $option): string + { + if ($sharing instanceof self) { + return $sharing->mode; + } + + if ($sharing === null || $sharing === TransportSharing::NONE) { + return TransportSharing::NONE; + } + + if ($sharing === TransportSharing::HANDLER_PREFER || $sharing === TransportSharing::HANDLER_REQUIRE) { + return $sharing; + } + + throw new \InvalidArgumentException(\sprintf( + 'The "%s" option must be null or a Plausible\Analytics\WP\Client\Lib\GuzzleHttp\\TransportSharing::* constant; received %s.', + $option, + \get_debug_type($sharing) + )); + } + + public static function assertNoRequiredSharingCustomFactoryConflict(array $options, string $handlerName): void + { + if (!\array_key_exists('handle_factory', $options) || $options['handle_factory'] === null) { + return; + } + + $mode = self::normalizeMode($options['transport_sharing'] ?? null, 'transport_sharing'); + if ($mode !== TransportSharing::HANDLER_REQUIRE) { + return; + } + + 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 + )); + } + + private static function createHandlerShareOrNull(string $mode): ?self + { + try { + return self::createHandlerShare($mode); + } catch (\Throwable $e) { + return null; + } + } + + 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.'); + } + + self::requireCurlConstant('CURLOPT_SHARE'); + $shareOption = self::requireCurlConstant('CURLSHOPT_SHARE'); + $locks = self::handlerLocks($mode); + $handle = curl_share_init(); + + try { + foreach ($locks as $lock) { + try { + $success = curl_share_setopt($handle, $shareOption, $lock); + } catch (\Throwable $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)); + } + } + } catch (\Throwable $e) { + self::closeHandlerShareHandleOnPhp7($handle); + + throw $e; + } + + return new self($mode, $handle); + } + + /** + * @return int[] + */ + 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; + } + + private static function requireCurlConstant(string $constant): int + { + if (!\defined($constant)) { + throw new \InvalidArgumentException(\sprintf( + 'The "transport_sharing" option requires %s, but it is not available in the installed PHP cURL extension.', + $constant + )); + } + + $value = \constant($constant); + if (!\is_int($value)) { + throw new \InvalidArgumentException(\sprintf('The cURL constant %s must resolve to an integer.', $constant)); + } + + return $value; + } + + /** + * @param resource|\CurlShareHandle $handle + */ + private static function closeHandlerShareHandleOnPhp7($handle): void + { + if (\PHP_VERSION_ID < 80000 && \is_resource($handle)) { + curl_share_close($handle); + } + } +} diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/CurlVersion.php b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlVersion.php new file mode 100644 index 00000000..78f946b4 --- /dev/null +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/CurlVersion.php @@ -0,0 +1,322 @@ +='); + } + + public static function supportsTls12(): bool + { + $version = self::getVersion(); + + return self::supportsSsl() + && \defined('CURL_SSLVERSION_TLSv1_2') + && $version !== null + && \version_compare($version, self::TLS_12_VERSION, '>='); + } + + public static function supportsTls13(): bool + { + $version = self::getVersion(); + + return self::supportsSsl() + && \defined('CURL_SSLVERSION_TLSv1_3') + && $version !== null + && \version_compare($version, self::TLS_13_VERSION, '>='); + } + + public static function supportsHttp2(): bool + { + $versionInfo = self::getVersionInfo(); + + return self::supportsTls12() + && \defined('CURL_VERSION_HTTP2') + && $versionInfo !== null + && 0 !== (\CURL_VERSION_HTTP2 & $versionInfo['features']); + } + + public static function supportsMultiplex(): bool + { + $version = self::getVersion(); + + return \defined('CURLOPT_PIPEWAIT') + && $version !== null + && \version_compare($version, self::MULTIPLEX_VERSION, '>='); + } + + public static function supportsHttpVersionReuseMatching(): bool + { + $version = self::getVersion(); + + if ($version === null || \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 supportsConnectionCaps(): bool + { + $version = self::getVersion(); + + return \defined('CURLMOPT_MAX_HOST_CONNECTIONS') + && \defined('CURLMOPT_MAX_TOTAL_CONNECTIONS') + && $version !== null + && \version_compare($version, self::CONNECTION_CAP_VERSION, '>='); + } + + public static function ensureConnectionCapsSupported(string $option): void + { + if (self::supportsConnectionCaps()) { + return; + } + + throw new \InvalidArgumentException(\sprintf( + 'The "%s" option requires PHP cURL support for CURLMOPT_MAX_HOST_CONNECTIONS and CURLMOPT_MAX_TOTAL_CONNECTIONS with libcurl %s or newer.', + $option, + self::CONNECTION_CAP_VERSION + )); + } + + public static function supportsRequiredMultiplex(): bool + { + $version = self::getVersion(); + + return \defined('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE') + && $version !== null + && self::supportsHttp2() + && \version_compare($version, self::REQUIRED_MULTIPLEX_VERSION, '>='); + } + + public static function supportsHttpsProxy(): bool + { + $versionInfo = self::getVersionInfo(); + + // CURL_VERSION_HTTPS_PROXY is not defined on every supported PHP + // version; fall back to the curl.h bit value. + $httpsProxyFeature = \defined('CURL_VERSION_HTTPS_PROXY') ? \CURL_VERSION_HTTPS_PROXY : (1 << 21); + + return $versionInfo !== null + && \version_compare($versionInfo['version'], self::HTTPS_PROXY_VERSION, '>=') + && 0 !== ($httpsProxyFeature & $versionInfo['features']); + } + + public static function supportsNtlm(): bool + { + $versionInfo = self::getVersionInfo(); + + // CURL_VERSION_NTLM is not defined on every supported PHP version; fall + // back to the curl.h bit value. + $ntlmFeature = \defined('CURL_VERSION_NTLM') ? \CURL_VERSION_NTLM : (1 << 4); + + return \defined('CURLAUTH_NTLM') + && $versionInfo !== null + && 0 !== ($ntlmFeature & $versionInfo['features']); + } + + public static function supportsHandlerSharing(): bool + { + $version = self::getVersion(); + + return $version !== null && \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 + { + $version = self::getVersion(); + + return self::supportsSsl() + && $version !== null + && \version_compare($version, self::SSL_SESSION_SHARING_VERSION, '>='); + } + + 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 supportsShareConnectionCaches(): bool + { + $version = self::getVersion(); + + // An undetectable libcurl version is treated as capable so the + // opaque share safeguards fail closed. + return $version === null || \version_compare($version, self::SHARE_CONNECTION_CACHE_VERSION, '>='); + } + + public static function supportsProxyTlsCredentialAwareConnectionReuse(): bool + { + $version = self::getVersion(); + + return $version !== null + && \version_compare($version, self::PROXY_TLS_CREDENTIAL_REUSE_VERSION, '>='); + } + + public static function supportsProxyCredentialAwareConnectionReuse(): bool + { + $version = self::getVersion(); + + return $version !== null + && \version_compare($version, self::PROXY_CREDENTIAL_REUSE_VERSION, '>='); + } + + public static function supportsSocksProxyCredentialAwareConnectionReuse(): bool + { + $version = self::getVersion(); + + return $version !== null + && \version_compare($version, self::SOCKS_PROXY_CREDENTIAL_REUSE_VERSION, '>='); + } + + public static function supportsProxyHeaderSeparation(): bool + { + $version = self::getVersion(); + + return $version !== null + && \version_compare($version, self::PROXY_HEADER_SEPARATION_VERSION, '>=') + && \defined('CURLOPT_PROXYHEADER') + && \defined('CURLOPT_HEADEROPT') + && \defined('CURLHEADER_SEPARATE'); + } + + private static function supportsSsl(): bool + { + $versionInfo = self::getVersionInfo(); + + return \defined('CURL_VERSION_SSL') + && $versionInfo !== null + && 0 !== (\CURL_VERSION_SSL & $versionInfo['features']); + } + + public static function getVersion(): ?string + { + $versionInfo = self::getVersionInfo(); + + return $versionInfo === null ? null : $versionInfo['version']; + } + + /** + * @return array{version: string, features: int}|null + */ + private static function getVersionInfo(): ?array + { + if (self::$versionInfo === null) { + 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 self::$versionInfo === false ? null : self::$versionInfo; + } +} diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/EasyHandle.php b/src/Client/lib/Lib/GuzzleHttp/Handler/EasyHandle.php index a261e59f..346174a9 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Handler/EasyHandle.php +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/EasyHandle.php @@ -30,6 +30,17 @@ final class EasyHandle */ public $headers = []; + /** + * @var array Valid trailer lines, retained only when an on_trailers + * callback is configured + */ + public $trailers = []; + + /** + * @var bool Whether this handle was configured with CURLOPT_PIPEWAIT + */ + public $usesPipewait = false; + /** * @var ResponseInterface|null Received response (if any) */ @@ -50,13 +61,26 @@ final class EasyHandle */ public $errno = 0; + /** + * @var string|null Effective CURLOPT_PROXY value the handle was created with (if any) + */ + public $effectiveProxy; + + /** + * Proxy tunnel or SOCKS proxy section signature for connection-reuse + * isolation, or null when the request does not require sectioning. + * + * @var string|null + */ + public $proxyTunnelSignature; + /** * @var \Throwable|null Exception during on_headers (if any) */ public $onHeadersException; /** - * @var \Exception|null Exception during createResponse (if any) + * @var \Throwable|null Exception during createResponse (if any) */ public $createResponseException; @@ -68,11 +92,13 @@ final class EasyHandle */ public function createResponse(): void { + $this->response = null; + [$ver, $status, $reason, $headers] = HeaderProcessor::parseHeaders($this->headers); $normalizedKeys = Utils::normalizeHeaderKeys($headers); - if (!empty($this->options['decode_content']) && isset($normalizedKeys['content-encoding'])) { + if (isset($this->options['decode_content']) && $this->options['decode_content'] !== false && isset($normalizedKeys['content-encoding'])) { $headers['x-encoded-content-encoding'] = $headers[$normalizedKeys['content-encoding']]; unset($headers[$normalizedKeys['content-encoding']]); if (isset($normalizedKeys['content-length'])) { @@ -80,7 +106,7 @@ public function createResponse(): void $bodyLength = (int) $this->sink->getSize(); if ($bodyLength) { - $headers[$normalizedKeys['content-length']] = $bodyLength; + $headers[$normalizedKeys['content-length']] = [(string) $bodyLength]; } else { unset($headers[$normalizedKeys['content-length']]); } diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/HeaderProcessor.php b/src/Client/lib/Lib/GuzzleHttp/Handler/HeaderProcessor.php index ae6f66df..441169c4 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Handler/HeaderProcessor.php +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/HeaderProcessor.php @@ -24,7 +24,14 @@ public static function parseHeaders(array $headers): array throw new \RuntimeException('Expected a non-empty array of header data'); } - $parts = \explode(' ', \array_shift($headers), 3); + $headers = self::getLastHeaderBlock(\array_values($headers)); + + $statusLine = \array_shift($headers); + if ($statusLine === null) { + throw new \RuntimeException('Expected a non-empty array of header data'); + } + + $parts = \explode(' ', $statusLine, 3); $version = \explode('/', $parts[0])[1] ?? null; if ($version === null) { @@ -37,6 +44,54 @@ public static function parseHeaders(array $headers): array throw new \RuntimeException('HTTP status code missing from header data'); } + if (!\preg_match('/^\d{3}$/D', $status)) { + throw new \RuntimeException('HTTP status code 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)]; } + + 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; + } + + /** + * @param non-empty-list $headers + * + * @return list + */ + private static function getLastHeaderBlock(array $headers): array + { + $lastStatusLine = 0; + + foreach ($headers as $index => $line) { + if (self::isStatusLineCandidate($line)) { + $lastStatusLine = $index; + } + } + + return \array_slice($headers, $lastStatusLine); + } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/HostValidator.php b/src/Client/lib/Lib/GuzzleHttp/Handler/HostValidator.php new file mode 100644 index 00000000..c630d113 --- /dev/null +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/HostValidator.php @@ -0,0 +1,198 @@ +getUri()->getHost(); + + self::assertUriHostValue($host, $request); + self::assertNoAuthorityDelimiter($host, $request); + self::assertNotADottedAddress($host, $request); + + foreach ($request->getHeader('Host') as $value) { + self::assertHostHeaderValue((string) $value, $request); + } + } + + /** + * @throws RequestException + */ + private static function assertUriHostValue(string $value, RequestInterface $request): void + { + if (!self::isPrintableAscii($value)) { + throw new RequestException(\sprintf('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.', self::escape($value)), $request); + } + + if (\strpos($value, '%') !== false) { + throw new RequestException(\sprintf('The request URI host "%s" must not contain a percent escape, because a handler can decode it and then connect to a host that differs from the one the request names.', self::escape($value)), $request); + } + } + + /** + * The Host header is sent rather than reparsed for the connection, so its + * diagnostics describe a request authority the caller did not write. + * + * @throws RequestException + */ + private static function assertHostHeaderValue(string $value, RequestInterface $request): void + { + if (!self::isPrintableAscii($value)) { + throw new RequestException(\sprintf('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.', self::escape($value)), $request); + } + + if (\strpos($value, '%') !== false) { + throw new RequestException(\sprintf('The request Host header "%s" must not contain a percent escape, because an intermediary or an origin server can decode it and then read it as an authority that differs from the one the request names.', self::escape($value)), $request); + } + } + + /** + * Matches the accepted shape positively so a PCRE failure rejects. + */ + private static function isPrintableAscii(string $value): bool + { + return \preg_match('/\A[\x21-\x7E]*\z/D', $value) === 1; + } + + /** + * Rejects a delimiter the transport could treat as the end of the URI host. + * + * This mirrors Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7\Uri::assertValidHost() and only affects + * third-party UriInterface values. Host headers may carry a port and are + * sent verbatim. + * + * @throws RequestException + */ + private static function assertNoAuthorityDelimiter(string $host, RequestInterface $request): void + { + $message = 'The request URI host "%s" must not contain a URI authority delimiter, because a handler reparses the URI and can then connect to a host that differs from the one the request names.'; + + // Match the accepted shape positively so a PCRE engine failure rejects. + if (\preg_match('/\A[^\/?#@\\\\]*\z/D', $host) !== 1) { + throw new RequestException(\sprintf($message, self::escape($host)), $request); + } + + if (\strpos($host, '[') !== false || \strpos($host, ']') !== false) { + if (\strpos($host, '[') !== 0 || \substr($host, -1) !== ']') { + throw new RequestException(\sprintf($message, self::escape($host)), $request); + } + + return; + } + + if (\strpos($host, ':') !== false) { + throw new RequestException(\sprintf($message, self::escape($host)), $request); + } + } + + /** + * Rejects one to four numeric-looking parts followed by trailing dots. + * + * libcurl 8.21.0 drops a trailing dot from inet_aton-style numeric hosts + * before connecting, while other validators treat the input as a name. + * Testing the shape also rejects some out-of-range values that transports + * keep as names; isNumericIpv4Host() explains that fail-closed tradeoff. + * Plain numeric shorthand stays accepted. + * + * @throws RequestException + */ + private static function assertNotADottedAddress(string $host, RequestInterface $request): void + { + if (\substr($host, -1) !== '.') { + return; + } + + if (!self::isNumericIpv4Host(\rtrim($host, '.'))) { + return; + } + + 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 value has the transport'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. No PCRE is used. + */ + 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); + } + + /** + * Escapes non-printable bytes as uppercase \xNN for safe diagnostics. + * Printable delimiters and dots stay visible. The result is not a + * reversible encoding. + */ + private static function escape(string $value): string + { + $escaped = ''; + + for ($offset = 0, $length = \strlen($value); $offset < $length; ++$offset) { + $byte = \ord($value[$offset]); + $escaped .= $byte >= 0x21 && $byte <= 0x7E ? $value[$offset] : \sprintf('\\x%02X', $byte); + } + + return $escaped; + } +} diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/MockHandler.php b/src/Client/lib/Lib/GuzzleHttp/Handler/MockHandler.php index 5cc0fa54..3c26c50f 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Handler/MockHandler.php +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/MockHandler.php @@ -7,7 +7,6 @@ use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise as P; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\TransferStats; -use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Utils; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\ResponseInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\StreamInterface; @@ -52,21 +51,21 @@ class MockHandler implements \Countable * @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. */ - public static function createWithMiddleware( ?array $queue = null, ?callable $onFulfilled = null, ?callable $onRejected = null ): HandlerStack + public static function createWithMiddleware(?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 \Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\ResponseInterface} objects, Exceptions, + * {@see ResponseInterface} objects, Exceptions, * callables, or Promises. * * @param array|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. */ - public function __construct( ?array $queue = null, ?callable $onFulfilled = null, ?callable $onRejected = null ) + public function __construct(?array $queue = null, ?callable $onFulfilled = null, ?callable $onRejected = null) { $this->onFulfilled = $onFulfilled; $this->onRejected = $onRejected; @@ -160,7 +159,7 @@ public function append(...$values): void ) { $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)); } } } @@ -200,7 +199,7 @@ public function reset(): void private function invokeStats( RequestInterface $request, array $options, - ?ResponseInterface $response = null, + ?ResponseInterface $response = null, $reason = null ): void { if (isset($options['on_stats'])) { diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/Proxy.php b/src/Client/lib/Lib/GuzzleHttp/Handler/Proxy.php index c63841f9..270db376 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Handler/Proxy.php +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/Proxy.php @@ -17,10 +17,10 @@ class Proxy * Sends synchronous requests to a specific handler while sending all other * requests to another handler. * - * @param callable(\Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface, array): \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface $default Handler used for normal responses - * @param callable(\Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface, array): \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface $sync Handler used for synchronous responses. + * @param callable(RequestInterface, array): PromiseInterface $default Handler used for normal responses + * @param callable(RequestInterface, array): PromiseInterface $sync Handler used for synchronous responses. * - * @return callable(\Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface, array): \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface Returns the composed handler. + * @return callable(RequestInterface, array): PromiseInterface Returns the composed handler. */ public static function wrapSync(callable $default, callable $sync): callable { @@ -37,10 +37,10 @@ public static function wrapSync(callable $default, callable $sync): callable * performance benefits of curl while still supporting true streaming * through the StreamHandler. * - * @param callable(\Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface, array): \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface $default Handler used for non-streaming responses - * @param callable(\Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface, array): \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface $streaming Handler used for streaming responses + * @param callable(RequestInterface, array): PromiseInterface $default Handler used for non-streaming responses + * @param callable(RequestInterface, array): PromiseInterface $streaming Handler used for streaming responses * - * @return callable(\Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface, array): \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface Returns the composed handler. + * @return callable(RequestInterface, array): PromiseInterface Returns the composed handler. */ public static function wrapStreaming(callable $default, callable $streaming): callable { @@ -48,4 +48,34 @@ public static function wrapStreaming(callable $default, callable $streaming): ca return empty($options['stream']) ? $default($request, $options) : $streaming($request, $options); }; } + + /** + * Sends requests to a fallback handler when the default cURL handler cannot + * honor TLS 1.2 selection. + * + * @param callable(RequestInterface, array): PromiseInterface $default + * @param callable(RequestInterface, array): PromiseInterface $fallback + * + * @return callable(RequestInterface, array): PromiseInterface Returns the composed handler. + */ + public static function wrapTlsFallback(callable $default, callable $fallback): callable + { + return static function (RequestInterface $request, array $options) use ($default, $fallback): PromiseInterface { + if (self::requiresTls12Fallback($options)) { + return $fallback($request, $options); + } + + return $default($request, $options); + }; + } + + /** + * @param array $options + */ + private static function requiresTls12Fallback(array $options): bool + { + return isset($options[RequestOptions::CRYPTO_METHOD]) + && $options[RequestOptions::CRYPTO_METHOD] === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT + && !CurlVersion::supportsTls12(); + } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/ProxyEnvironment.php b/src/Client/lib/Lib/GuzzleHttp/Handler/ProxyEnvironment.php new file mode 100644 index 00000000..c400090f --- /dev/null +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/ProxyEnvironment.php @@ -0,0 +1,121 @@ + true, + 'max_total_connections' => true, + 'transport_sharing' => true, + ]; + + private const CONNECTION_ERRORS = [ + 'php_network_getaddresses:', + 'getaddrinfo', + 'gethostbyname failed', + 'Connection refused', + 'No connection could be made because the target machine actively refused it', + "couldn't connect to host", // error on HHVM + 'connection attempt failed', + 'connect() failed', + 'Connection timed out', + 'Operation timed out', + 'Network is unreachable', + 'No route to host', + 'Host is unreachable', + 'Host is down', + 'Cannot connect to HTTPS server through proxy', + ]; + /** * @var array */ private $lastHeaders = []; + /** + * @var string + */ + private $transportSharingMode; + + /** + * @var bool + */ + private $connectionCapsConfigured = false; + + /** + * Accepts an associative array of options: + * + * - max_host_connections: Optional positive integer or null. A non-null + * value marks the handler as incompatible with enabled response + * streaming; the number is not used for stream-handler admission. + * - max_total_connections: Optional positive integer or null. A non-null + * value marks the handler as incompatible with enabled response + * streaming; the number is not used for stream-handler admission. + * - transport_sharing: Optional transport sharing mode. + * + * The stream handler cannot cap streamed connections, so a configured cap + * marker rejects enabled response streaming ("stream" => true). Accepted + * transfers are buffered and hold at most one connection per in-flight + * call, but overlapping buffered calls are not collectively limited. + * + * @param array{max_host_connections?: mixed, max_total_connections?: mixed, transport_sharing?: mixed} $options Array of options to use with the handler + */ + public function __construct(array $options = []) + { + foreach ($options as $name => $_) { + if (!isset(self::KNOWN_CONSTRUCTOR_OPTIONS[$name])) { + \trigger_deprecation('guzzlehttp/guzzle', '7.14', \sprintf('The "%s" StreamHandler constructor option is unknown; guzzlehttp/guzzle 8.0 will reject unknown constructor options.', (string) $name)); + } + } + + $this->transportSharingMode = CurlShareHandleState::normalizeMode( + $options['transport_sharing'] ?? null, + 'transport_sharing' + ); + + foreach (['max_host_connections', 'max_total_connections'] as $capOption) { + $value = $options[$capOption] ?? null; + if ($value === null) { + continue; + } + + if (!\is_int($value) || $value < 1) { + throw new \InvalidArgumentException(\sprintf('%s must be a positive integer.', $capOption)); + } + + $this->connectionCapsConfigured = true; + } + } + /** * Sends an HTTP request. * @@ -40,15 +122,60 @@ public function __invoke(RequestInterface $request, array $options): PromiseInte \usleep($options['delay'] * 1000); } + $multiplex = $options['multiplex'] ?? null; + + // Multiplexing::NONE is trivially satisfied: the stream handler sends + // one HTTP/1.x request per connection and never multiplexes. + if (null !== $multiplex && !\in_array($multiplex, [Multiplexing::NONE, Multiplexing::EAGER, Multiplexing::WAIT, Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { + throw new \InvalidArgumentException(\sprintf( + 'The "multiplex" option must be null or a Plausible\Analytics\WP\Client\Lib\GuzzleHttp\\Multiplexing::* constant; received %s.', + \get_debug_type($multiplex) + )); + } + + if (\in_array($multiplex, [Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { + throw new ConnectException('The stream handler cannot guarantee a multiplexed protocol; required multiplexing needs a cURL handler.', $request); + } + + if ($this->connectionCapsConfigured && !empty($options['stream'])) { + throw new \InvalidArgumentException('Enabling the "stream" request option on a stream handler configured with the "max_host_connections" or "max_total_connections" option is not supported because streamed connections cannot be capped.'); + } + + if (isset($options['on_trailers'])) { + throw new \InvalidArgumentException('Passing the "on_trailers" request option to the stream handler is not supported because the stream handler cannot observe trailers.'); + } + + $protocolVersion = $request->getProtocolVersion(); + + if ('' === $protocolVersion) { + \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Sending a request with an empty protocol version is deprecated; guzzlehttp/guzzle 8.0 will reject empty protocol versions.'); + + $protocolVersion = '1.1'; + $request = Psr7\Utils::modifyRequest($request, ['version' => $protocolVersion]); + } + + if ('1.0' !== $protocolVersion && '1.1' !== $protocolVersion) { + throw new ConnectException(sprintf('HTTP/%s is not supported by the stream handler.', $protocolVersion), $request); + } + $startTime = isset($options['on_stats']) ? Utils::currentTime() : null; + self::triggerUnsupportedRequestOptionDeprecations($request, $options); + $this->assertTransportSharingSupported(); + try { // Does not support the expect header. $request = $request->withoutHeader('Expect'); // Append a content-length header if body size is zero to match - // cURL's behavior. - if (0 === $request->getBody()->getSize()) { + // the behavior of `CurlHandler` + if ( + ( + Psr7\Utils::caselessEquals('PUT', $request->getMethod()) + || Psr7\Utils::caselessEquals('POST', $request->getMethod()) + ) + && 0 === $request->getBody()->getSize() + ) { $request = $request->withHeader('Content-Length', '0'); } @@ -61,17 +188,10 @@ public function __invoke(RequestInterface $request, array $options): PromiseInte } catch (\InvalidArgumentException $e) { throw $e; } catch (\Exception $e) { - // Determine if the error was a networking error. - $message = $e->getMessage(); - // This list can probably get more comprehensive. - if (false !== \strpos($message, 'getaddrinfo') // DNS lookup failed - || false !== \strpos($message, 'Connection refused') - || false !== \strpos($message, "couldn't connect to host") // error on HHVM - || false !== \strpos($message, 'connection attempt failed') - ) { - $e = new ConnectException($e->getMessage(), $request, $e); - } else { - $e = RequestException::wrapException($request, $e); + if (!$e instanceof TransferException) { + $e = self::isConnectionError($e->getMessage()) + ? new ConnectException($e->getMessage(), $request, $e) + : new RequestException($e->getMessage(), $request, null, $e); } $this->invokeStats($options, $request, $startTime, null, $e); @@ -79,12 +199,23 @@ public function __invoke(RequestInterface $request, array $options): PromiseInte } } + private static function isConnectionError(string $message): bool + { + foreach (self::CONNECTION_ERRORS as $connectionError) { + if (false !== \strpos($message, $connectionError)) { + return true; + } + } + + return false; + } + private function invokeStats( array $options, RequestInterface $request, ?float $startTime, - ?ResponseInterface $response = null, - ?\Throwable $error = null + ?ResponseInterface $response = null, + ?\Throwable $error = null ): void { if (isset($options['on_stats'])) { $stats = new TransferStats($request, $response, Utils::currentTime() - $startTime, $error, []); @@ -102,32 +233,28 @@ private function createResponse(RequestInterface $request, array $options, $stre try { [$ver, $status, $reason, $headers] = HeaderProcessor::parseHeaders($hdrs); - } catch (\Exception $e) { - return P\Create::rejectionFor( - new RequestException('An error was encountered while creating the response', $request, null, $e) - ); + } catch (\Throwable $e) { + return $this->rejectResponseCreation($options, $request, $startTime, $e); } [$stream, $headers] = $this->checkDecode($options, $headers, $stream); $stream = Psr7\Utils::streamFor($stream); $sink = $stream; - if (\strcasecmp('HEAD', $request->getMethod())) { + if (!Psr7\Utils::caselessEquals('HEAD', $request->getMethod())) { $sink = $this->createSink($stream, $options); } try { $response = new Psr7\Response($status, $headers, $sink, $ver, $reason); - } catch (\Exception $e) { - return P\Create::rejectionFor( - new RequestException('An error was encountered while creating the response', $request, null, $e) - ); + } catch (\Throwable $e) { + return $this->rejectResponseCreation($options, $request, $startTime, $e); } if (isset($options['on_headers'])) { try { $options['on_headers']($response); - } catch (\Exception $e) { + } catch (\Throwable $e) { return P\Create::rejectionFor( new RequestException('An error was encountered during the on_headers event', $request, $response, $e) ); @@ -145,6 +272,24 @@ private function createResponse(RequestInterface $request, array $options, $stre return new FulfilledPromise($response); } + private function rejectResponseCreation( + array $options, + RequestInterface $request, + ?float $startTime, + \Throwable $previous + ): PromiseInterface { + $reason = new RequestException( + 'An error was encountered while creating the response', + $request, + null, + $previous + ); + + $this->invokeStats($options, $request, $startTime, null, $reason); + + return P\Create::rejectionFor($reason); + } + private function createSink(StreamInterface $stream, array $options): StreamInterface { if (!empty($options['stream'])) { @@ -162,7 +307,7 @@ private function createSink(StreamInterface $stream, array $options): StreamInte private function checkDecode(array $options, array $headers, $stream): array { // Automatically decode responses when instructed. - if (!empty($options['decode_content'])) { + if (isset($options['decode_content']) && $options['decode_content'] !== false) { $normalizedKeys = Utils::normalizeHeaderKeys($headers); if (isset($normalizedKeys['content-encoding'])) { $encoding = $headers[$normalizedKeys['content-encoding']]; @@ -173,15 +318,12 @@ private function checkDecode(array $options, array $headers, $stream): array // Remove content-encoding header unset($headers[$normalizedKeys['content-encoding']]); - // Fix content-length header + // The decoded length cannot be known without inflating the + // stream, so keep the original length for inspection and + // drop the now-unknown Content-Length header. if (isset($normalizedKeys['content-length'])) { $headers['x-encoded-content-length'] = $headers[$normalizedKeys['content-length']]; - $length = (int) $stream->getSize(); - if ($length === 0) { - unset($headers[$normalizedKeys['content-length']]); - } else { - $headers[$normalizedKeys['content-length']] = [$length]; - } + unset($headers[$normalizedKeys['content-length']]); } } } @@ -251,7 +393,7 @@ private function createResource(callable $callback) $message .= "[$key] $value".\PHP_EOL; } } - throw new \RuntimeException(\trim($message)); + throw new \RuntimeException(\trim($message, " \n\r\t\0\x0B")); } return $resource; @@ -267,13 +409,30 @@ private function createStream(RequestInterface $request, array $options) $methods = \array_flip(\get_class_methods(__CLASS__)); } - if (!\in_array($request->getUri()->getScheme(), ['http', 'https'])) { - throw new RequestException(\sprintf("The scheme '%s' is not supported.", $request->getUri()->getScheme()), $request); + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + if ($scheme === '') { + throw new RequestException('URI must include a scheme and host. Use an absolute URI, a network-path reference starting with //, or configure a base_uri.', $request); + } + + if (!\in_array($scheme, ['http', 'https'], true)) { + throw new RequestException(\sprintf("The scheme '%s' is not supported.", $scheme), $request); } + $protocols = Utils::normalizeProtocols($options['protocols'] ?? ['http', 'https']); + if (!\in_array($scheme, $protocols, true)) { + throw new RequestException(\sprintf('The scheme "%s" is not allowed by the protocols request option.', $scheme), $request); + } + + if ($uri->getHost() === '') { + throw new RequestException('URI must include a scheme and host. Use an absolute URI, a network-path reference starting with //, or configure a base_uri.', $request); + } + + HostValidator::assertRequestHost($request); + // HTTP/1.1 streams using the PHP stream wrapper require a // Connection: close header - if ($request->getProtocolVersion() == '1.1' + if ($request->getProtocolVersion() === '1.1' && !$request->hasHeader('Connection') ) { $request = $request->withHeader('Connection', 'close'); @@ -291,10 +450,19 @@ private function createStream(RequestInterface $request, array $options) throw new \InvalidArgumentException('on_headers must be callable'); } + self::assertTlsVersionRangeForOptions($options); + + $proxyAuthorizationAdded = false; if (!empty($options)) { foreach ($options as $key => $value) { $method = "add_{$key}"; if (isset($methods[$method])) { + if ($method === 'add_proxy') { + $proxyAuthorizationAdded = $this->add_proxy($request, $context, $value, $params); + + continue; + } + $this->{$method}($request, $context, $value, $params); } } @@ -304,6 +472,16 @@ private function createStream(RequestInterface $request, array $options) if (!\is_array($options['stream_context'])) { throw new \InvalidArgumentException('stream_context must be an array'); } + if ( + $proxyAuthorizationAdded + && isset($options['stream_context']['http']) + && \is_array($options['stream_context']['http']) + && \array_key_exists('proxy', $options['stream_context']['http']) + ) { + throw new \InvalidArgumentException('stream_context.http.proxy cannot override a proxy after the stream handler has generated a Proxy-Authorization header; configure the final proxy with the "proxy" request option.'); + } + self::triggerConflictingStreamContextOptionDeprecations($options['stream_context']); + self::triggerUnsupportedStreamContextOptionDeprecations($options['stream_context']); $context = \array_replace_recursive($context, $options['stream_context']); } @@ -321,12 +499,18 @@ static function () use ($context, $params) { ); return $this->createResource( - function () use ($uri, &$http_response_header, $contextResource, $context, $options, $request) { + function () use ($uri, $contextResource, $context, $options, $request) { $resource = @\fopen((string) $uri, 'r', false, $contextResource); + + // See https://wiki.php.net/rfc/deprecations_php_8_5#deprecate_the_http_response_header_predefined_variable + if (function_exists('http_get_last_response_headers')) { + $http_response_header = \http_get_last_response_headers(); + } + $this->lastHeaders = $http_response_header ?? []; if (false === $resource) { - throw new ConnectException(sprintf('Connection refused for URI %s', $uri), $request, null, $context); + throw new ConnectException(sprintf('Connection refused for URI %s', Psr7\Utils::redactUserInfo($uri)), $request, null, $context); } if (isset($options['read_timeout'])) { @@ -345,7 +529,23 @@ private function resolveHost(RequestInterface $request, array $options): UriInte { $uri = $request->getUri(); - if (isset($options['force_ip_resolve']) && !\filter_var($uri->getHost(), \FILTER_VALIDATE_IP)) { + $host = $uri->getHost(); + + // Fold a numeric IPv4 spelling to the dotted quad libcurl connects + // to, rather than leaving it to the platform resolver: macOS reads + // the zero-padded 0177 as decimal 177 where glibc, musl and FreeBSD + // read octal 127. The Host header is serialized from the request and + // stays as written; the TLS peer name follows the same fold. + $canonicalHost = self::canonicalConnectionHost($host); + if ($canonicalHost !== $host) { + $uri = $uri->withHost($canonicalHost); + $host = $canonicalHost; + } + + $hostForIpCheck = $host !== '' && $host[0] === '[' && \substr($host, -1) === ']' + ? \substr($host, 1, -1) + : $host; + if (isset($options['force_ip_resolve']) && !\filter_var($hostForIpCheck, \FILTER_VALIDATE_IP)) { if ('v4' === $options['force_ip_resolve']) { $records = \dns_get_record($uri->getHost(), \DNS_A); if (false === $records || !isset($records[0]['ip'])) { @@ -367,10 +567,99 @@ private function resolveHost(RequestInterface $request, array $options): UriInte return $uri; } + /** + * Returns a numeric IPv4 spelling folded to the dotted quad libcurl's + * ipv4_normalize() produces, and every other host unchanged. + */ + private static function canonicalConnectionHost(string $host): string + { + $binary = self::numericIpv4ToBinary($host); + if ($binary === null) { + return $host; + } + + return (string) \inet_ntop($binary); + } + + /** + * 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 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. + */ + private static function numericIpv4ToBinary(string $host): ?string + { + if (!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 getDefaultContext(RequestInterface $request): array { $headers = ''; foreach ($request->getHeaders() as $name => $value) { + // A first-class Proxy-Authorization header is proxy-scoped. Keep + // it out of the origin context; add_proxy() adds one + // validated canonical line only when Guzzle selects a proxy; PHP + // extracts that line for CONNECT and removes it before sending the + // tunneled origin request. The caselessEquals() helper is + // locale-independent, unlike strcasecmp(), so a locale cannot + // make this match miss and re-leak the credential. + if (Psr7\Utils::caselessEquals((string) $name, 'Proxy-Authorization')) { + continue; + } + foreach ($value as $val) { $headers .= "$name: $val\r\n"; } @@ -385,7 +674,7 @@ private function getDefaultContext(RequestInterface $request): array 'follow_location' => 0, ], 'ssl' => [ - 'peer_name' => $request->getUri()->getHost(), + 'peer_name' => self::canonicalConnectionHost($request->getUri()->getHost()), ], ]; @@ -399,15 +688,271 @@ private function getDefaultContext(RequestInterface $request): array } } - $context['http']['header'] = \rtrim($context['http']['header']); + $context['http']['header'] = \rtrim($context['http']['header'], " \n\r\t\0\x0B"); return $context; } + private static function triggerUnsupportedRequestOptionDeprecations(RequestInterface $request, array $options): void + { + if ( + \array_key_exists('curl', $options) + && $options['curl'] !== null + && $options['curl'] !== [] + && !self::isCurlOptionGeneratedByAuth($options) + ) { + \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "curl" request option to the stream handler is deprecated; guzzlehttp/guzzle 8.0 will reject this option because the stream handler ignores cURL options.'); + } + + if (\array_key_exists('expect', $options) && $options['expect'] !== false && $request->hasHeader('Expect')) { + \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "expect" request option to the stream handler is deprecated when it adds an Expect header; guzzlehttp/guzzle 8.0 will reject this option because the stream handler does not support Expect: 100-Continue.'); + } + } + + private static function triggerConflictingStreamContextOptionDeprecations(array $streamContext): void + { + $conflictingOptions = self::conflictingStreamContextOptions(); + + foreach ($streamContext as $wrapper => $contextOptions) { + if (!\is_string($wrapper) || !isset($conflictingOptions[$wrapper]) || !\is_array($contextOptions)) { + continue; + } + + foreach ($contextOptions as $option => $_) { + if (!\is_string($option) || !\array_key_exists($option, $conflictingOptions[$wrapper])) { + continue; + } + + \trigger_deprecation( + 'guzzlehttp/guzzle', + '7.12', + \sprintf( + 'Passing stream_context.%s.%s in the "stream_context" request option is deprecated; guzzlehttp/guzzle 8.0 will reject this option because it conflicts with Guzzle-managed request handling. Use %s instead.', + $wrapper, + $option, + $conflictingOptions[$wrapper][$option] + ) + ); + } + } + } + + private static function triggerUnsupportedStreamContextOptionDeprecations(array $streamContext): void + { + $unsupportedOptions = self::unsupportedStreamContextOptions($streamContext); + if ($unsupportedOptions === []) { + return; + } + + \trigger_deprecation( + 'guzzlehttp/guzzle', + '7.12', + \sprintf( + 'Passing PHP stream context options outside the built-in stream handler allow-list to the "stream_context" request option is deprecated; guzzlehttp/guzzle 8.0 will reject stream context options outside the allow-list. Deprecated option%s: %s.', + \count($unsupportedOptions) === 1 ? '' : 's', + \implode(', ', $unsupportedOptions) + ) + ); + } + + /** + * @return string[] + */ + private static function unsupportedStreamContextOptions(array $streamContext): array + { + $supportedOptions = self::supportedStreamContextOptions(); + $conflictingOptions = self::conflictingStreamContextOptions(); + $unsupportedOptions = []; + + foreach ($streamContext as $wrapper => $contextOptions) { + if (!\is_string($wrapper) || !isset($supportedOptions[$wrapper])) { + if (\is_array($contextOptions)) { + foreach ($contextOptions as $option => $_) { + if (\is_string($wrapper) && \is_string($option) && isset($conflictingOptions[$wrapper]) && \array_key_exists($option, $conflictingOptions[$wrapper])) { + continue; + } + + $unsupportedOptions[] = \sprintf('stream_context.%s.%s', (string) $wrapper, (string) $option); + } + } else { + $unsupportedOptions[] = \sprintf('stream_context.%s', (string) $wrapper); + } + + continue; + } + + if (!\is_array($contextOptions)) { + $unsupportedOptions[] = \sprintf('stream_context.%s', $wrapper); + + continue; + } + + foreach ($contextOptions as $option => $_) { + if (\is_string($option) && isset($conflictingOptions[$wrapper]) && \array_key_exists($option, $conflictingOptions[$wrapper])) { + continue; + } + + if (!\is_string($option) || !\array_key_exists($option, $supportedOptions[$wrapper])) { + $unsupportedOptions[] = \sprintf('stream_context.%s.%s', $wrapper, (string) $option); + } + } + } + + return $unsupportedOptions; + } + + /** + * @return array> + */ + private static function supportedStreamContextOptions(): array + { + return [ + 'http' => [ + 'request_fulluri' => true, + ], + 'socket' => [ + 'bindto' => true, + 'tcp_nodelay' => true, + ], + 'ssl' => [ + 'SNI_enabled' => true, + 'capture_peer_cert' => true, + 'capture_peer_cert_chain' => true, + 'ciphers' => true, + 'disable_compression' => true, + 'no_ticket' => true, + 'peer_fingerprint' => true, + 'security_level' => true, + 'verify_depth' => true, + ], + ]; + } + + /** + * @return array> + */ + private static function conflictingStreamContextOptions(): array + { + return [ + 'http' => [ + 'content' => 'the request body', + 'follow_location' => 'the "allow_redirects" request option', + 'header' => 'the request headers', + 'max_redirects' => 'the "allow_redirects" request option', + 'method' => 'the request method', + 'protocol_version' => 'the request protocol version', + 'proxy' => 'the "proxy" request option', + 'timeout' => 'the "timeout" request option', + ], + 'ssl' => [ + 'allow_self_signed' => 'the "verify" request option', + 'cafile' => 'the "verify" request option', + 'capath' => 'the "verify" request option', + 'crypto_method' => 'the "crypto_method" request option', + 'local_cert' => 'the "cert" request option', + 'local_pk' => 'the "ssl_key" request option', + 'max_proto_version' => 'the "crypto_method_max" request option', + 'min_proto_version' => 'the "crypto_method" request option', + 'passphrase' => 'the "cert" or "ssl_key" request option', + 'peer_name' => 'the request URI', + 'verify_peer' => 'the "verify" request option', + 'verify_peer_name' => 'the "verify" request option', + ], + ]; + } + + private function assertTransportSharingSupported(): void + { + if ($this->transportSharingMode === TransportSharing::HANDLER_REQUIRE) { + throw new \InvalidArgumentException('The "transport_sharing" option requires transport sharing, but the stream handler does not support it.'); + } + } + + private static function isCurlOptionGeneratedByAuth(array $options): bool + { + if (!isset($options['curl']) || !\is_array($options['curl']) || !isset($options['auth'][2]) || !\is_string($options['auth'][2])) { + return false; + } + + if (!\defined('CURLOPT_HTTPAUTH') || !\defined('CURLOPT_USERPWD')) { + return false; + } + + $type = Psr7\Utils::asciiToLower($options['auth'][2]); + if ($type === 'digest') { + $httpAuth = \defined('CURLAUTH_DIGEST') ? \constant('CURLAUTH_DIGEST') : null; + } elseif ($type === 'ntlm') { + $httpAuth = \defined('CURLAUTH_NTLM') ? \constant('CURLAUTH_NTLM') : null; + } else { + return false; + } + + return $httpAuth !== null + && \count($options['curl']) === 2 + && isset($options['curl'][\CURLOPT_HTTPAUTH], $options['curl'][\CURLOPT_USERPWD]) + && $options['curl'][\CURLOPT_HTTPAUTH] === $httpAuth; + } + + /** + * @param mixed $value as passed via Request transfer options. + * + * @return array{0: string, 1: string|null} + */ + private static function normalizeTlsFileOption(string $option, $value): array + { + $passphrase = null; + + if (\is_array($value)) { + if (!isset($value[0]) || !\is_string($value[0])) { + throw new \InvalidArgumentException(\sprintf('Invalid %s request option', $option)); + } + if (isset($value[1])) { + if (!\is_string($value[1])) { + throw new \InvalidArgumentException(\sprintf('Invalid %s request option', $option)); + } + $passphrase = $value[1]; + } + $value = $value[0]; + } + + if (!\is_string($value)) { + throw new \InvalidArgumentException(\sprintf('Invalid %s request option', $option)); + } + + return [$value, $passphrase]; + } + + private static function setTlsPassphrase(array &$options, ?string $passphrase, string $option): void + { + if ($passphrase === null) { + return; + } + + if (isset($options['ssl']['passphrase']) && $options['ssl']['passphrase'] !== $passphrase) { + throw new \InvalidArgumentException(\sprintf('Cannot use different passphrases for cert and ssl_key with the stream handler; %s conflicts with an existing TLS passphrase.', $option)); + } + + $options['ssl']['passphrase'] = $passphrase; + } + /** * @param mixed $value as passed via Request transfer options. */ - private function add_proxy(RequestInterface $request, array &$options, $value, array &$params): void + private static function assertStreamTlsType(string $option, $value): void + { + if (!\is_string($value) || $value === '') { + throw new \InvalidArgumentException(\sprintf('%s must be a non-empty string', $option)); + } + + if (Psr7\Utils::asciiToUpper($value) !== 'PEM') { + throw new \InvalidArgumentException(\sprintf('The stream handler only supports "PEM" for the %s request option.', $option)); + } + } + + /** + * @param mixed $value as passed via Request transfer options. + */ + private function add_proxy(RequestInterface $request, array &$options, $value, array &$params): bool { $uri = null; @@ -416,25 +961,50 @@ private function add_proxy(RequestInterface $request, array &$options, $value, a } else { $scheme = $request->getUri()->getScheme(); if (isset($value[$scheme])) { - if (!isset($value['no']) || !Utils::isHostInNoProxy($request->getUri()->getHost(), $value['no'])) { + if ( + !isset($value['no']) + || !Utils::isUriInNoProxy($request->getUri(), $value['no']) + ) { $uri = $value[$scheme]; } } } if (!$uri) { - return; + return false; } $parsed = $this->parse_proxy($uri); + + // PHP extracts and removes only one Proxy-Authorization line for a + // CONNECT tunnel. Serialize exactly one validated first-class value; + // more than one could leave a credential in the tunneled origin + // request. A first-class value, including an empty one, is + // authoritative over Basic credentials embedded in the proxy URI. + $managed = $request->getHeader('Proxy-Authorization'); + if (\count($managed) > 1) { + throw new \InvalidArgumentException('The stream handler supports exactly one Proxy-Authorization request header value when a proxy is selected.'); + } + if ($managed !== [] && \strpbrk($managed[0], "\r\n") !== false) { + throw new \InvalidArgumentException('Proxy-Authorization request header values must not contain a carriage return or line feed.'); + } + $options['http']['proxy'] = $parsed['proxy']; - if ($parsed['auth']) { - if (!isset($options['http']['header'])) { - $options['http']['header'] = []; - } + if (($managed !== [] || $parsed['auth']) && !isset($options['http']['header'])) { + $options['http']['header'] = ''; + } + if ($managed !== []) { + $options['http']['header'] .= "\r\nProxy-Authorization: {$managed[0]}"; + + return true; + } elseif ($parsed['auth']) { $options['http']['header'] .= "\r\nProxy-Authorization: {$parsed['auth']}"; + + return true; } + + return false; } /** @@ -444,16 +1014,29 @@ private function parse_proxy(string $url): array { $parsed = \parse_url($url); - if ($parsed !== false && isset($parsed['scheme']) && $parsed['scheme'] === 'http') { - if (isset($parsed['host']) && isset($parsed['port'])) { - $auth = null; - if (isset($parsed['user']) && isset($parsed['pass'])) { - $auth = \base64_encode("{$parsed['user']}:{$parsed['pass']}"); - } + // parse_url() misreads scheme-less proxy authorities like + // "user:pass@host"; re-parse only those forms as HTTP. + $schemeLessAuthority = \strpos($url, '://') === false && \strncmp($url, '//', 2) !== 0; + if ($schemeLessAuthority) { + if (\is_array($parsed) && !isset($parsed['scheme']) && isset($parsed['host'], $parsed['port'])) { + $parsed['scheme'] = 'http'; + } elseif ( + (!\is_array($parsed) || !isset($parsed['host'])) + && (\strpos($url, '@') !== false || \strncmp($url, '[', 1) === 0) + ) { + $parsed = \parse_url('http://'.$url); + } + } + + if (\is_array($parsed) && isset($parsed['scheme']) && Psr7\Utils::caselessEquals($parsed['scheme'], 'http')) { + if (isset($parsed['host'], $parsed['port'])) { + $user = $parsed['user'] ?? ''; + $pass = $parsed['pass'] ?? ''; + $auth = ($user !== '' || $pass !== '') ? 'Basic '.\base64_encode("{$user}:{$pass}") : null; return [ 'proxy' => "tcp://{$parsed['host']}:{$parsed['port']}", - 'auth' => $auth ? "Basic {$auth}" : null, + 'auth' => $auth, ]; } } @@ -494,6 +1077,26 @@ private function add_crypto_method(RequestInterface $request, array &$options, $ throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided'); } + /** + * @param mixed $value as passed via Request transfer options. + */ + private function add_crypto_method_max(RequestInterface $request, array &$options, $value, array &$params): void + { + $options['ssl']['max_proto_version'] = TlsVersion::streamProtocolVersion('crypto_method_max', $value); + } + + private static function assertTlsVersionRangeForOptions(array $options): void + { + if (!isset($options['crypto_method_max'])) { + return; + } + + TlsVersion::assertRange( + $options['crypto_method'] ?? null, + $options['crypto_method_max'] + ); + } + /** * @param mixed $value as passed via Request transfer options. */ @@ -525,23 +1128,56 @@ private function add_verify(RequestInterface $request, array &$options, $value, */ private function add_cert(RequestInterface $request, array &$options, $value, array &$params): void { - if (\is_array($value)) { - $options['ssl']['passphrase'] = $value[1]; - $value = $value[0]; - } + [$value, $passphrase] = self::normalizeTlsFileOption('cert', $value); if (!\file_exists($value)) { throw new \RuntimeException("SSL certificate not found: {$value}"); } + self::setTlsPassphrase($options, $passphrase, 'cert'); $options['ssl']['local_cert'] = $value; } + /** + * @param mixed $value as passed via Request transfer options. + */ + private function add_cert_type(RequestInterface $request, array &$options, $value, array &$params): void + { + self::assertStreamTlsType('cert_type', $value); + } + + /** + * @param mixed $value as passed via Request transfer options. + */ + private function add_ssl_key(RequestInterface $request, array &$options, $value, array &$params): void + { + [$value, $passphrase] = self::normalizeTlsFileOption('ssl_key', $value); + + if (!\file_exists($value)) { + throw new \RuntimeException("SSL private key not found: {$value}"); + } + + self::setTlsPassphrase($options, $passphrase, 'ssl_key'); + $options['ssl']['local_pk'] = $value; + } + + /** + * @param mixed $value as passed via Request transfer options. + */ + private function add_ssl_key_type(RequestInterface $request, array &$options, $value, array &$params): void + { + self::assertStreamTlsType('ssl_key_type', $value); + } + /** * @param mixed $value as passed via Request transfer options. */ private function add_progress(RequestInterface $request, array &$options, $value, array &$params): void { + if (!\is_callable($value)) { + throw new \InvalidArgumentException('progress client option must be callable'); + } + self::addNotification( $params, static function ($code, $a, $b, $c, $transferred, $total) use ($value) { diff --git a/src/Client/lib/Lib/GuzzleHttp/Handler/TlsVersion.php b/src/Client/lib/Lib/GuzzleHttp/Handler/TlsVersion.php new file mode 100644 index 00000000..513944bb --- /dev/null +++ b/src/Client/lib/Lib/GuzzleHttp/Handler/TlsVersion.php @@ -0,0 +1,84 @@ +push(Middleware::httpErrors(), 'http_errors'); @@ -58,7 +58,7 @@ public static function create( ?callable $handler = null ): self /** * @param (callable(RequestInterface, array): PromiseInterface)|null $handler Underlying HTTP handler. */ - public function __construct( ?callable $handler = null ) + public function __construct(?callable $handler = null) { $this->handler = $handler; } @@ -131,7 +131,7 @@ public function hasHandler(): bool * @param callable(callable): callable $middleware Middleware function * @param string $name Name to register for this middleware. */ - public function unshift( callable $middleware, ?string $name = null ): void + public function unshift(callable $middleware, ?string $name = null): void { \array_unshift($this->stack, [$middleware, $name]); $this->cached = null; @@ -181,15 +181,29 @@ public function after(string $findName, callable $middleware, string $withName = 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__); + \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__); } $this->cached = null; - $idx = \is_callable($remove) ? 0 : 1; + + if (\is_string($remove)) { + $count = \count($this->stack); + $this->stack = \array_values(\array_filter( + $this->stack, + static function ($tuple) use ($remove) { + return $tuple[1] !== $remove; + } + )); + + if ($count !== \count($this->stack) || !\is_callable($remove)) { + return; + } + } + $this->stack = \array_values(\array_filter( $this->stack, - static function ($tuple) use ($idx, $remove) { - return $tuple[$idx] !== $remove; + static function ($tuple) use ($remove) { + return $tuple[0] !== $remove; } )); } @@ -270,6 +284,6 @@ private function debugCallable($fn): string } /** @var object $fn */ - return 'callable('.\spl_object_hash($fn).')'; + return 'callable('.\spl_object_id($fn).')'; } } diff --git a/src/Client/lib/Lib/GuzzleHttp/MessageFormatter.php b/src/Client/lib/Lib/GuzzleHttp/MessageFormatter.php index 77ae28bf..5afe9237 100644 --- a/src/Client/lib/Lib/GuzzleHttp/MessageFormatter.php +++ b/src/Client/lib/Lib/GuzzleHttp/MessageFormatter.php @@ -68,12 +68,11 @@ public function __construct(?string $template = self::CLF) * @param ResponseInterface|null $response Response that was received * @param \Throwable|null $error Exception that was received */ - public function format( RequestInterface $request, ?ResponseInterface $response = null, ?\Throwable $error = null ): string + public function format(RequestInterface $request, ?ResponseInterface $response = null, ?\Throwable $error = null): string { $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) { if (isset($cache[$matches[1]])) { @@ -90,7 +89,7 @@ function (array $matches) use ($request, $response, $error, &$cache) { 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; @@ -182,6 +181,12 @@ function (array $matches) use ($request, $response, $error, &$cache) { }, $this->template ); + + if ($result === null) { + throw new \RuntimeException('Unable to format message: '.\preg_last_error_msg()); + } + + return $result; } /** @@ -194,6 +199,6 @@ private function headers(MessageInterface $message): string $result .= $name.': '.\implode(', ', $values)."\r\n"; } - return \trim($result); + return \trim($result, " \n\r\t\0\x0B"); } } diff --git a/src/Client/lib/Lib/GuzzleHttp/MessageFormatterInterface.php b/src/Client/lib/Lib/GuzzleHttp/MessageFormatterInterface.php index 412d9dd2..d3df4473 100644 --- a/src/Client/lib/Lib/GuzzleHttp/MessageFormatterInterface.php +++ b/src/Client/lib/Lib/GuzzleHttp/MessageFormatterInterface.php @@ -14,5 +14,5 @@ interface MessageFormatterInterface * @param ResponseInterface|null $response Response that was received * @param \Throwable|null $error Exception that was received */ - public function format( RequestInterface $request, ?ResponseInterface $response = null, ?\Throwable $error = null ): string; + public function format(RequestInterface $request, ?ResponseInterface $response = null, ?\Throwable $error = null): string; } diff --git a/src/Client/lib/Lib/GuzzleHttp/Middleware.php b/src/Client/lib/Lib/GuzzleHttp/Middleware.php index 8f1c5cc7..48747998 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Middleware.php +++ b/src/Client/lib/Lib/GuzzleHttp/Middleware.php @@ -29,7 +29,7 @@ public static function cookies(): callable return static function ($request, array $options) use ($handler) { if (empty($options['cookies'])) { return $handler($request, $options); - } elseif (!($options['cookies'] instanceof CookieJarInterface)) { + } elseif (!$options['cookies'] instanceof CookieJarInterface) { throw new \InvalidArgumentException('cookies must be an instance of Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie\CookieJarInterface'); } $cookieJar = $options['cookies']; @@ -55,7 +55,7 @@ static function (ResponseInterface $response) use ($cookieJar, $request): Respon * * @return callable(callable): callable Returns a function that accepts the next handler. */ - public static function httpErrors( ?BodySummarizerInterface $bodySummarizer = null ): callable + 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) { @@ -132,7 +132,7 @@ static function ($reason) use ($request, &$container, $options) { * * @return callable Returns a function that accepts the next handler. */ - public static function tap( ?callable $before = null, ?callable $after = null ): callable + 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) { @@ -176,7 +176,7 @@ public static function redirect(): callable * * @return callable Returns a function that accepts the next handler. */ - public static function retry( callable $decider, ?callable $delay = null ): callable + public static function retry(callable $decider, ?callable $delay = null): callable { return static function (callable $handler) use ($decider, $delay): RetryMiddleware { return new RetryMiddleware($decider, $handler, $delay); @@ -187,12 +187,12 @@ public static function retry( callable $decider, ?callable $delay = null ): call * Middleware that logs requests, responses, and errors using a message * formatter. * - * @phpstan-param \Psr\Log\LogLevel::* $logLevel Level at which to log requests. - * * @param LoggerInterface $logger Logs messages. * @param MessageFormatterInterface|MessageFormatter $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. */ public static function log(LoggerInterface $logger, $formatter, string $logLevel = 'info'): callable diff --git a/src/Client/lib/Lib/GuzzleHttp/Multiplexing.php b/src/Client/lib/Lib/GuzzleHttp/Multiplexing.php new file mode 100644 index 00000000..79592a49 --- /dev/null +++ b/src/Client/lib/Lib/GuzzleHttp/Multiplexing.php @@ -0,0 +1,26 @@ + $rfn) { @@ -59,7 +71,7 @@ public function __construct(ClientInterface $client, $requests, array $config = } elseif (\is_callable($rfn)) { yield $key => $rfn($opts); } 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 Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface or a callable that returns a promise that fulfills with a Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\ResponseInterface object.'); } } }; @@ -86,7 +98,7 @@ public function promise(): PromiseInterface * @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 \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Pool::__construct} + * {@see Pool::__construct} * * @return array Returns an array containing the response or an exception * in the same order that the requests were sent. diff --git a/src/Client/lib/Lib/GuzzleHttp/PrepareBodyMiddleware.php b/src/Client/lib/Lib/GuzzleHttp/PrepareBodyMiddleware.php index 7542fba5..a65853ee 100644 --- a/src/Client/lib/Lib/GuzzleHttp/PrepareBodyMiddleware.php +++ b/src/Client/lib/Lib/GuzzleHttp/PrepareBodyMiddleware.php @@ -52,7 +52,7 @@ public function __invoke(RequestInterface $request, array $options): PromiseInte ) { $size = $request->getBody()->getSize(); if ($size !== null) { - $modify['set_headers']['Content-Length'] = $size; + $modify['set_headers']['Content-Length'] = (string) $size; } else { $modify['set_headers']['Transfer-Encoding'] = 'chunked'; } @@ -76,8 +76,8 @@ private function addExpectHeader(RequestInterface $request, array $options, arra $expect = $options['expect'] ?? null; - // Return if disabled or if you're not using HTTP/1.1 or HTTP/2.0 - if ($expect === false || $request->getProtocolVersion() < 1.1) { + // Return if disabled or using HTTP/1.0 + if ($expect === false || $request->getProtocolVersion() === '1.0') { return; } diff --git a/src/Client/lib/Lib/GuzzleHttp/Promise/Coroutine.php b/src/Client/lib/Lib/GuzzleHttp/Promise/Coroutine.php index c04cf4d7..b003bc1e 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Promise/Coroutine.php +++ b/src/Client/lib/Lib/GuzzleHttp/Promise/Coroutine.php @@ -84,8 +84,8 @@ public static function of(callable $generatorFn): self } public function then( - ?callable $onFulfilled = null, - ?callable $onRejected = null + ?callable $onFulfilled = null, + ?callable $onRejected = null ): PromiseInterface { return $this->result->then($onFulfilled, $onRejected); } @@ -117,7 +117,10 @@ public function reject($reason): void public function cancel(): void { - $this->currentPromise->cancel(); + if (isset($this->currentPromise)) { + $this->currentPromise->cancel(); + } + $this->result->cancel(); } diff --git a/src/Client/lib/Lib/GuzzleHttp/Promise/Create.php b/src/Client/lib/Lib/GuzzleHttp/Promise/Create.php index d2233b02..f9998fb0 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Promise/Create.php +++ b/src/Client/lib/Lib/GuzzleHttp/Promise/Create.php @@ -74,6 +74,16 @@ public static function iterFor($value): \Iterator return new \ArrayIterator($value); } + if (!is_iterable($value)) { + \trigger_deprecation( + 'guzzlehttp/promises', + '2.5', + 'Passing a non-iterable to %s::%s() is deprecated; guzzlehttp/promises 3.0 will require an iterable.', + __CLASS__, + __FUNCTION__ + ); + } + return new \ArrayIterator([$value]); } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Promise/Each.php b/src/Client/lib/Lib/GuzzleHttp/Promise/Each.php index b29d90f2..8e5b0b9b 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Promise/Each.php +++ b/src/Client/lib/Lib/GuzzleHttp/Promise/Each.php @@ -19,15 +19,15 @@ final class Each * index, and the aggregate promise. The callback can invoke any necessary * side effects and choose to resolve or reject the aggregate if needed. * - * @param mixed $iterable Iterator or array to iterate over. - * @param callable $onFulfilled - * @param callable $onRejected + * @param mixed $iterable Iterator or array to iterate over. */ public static function of( $iterable, - ?callable $onFulfilled = null, - ?callable $onRejected = null + ?callable $onFulfilled = null, + ?callable $onRejected = null ): PromiseInterface { + $iterable = self::prepareIterable($iterable, __FUNCTION__); + return (new EachPromise($iterable, [ 'fulfilled' => $onFulfilled, 'rejected' => $onRejected, @@ -44,15 +44,15 @@ public static function of( * * @param mixed $iterable * @param int|callable $concurrency - * @param callable $onFulfilled - * @param callable $onRejected */ public static function ofLimit( $iterable, $concurrency, - ?callable $onFulfilled = null, - ?callable $onRejected = null + ?callable $onFulfilled = null, + ?callable $onRejected = null ): PromiseInterface { + $iterable = self::prepareIterable($iterable, __FUNCTION__); + return (new EachPromise($iterable, [ 'fulfilled' => $onFulfilled, 'rejected' => $onRejected, @@ -67,13 +67,14 @@ public static function ofLimit( * * @param mixed $iterable * @param int|callable $concurrency - * @param callable $onFulfilled */ public static function ofLimitAll( $iterable, $concurrency, - ?callable $onFulfilled = null + ?callable $onFulfilled = null ): PromiseInterface { + $iterable = self::prepareIterable($iterable, __FUNCTION__); + return self::ofLimit( $iterable, $concurrency, @@ -83,4 +84,21 @@ function ($reason, $idx, PromiseInterface $aggregate): void { } ); } + + private static function prepareIterable($iterable, string $method): iterable + { + if (is_iterable($iterable)) { + return $iterable; + } + + \trigger_deprecation( + 'guzzlehttp/promises', + '2.5', + 'Passing a non-iterable to %s::%s() is deprecated; guzzlehttp/promises 3.0 will require an iterable.', + self::class, + $method + ); + + return [$iterable]; + } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Promise/EachPromise.php b/src/Client/lib/Lib/GuzzleHttp/Promise/EachPromise.php index a6e01428..d09cee2a 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Promise/EachPromise.php +++ b/src/Client/lib/Lib/GuzzleHttp/Promise/EachPromise.php @@ -34,6 +34,9 @@ class EachPromise implements PromisorInterface /** @var bool|null */ private $mutex; + /** @var bool */ + private $stepWhileLocked = false; + /** * Configuration hash can include the following key value pairs: * @@ -57,6 +60,18 @@ class EachPromise implements PromisorInterface */ public function __construct($iterable, array $config = []) { + if (!is_iterable($iterable)) { + \trigger_deprecation( + 'guzzlehttp/promises', + '2.5', + 'Passing a non-iterable to %s::%s() is deprecated; guzzlehttp/promises 3.0 will require an iterable.', + __CLASS__, + __FUNCTION__ + ); + + $iterable = [$iterable]; + } + $this->iterable = Create::iterFor($iterable); if (isset($config['concurrency'])) { @@ -84,6 +99,19 @@ public function promise(): PromiseInterface /** @psalm-assert Promise $this->aggregate */ $this->iterable->rewind(); $this->refillPending(); + if (!$this->pending) { + Utils::queue()->add(function (): void { + if (!$this->aggregate || Is::settled($this->aggregate)) { + return; + } + + try { + $this->checkIfFinished(); + } catch (\Throwable $e) { + $this->aggregate->reject($e); + } + }); + } } catch (\Throwable $e) { $this->aggregate->reject($e); } @@ -98,16 +126,23 @@ private function createPromise(): void { $this->mutex = false; $this->aggregate = new Promise(function (): void { - if ($this->checkIfFinished()) { - return; - } - reset($this->pending); - // Consume a potentially fluctuating list of promises while - // ensuring that indexes are maintained (precluding array_shift). - while ($promise = current($this->pending)) { - next($this->pending); - $promise->wait(); - if (Is::settled($this->aggregate)) { + while (true) { + if ($this->checkIfFinished()) { + return; + } + reset($this->pending); + // Consume a potentially fluctuating list of promises while + // ensuring that indexes are maintained (precluding array_shift). + while ($promise = current($this->pending)) { + next($this->pending); + $promise->wait(); + if (Is::settled($this->aggregate)) { + return; + } + } + // Refill and re-sweep; give up only when nothing remains. + $this->refillPending(); + if (Is::settled($this->aggregate) || !$this->pending) { return; } } @@ -135,8 +170,12 @@ private function refillPending(): void // Add only up to N pending promises. $concurrency = is_callable($this->concurrency) - ? call_user_func($this->concurrency, count($this->pending)) + ? ($this->concurrency)(count($this->pending)) : $this->concurrency; + // The callable can settle the aggregate; admit nothing more. + if (Is::settled($this->aggregate)) { + return; + } $concurrency = max($concurrency - count($this->pending), 0); // Concurrency may be set to 0 to disallow new promises. if (!$concurrency) { @@ -170,8 +209,7 @@ private function addPending(): bool $this->pending[$idx] = $promise->then( function ($value) use ($idx, $key): void { if ($this->onFulfilled) { - call_user_func( - $this->onFulfilled, + ($this->onFulfilled)( $value, $key, $this->aggregate @@ -181,8 +219,7 @@ function ($value) use ($idx, $key): void { }, function ($reason) use ($idx, $key): void { if ($this->onRejected) { - call_user_func( - $this->onRejected, + ($this->onRejected)( $reason, $key, $this->aggregate @@ -200,6 +237,8 @@ private function advanceIterator(): bool // Place a lock on the iterator so that we ensure to not recurse, // preventing fatal generator errors. if ($this->mutex) { + $this->stepWhileLocked = true; + return false; } @@ -208,14 +247,22 @@ private function advanceIterator(): bool try { $this->iterable->next(); $this->mutex = false; - - return true; } catch (\Throwable $e) { $this->aggregate->reject($e); $this->mutex = false; return false; } + + // Run the completion check that locked steps skipped. + if ($this->stepWhileLocked) { + $this->stepWhileLocked = false; + if (!Is::settled($this->aggregate)) { + $this->checkIfFinished(); + } + } + + return true; } private function step(int $idx): void @@ -236,6 +283,7 @@ private function step(int $idx): void } } + /** @phpstan-impure */ private function checkIfFinished(): bool { if (!$this->pending && !$this->iterable->valid()) { diff --git a/src/Client/lib/Lib/GuzzleHttp/Promise/FulfilledPromise.php b/src/Client/lib/Lib/GuzzleHttp/Promise/FulfilledPromise.php index 34ce3707..c6366dca 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Promise/FulfilledPromise.php +++ b/src/Client/lib/Lib/GuzzleHttp/Promise/FulfilledPromise.php @@ -31,8 +31,8 @@ public function __construct($value) } public function then( - ?callable $onFulfilled = null, - ?callable $onRejected = null + ?callable $onFulfilled = null, + ?callable $onRejected = null ): PromiseInterface { // Return itself if there is no onFulfilled function. if (!$onFulfilled) { diff --git a/src/Client/lib/Lib/GuzzleHttp/Promise/Promise.php b/src/Client/lib/Lib/GuzzleHttp/Promise/Promise.php index 3442a874..6c580607 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Promise/Promise.php +++ b/src/Client/lib/Lib/GuzzleHttp/Promise/Promise.php @@ -25,16 +25,16 @@ class Promise implements PromiseInterface * @param callable $cancelFn Fn that when invoked cancels the promise. */ public function __construct( - ?callable $waitFn = null, - ?callable $cancelFn = null + ?callable $waitFn = null, + ?callable $cancelFn = null ) { $this->waitFn = $waitFn; $this->cancelFn = $cancelFn; } public function then( - ?callable $onFulfilled = null, - ?callable $onRejected = null + ?callable $onFulfilled = null, + ?callable $onRejected = null ): PromiseInterface { if ($this->state === self::PENDING) { $p = new Promise(null, [$this, 'cancel']); diff --git a/src/Client/lib/Lib/GuzzleHttp/Promise/PromiseInterface.php b/src/Client/lib/Lib/GuzzleHttp/Promise/PromiseInterface.php index 34b5954d..2f9dda08 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Promise/PromiseInterface.php +++ b/src/Client/lib/Lib/GuzzleHttp/Promise/PromiseInterface.php @@ -27,8 +27,8 @@ interface PromiseInterface * @param callable $onRejected Invoked when the promise is rejected. */ public function then( - ?callable $onFulfilled = null, - ?callable $onRejected = null + ?callable $onFulfilled = null, + ?callable $onRejected = null ): PromiseInterface; /** diff --git a/src/Client/lib/Lib/GuzzleHttp/Promise/RejectedPromise.php b/src/Client/lib/Lib/GuzzleHttp/Promise/RejectedPromise.php index 73bee048..9c3d8ec1 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Promise/RejectedPromise.php +++ b/src/Client/lib/Lib/GuzzleHttp/Promise/RejectedPromise.php @@ -31,8 +31,8 @@ public function __construct($reason) } public function then( - ?callable $onFulfilled = null, - ?callable $onRejected = null + ?callable $onFulfilled = null, + ?callable $onRejected = null ): PromiseInterface { // If there's no onRejected callback then just return self. if (!$onRejected) { diff --git a/src/Client/lib/Lib/GuzzleHttp/Promise/Utils.php b/src/Client/lib/Lib/GuzzleHttp/Promise/Utils.php index 6a8a8950..367262f4 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Promise/Utils.php +++ b/src/Client/lib/Lib/GuzzleHttp/Promise/Utils.php @@ -76,9 +76,15 @@ public static function inspect(PromiseInterface $promise): array 'state' => PromiseInterface::FULFILLED, 'value' => $promise->wait(), ]; - } catch (RejectionException $e) { - return ['state' => PromiseInterface::REJECTED, 'reason' => $e->getReason()]; } catch (\Throwable $e) { + if ($e instanceof AggregateException) { + return ['state' => PromiseInterface::REJECTED, 'reason' => $e]; + } + + if ($e instanceof RejectionException) { + return ['state' => PromiseInterface::REJECTED, 'reason' => $e->getReason()]; + } + return ['state' => PromiseInterface::REJECTED, 'reason' => $e]; } } @@ -95,6 +101,8 @@ public static function inspect(PromiseInterface $promise): array */ public static function inspectAll($promises): array { + $promises = self::prepareIterable($promises, __FUNCTION__); + $results = []; foreach ($promises as $key => $promise) { $results[$key] = self::inspect($promise); @@ -116,6 +124,8 @@ public static function inspectAll($promises): array */ public static function unwrap($promises): array { + $promises = self::prepareIterable($promises, __FUNCTION__); + $results = []; foreach ($promises as $key => $promise) { $results[$key] = $promise->wait(); @@ -137,6 +147,8 @@ public static function unwrap($promises): array */ public static function all($promises, bool $recursive = false): PromiseInterface { + $promises = self::prepareIterable($promises, __FUNCTION__); + $results = []; $promise = Each::of( $promises, @@ -144,7 +156,9 @@ function ($value, $idx) use (&$results): void { $results[$idx] = $value; }, function ($reason, $idx, Promise $aggregate): void { - $aggregate->reject($reason); + if (Is::pending($aggregate)) { + $aggregate->reject($reason); + } } )->then(function () use (&$results) { ksort($results); @@ -154,6 +168,12 @@ function ($reason, $idx, Promise $aggregate): void { if (true === $recursive) { $promise = $promise->then(function ($results) use ($recursive, &$promises) { + // A consumed generator cannot be traversed again, so a + // recursive pass has nothing further to observe. + if ($promises instanceof \Generator) { + return $results; + } + foreach ($promises as $promise) { if (Is::pending($promise)) { return self::all($promises, $recursive); @@ -183,6 +203,8 @@ function ($reason, $idx, Promise $aggregate): void { */ public static function some(int $count, $promises): PromiseInterface { + $promises = self::prepareIterable($promises, __FUNCTION__); + $results = []; $rejections = []; @@ -223,6 +245,8 @@ function () use (&$results, &$rejections, $count) { */ public static function any($promises): PromiseInterface { + $promises = self::prepareIterable($promises, __FUNCTION__); + return self::some(1, $promises)->then(function ($values) { return $values[0]; }); @@ -240,6 +264,8 @@ public static function any($promises): PromiseInterface */ public static function settle($promises): PromiseInterface { + $promises = self::prepareIterable($promises, __FUNCTION__); + $results = []; return Each::of( @@ -256,4 +282,30 @@ function ($reason, $idx) use (&$results): void { return $results; }); } + + private static function prepareIterable($promises, string $method): iterable + { + if (is_iterable($promises)) { + return $promises; + } + + self::triggerNonIterableDeprecation($promises, $method); + + return [$promises]; + } + + private static function triggerNonIterableDeprecation($promises, string $method): void + { + if (is_iterable($promises)) { + return; + } + + \trigger_deprecation( + 'guzzlehttp/promises', + '2.5', + 'Passing a non-iterable to %s::%s() is deprecated; guzzlehttp/promises 3.0 will require an iterable.', + self::class, + $method + ); + } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/AppendStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/AppendStream.php index 13f3d371..60ac006b 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/AppendStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/AppendStream.php @@ -155,6 +155,24 @@ public function rewind(): void */ public function seek($offset, $whence = SEEK_SET): void { + if (!\is_int($offset)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.', + \get_debug_type($offset) + ); + } + + if (!\is_int($whence)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.', + \get_debug_type($whence) + ); + } + if (!$this->seekable) { throw new \RuntimeException('This AppendStream is not seekable'); } elseif ($whence !== SEEK_SET) { @@ -187,6 +205,19 @@ public function seek($offset, $whence = SEEK_SET): void */ public function read($length): string { + if (!\is_int($length)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.', + \get_debug_type($length) + ); + } + + if ($this->streams === []) { + return ''; + } + $buffer = ''; $total = count($this->streams) - 1; $remaining = $length; @@ -235,6 +266,15 @@ public function isSeekable(): bool public function write($string): int { + if (!\is_string($string)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.', + \get_debug_type($string) + ); + } + throw new \RuntimeException('Cannot write to an AppendStream'); } @@ -243,6 +283,15 @@ public function write($string): int */ public function getMetadata($key = null) { + if ($key !== null && !\is_string($key)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.', + \get_debug_type($key) + ); + } + return $key ? null : []; } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/BufferStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/BufferStream.php index fe5a5da5..acce418c 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/BufferStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/BufferStream.php @@ -86,6 +86,24 @@ public function rewind(): void public function seek($offset, $whence = SEEK_SET): void { + if (!\is_int($offset)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.', + \get_debug_type($offset) + ); + } + + if (!\is_int($whence)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.', + \get_debug_type($whence) + ); + } + throw new \RuntimeException('Cannot seek a BufferStream'); } @@ -104,6 +122,15 @@ public function tell(): int */ public function read($length): string { + if (!\is_int($length)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.', + \get_debug_type($length) + ); + } + $currentLength = strlen($this->buffer); if ($length >= $currentLength) { @@ -124,6 +151,15 @@ public function read($length): string */ public function write($string): int { + if (!\is_string($string)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.', + \get_debug_type($string) + ); + } + $this->buffer .= $string; if (strlen($this->buffer) >= $this->hwm) { @@ -138,6 +174,15 @@ public function write($string): int */ public function getMetadata($key = null) { + if ($key !== null && !\is_string($key)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.', + \get_debug_type($key) + ); + } + if ($key === 'hwm') { return $this->hwm; } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/CachingStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/CachingStream.php index 3a4074c9..e759c08e 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/CachingStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/CachingStream.php @@ -25,6 +25,9 @@ final class CachingStream implements StreamInterface */ private $stream; + /** @var bool */ + private $detached = false; + /** * We will treat the buffer object as the body of the stream * @@ -33,7 +36,7 @@ final class CachingStream implements StreamInterface */ public function __construct( StreamInterface $stream, - ?StreamInterface $target = null + ?StreamInterface $target = null ) { $this->remoteStream = $stream; $this->stream = $target ?: new Stream(Utils::tryFopen('php://temp', 'r+')); @@ -41,6 +44,10 @@ public function __construct( public function getSize(): ?int { + if ($this->detached) { + return null; + } + $remoteSize = $this->remoteStream->getSize(); if (null === $remoteSize) { @@ -57,6 +64,24 @@ public function rewind(): void public function seek($offset, $whence = SEEK_SET): void { + if (!\is_int($offset)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.', + \get_debug_type($offset) + ); + } + + if (!\is_int($whence)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.', + \get_debug_type($whence) + ); + } + if ($whence === SEEK_SET) { $byte = $offset; } elseif ($whence === SEEK_CUR) { @@ -77,8 +102,16 @@ public function seek($offset, $whence = SEEK_SET): void // Read the remoteStream until we have read in at least the amount // of bytes requested, or we reach the end of the file. while ($diff > 0 && !$this->remoteStream->eof()) { - $this->read($diff); - $diff = $byte - $this->stream->getSize(); + $previousSize = $this->stream->getSize(); + $previousSkipReadBytes = $this->skipReadBytes; + $data = $this->read($diff); + $currentSize = $this->stream->getSize(); + + if ($data === '' && $currentSize === $previousSize && $this->skipReadBytes === $previousSkipReadBytes) { + break; + } + + $diff = $byte - $currentSize; } } else { // We can just do a normal seek since we've already seen this byte. @@ -88,6 +121,15 @@ public function seek($offset, $whence = SEEK_SET): void public function read($length): string { + if (!\is_int($length)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.', + \get_debug_type($length) + ); + } + // Perform a regular read on any previously read data from the buffer $data = $this->stream->read($length); $remaining = $length - strlen($data); @@ -109,7 +151,11 @@ public function read($length): string } $data .= $remoteData; - $this->stream->write($remoteData); + + // A short cache write would silently corrupt later replays, so fail loudly. + if ($this->stream->write($remoteData) !== strlen($remoteData)) { + throw new \RuntimeException('Unable to cache the entire read from the remote stream'); + } } return $data; @@ -117,6 +163,15 @@ public function read($length): string public function write($string): int { + if (!\is_string($string)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.', + \get_debug_type($string) + ); + } + // When appending to the end of the currently read stream, you'll want // to skip bytes from being read from the remote stream to emulate // other stream wrappers. Basically replacing bytes of data of a fixed @@ -134,6 +189,23 @@ public function eof(): bool return $this->stream->eof() && $this->remoteStream->eof(); } + public function detach() + { + if ($this->detached) { + return null; + } + + $position = $this->tell(); + + $this->cacheEntireStream(); + $this->stream->seek($position); + + $resource = $this->stream->detach(); + $this->detached = true; + + return $resource; + } + /** * Close both the remote stream and buffer stream */ @@ -141,6 +213,7 @@ public function close(): void { $this->remoteStream->close(); $this->stream->close(); + $this->detached = true; } private function cacheEntireStream(): int diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/DroppingStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/DroppingStream.php index 3ecfa5e9..75bcdfb9 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/DroppingStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/DroppingStream.php @@ -32,6 +32,15 @@ public function __construct(StreamInterface $stream, int $maxLength) public function write($string): int { + if (!\is_string($string)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.', + \get_debug_type($string) + ); + } + $diff = $this->maxLength - $this->stream->getSize(); // Begin returning 0 when the underlying stream is too large. diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/FnStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/FnStream.php index 5f6371af..9876e125 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/FnStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/FnStream.php @@ -7,7 +7,7 @@ use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\StreamInterface; /** - * Compose stream implementations based on a hash of functions. + * Compose stream implementations based on a hash of callables. * * Allows for easy testing and extension of a provided stream without needing * to create a concrete class for a simple extension point. @@ -31,7 +31,7 @@ public function __construct(array $methods) { $this->methods = $methods; - // Create the functions on the class + // Create the callables on the class foreach ($methods as $name => $fn) { $this->{'_fn_'.$name} = $fn; } @@ -54,7 +54,7 @@ public function __get(string $name): void public function __destruct() { if (isset($this->_fn_close)) { - call_user_func($this->_fn_close); + ($this->_fn_close)(); } } @@ -73,7 +73,7 @@ public function __wakeup(): void * specific method calls. * * @param StreamInterface $stream Stream to decorate - * @param array $methods Hash of method name to a closure + * @param array $methods Hash of method name to a callable * * @return FnStream */ @@ -93,7 +93,8 @@ public static function decorate(StreamInterface $stream, array $methods) public function __toString(): string { try { - return call_user_func($this->_fn___toString); + /** @var string */ + return ($this->_fn___toString)(); } catch (\Throwable $e) { if (\PHP_VERSION_ID >= 70400) { throw $e; @@ -106,67 +107,103 @@ public function __toString(): string public function close(): void { - call_user_func($this->_fn_close); + ($this->_fn_close)(); } public function detach() { - return call_user_func($this->_fn_detach); + return ($this->_fn_detach)(); } public function getSize(): ?int { - return call_user_func($this->_fn_getSize); + return ($this->_fn_getSize)(); } public function tell(): int { - return call_user_func($this->_fn_tell); + return ($this->_fn_tell)(); } public function eof(): bool { - return call_user_func($this->_fn_eof); + return ($this->_fn_eof)(); } public function isSeekable(): bool { - return call_user_func($this->_fn_isSeekable); + return ($this->_fn_isSeekable)(); } public function rewind(): void { - call_user_func($this->_fn_rewind); + ($this->_fn_rewind)(); } public function seek($offset, $whence = SEEK_SET): void { - call_user_func($this->_fn_seek, $offset, $whence); + if (!\is_int($offset)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.', + \get_debug_type($offset) + ); + } + + if (!\is_int($whence)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.', + \get_debug_type($whence) + ); + } + + ($this->_fn_seek)($offset, $whence); } public function isWritable(): bool { - return call_user_func($this->_fn_isWritable); + return ($this->_fn_isWritable)(); } public function write($string): int { - return call_user_func($this->_fn_write, $string); + if (!\is_string($string)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.', + \get_debug_type($string) + ); + } + + return ($this->_fn_write)($string); } public function isReadable(): bool { - return call_user_func($this->_fn_isReadable); + return ($this->_fn_isReadable)(); } public function read($length): string { - return call_user_func($this->_fn_read, $length); + if (!\is_int($length)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.', + \get_debug_type($length) + ); + } + + return ($this->_fn_read)($length); } public function getContents(): string { - return call_user_func($this->_fn_getContents); + return ($this->_fn_getContents)(); } /** @@ -174,6 +211,15 @@ public function getContents(): string */ public function getMetadata($key = null) { - return call_user_func($this->_fn_getMetadata, $key); + if ($key !== null && !\is_string($key)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.', + \get_debug_type($key) + ); + } + + return ($this->_fn_getMetadata)($key); } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/Header.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/Header.php index 6157b8c6..e7b94fcc 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/Header.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/Header.php @@ -22,7 +22,7 @@ public static function parse($header): array foreach ((array) $header as $value) { foreach (self::splitList($value) as $val) { $part = []; - foreach (preg_split('/;(?=([^"]*"[^"]*")*[^"]*$)/', $val) as $kvp) { + foreach (self::splitParameters($val) as $kvp) { if (preg_match_all('/<[^>]+>|[^=]+/', $kvp, $matches)) { $m = $matches[0]; if (isset($m[1])) { @@ -41,6 +41,50 @@ public static function parse($header): array return $params; } + /** + * Split a header value into semicolon-separated parameters. + * + * @return string[] + */ + private static function splitParameters(string $value): array + { + $values = []; + $start = 0; + $isQuoted = false; + $isEscaped = false; + + for ($i = 0, $max = \strlen($value); $i < $max; ++$i) { + $char = $value[$i]; + + if ($isEscaped) { + $isEscaped = false; + + continue; + } + + if ($isQuoted && $char === '\\') { + $isEscaped = true; + + continue; + } + + if ($char === '"') { + $isQuoted = !$isQuoted; + + continue; + } + + if (!$isQuoted && $char === ';') { + $values[] = \substr($value, $start, $i - $start); + $start = $i + 1; + } + } + + $values[] = \substr($value, $start); + + return $values; + } + /** * Converts an array of header values that may contain comma separated * headers into an array of headers with no comma separated values. @@ -51,6 +95,8 @@ public static function parse($header): array */ public static function normalize($header): array { + \trigger_deprecation('guzzlehttp/psr7', '2.3', 'Header::normalize() is deprecated and will be removed in guzzlehttp/psr7 3.0. Use Header::splitList() instead.'); + $result = []; foreach ((array) $header as $value) { foreach (self::splitList($value) as $parsed) { @@ -98,7 +144,7 @@ public static function splitList($values): array } if (!$isQuoted && $value[$i] === ',') { - $v = \trim($v); + $v = \trim($v, " \n\r\t\0\x0B"); if ($v !== '') { $result[] = $v; } @@ -123,7 +169,7 @@ public static function splitList($values): array $v .= $value[$i]; } - $v = \trim($v); + $v = \trim($v, " \n\r\t\0\x0B"); if ($v !== '') { $result[] = $v; } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/HttpFactory.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/HttpFactory.php index e09be839..99e87fe9 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/HttpFactory.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/HttpFactory.php @@ -27,10 +27,10 @@ final class HttpFactory implements RequestFactoryInterface, ResponseFactoryInter { public function createUploadedFile( StreamInterface $stream, - ?int $size = null, + ?int $size = null, int $error = \UPLOAD_ERR_OK, - ?string $clientFilename = null, - ?string $clientMediaType = null + ?string $clientFilename = null, + ?string $clientMediaType = null ): UploadedFileInterface { if ($size === null) { $size = $stream->getSize(); diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/InflateStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/InflateStream.php index 170caaa1..1330f711 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/InflateStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/InflateStream.php @@ -13,9 +13,9 @@ * then appends the zlib.inflate filter. The stream is then converted back * to a Guzzle stream resource to be used as a Guzzle stream. * - * @see http://tools.ietf.org/html/rfc1950 - * @see http://tools.ietf.org/html/rfc1952 - * @see http://php.net/manual/en/filters.compression.php + * @see https://datatracker.ietf.org/doc/html/rfc1950 + * @see https://datatracker.ietf.org/doc/html/rfc1952 + * @see https://www.php.net/manual/en/filters.compression.php */ final class InflateStream implements StreamInterface { @@ -28,7 +28,7 @@ public function __construct(StreamInterface $stream) { $resource = StreamWrapper::getResource($stream); // Specify window=15+32, so zlib will use header detection to both gzip (with header) and zlib data - // See http://www.zlib.net/manual.html#Advanced definition of inflateInit2 + // See https://www.zlib.net/manual.html#Advanced definition of inflateInit2 // "Add 32 to windowBits to enable zlib and gzip decoding with automatic header detection" // Default window size is 15. stream_filter_append($resource, 'zlib.inflate', STREAM_FILTER_READ, ['window' => 15 + 32]); diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/LimitStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/LimitStream.php index 43b6bb1d..30a111f8 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/LimitStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/LimitStream.php @@ -61,11 +61,15 @@ public function getSize(): ?int { if (null === ($length = $this->stream->getSize())) { return null; - } elseif ($this->limit === -1) { - return $length - $this->offset; - } else { - return min($this->limit, $length - $this->offset); } + + $size = $length - $this->offset; + + if ($this->limit !== -1) { + $size = min($this->limit, $size); + } + + return max(0, $size); } /** @@ -73,6 +77,24 @@ public function getSize(): ?int */ public function seek($offset, $whence = SEEK_SET): void { + if (!\is_int($offset)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.', + \get_debug_type($offset) + ); + } + + if (!\is_int($whence)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.', + \get_debug_type($whence) + ); + } + if ($whence !== SEEK_SET || $offset < 0) { throw new \RuntimeException(sprintf( 'Cannot seek to offset %s with whence %s', @@ -139,6 +161,15 @@ public function setLimit(int $limit): void public function read($length): string { + if (!\is_int($length)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.', + \get_debug_type($length) + ); + } + if ($this->limit === -1) { return $this->stream->read($length); } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/Message.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/Message.php index 4cc9a376..3071da4f 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/Message.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/Message.php @@ -19,7 +19,7 @@ public static function toString(MessageInterface $message): string { if ($message instanceof RequestInterface) { $msg = trim($message->getMethod().' ' - .$message->getRequestTarget()) + .$message->getRequestTarget(), " \n\r\t\0\x0B") .' HTTP/'.$message->getProtocolVersion(); if (!$message->hasHeader('host')) { $msg .= "\r\nHost: ".$message->getUri()->getHost(); @@ -33,7 +33,7 @@ public static function toString(MessageInterface $message): string } foreach ($message->getHeaders() as $name => $values) { - if (is_string($name) && strtolower($name) === 'set-cookie') { + if (is_string($name) && Utils::asciiToLower($name) === 'set-cookie') { foreach ($values as $value) { $msg .= "\r\n{$name}: ".$value; } @@ -69,12 +69,17 @@ public static function bodySummary(MessageInterface $message, int $truncateAt = $body->rewind(); $summary = $body->read($truncateAt); - $body->rewind(); if ($size > $truncateAt) { + if (preg_match('//u', $summary) !== 1) { + $summary = self::trimTrailingIncompleteUtf8Character($summary, $body->read(3)); + } + $summary .= ' (truncated...)'; } + $body->rewind(); + // Matches any printable character, including unicode characters: // letters, marks, numbers, punctuation, spacing, and separators. if (preg_match('/[^\pL\pM\pN\pP\pS\pZ\n\r\t]/u', $summary) !== 0) { @@ -84,6 +89,60 @@ public static function bodySummary(MessageInterface $message, int $truncateAt = return $summary; } + /** + * Trims a partial UTF-8 character from the end of a truncated string. + */ + private static function trimTrailingIncompleteUtf8Character(string $summary, string $lookahead): string + { + $length = strlen($summary); + + if ($length === 0) { + return $summary; + } + + $start = $length - 1; + + while ($start >= 0) { + $byte = ord($summary[$start]); + + if ($byte < 0x80 || $byte > 0xBF) { + break; + } + + --$start; + } + + if ($start < 0) { + return $summary; + } + + $lead = ord($summary[$start]); + + if ($lead >= 0xC2 && $lead <= 0xDF) { + $expectedLength = 2; + } elseif ($lead >= 0xE0 && $lead <= 0xEF) { + $expectedLength = 3; + } elseif ($lead >= 0xF0 && $lead <= 0xF4) { + $expectedLength = 4; + } else { + return $summary; + } + + $availableLength = $length - $start; + + if ($availableLength >= $expectedLength) { + return $summary; + } + + $sequence = substr($summary, $start).substr($lookahead, 0, $expectedLength - $availableLength); + + if (strlen($sequence) !== $expectedLength || preg_match('//u', $sequence) !== 1) { + return $summary; + } + + return substr($summary, 0, $start); + } + /** * Attempts to rewind a message body and throws an exception on failure. * @@ -122,7 +181,11 @@ public static function parseMessage(string $message): array $messageParts = preg_split("/\r?\n\r?\n/", $message, 2); - if ($messageParts === false || count($messageParts) !== 2) { + if ($messageParts === false) { + throw new \RuntimeException('Unable to split HTTP message: '.preg_last_error_msg()); + } + + if (count($messageParts) !== 2) { throw new \InvalidArgumentException('Invalid message: Missing header delimiter'); } @@ -130,24 +193,48 @@ public static function parseMessage(string $message): array $rawHeaders .= "\r\n"; // Put back the delimiter we split previously $headerParts = preg_split("/\r?\n/", $rawHeaders, 2); - if ($headerParts === false || count($headerParts) !== 2) { + if ($headerParts === false) { + throw new \RuntimeException('Unable to split HTTP message headers: '.preg_last_error_msg()); + } + + if (count($headerParts) !== 2) { throw new \InvalidArgumentException('Invalid message: Missing status line'); } [$startLine, $rawHeaders] = $headerParts; - if (preg_match("/(?:^HTTP\/|^[A-Z]+ \S+ HTTP\/)(\d+(?:\.\d+)?)/i", $startLine, $matches) && $matches[1] === '1.0') { + $versionMatch = preg_match("/(?:^HTTP\/|^[A-Z]+ \S+ HTTP\/)(\d+(?:\.\d+)?)/i", $startLine, $matches); + + if ($versionMatch === false) { + throw new \RuntimeException('Unable to parse HTTP start line: '.preg_last_error_msg()); + } + + if ($versionMatch === 1 && $matches[1] === '1.0') { // Header folding is deprecated for HTTP/1.1, but allowed in HTTP/1.0 $rawHeaders = preg_replace(Rfc7230::HEADER_FOLD_REGEX, ' ', $rawHeaders); + + if ($rawHeaders === null) { + throw new \RuntimeException('Unable to unfold HTTP headers: '.preg_last_error_msg()); + } } /** @var array[] $headerLines */ $count = preg_match_all(Rfc7230::HEADER_REGEX, $rawHeaders, $headerLines, PREG_SET_ORDER); + if ($count === false) { + throw new \RuntimeException('Unable to parse HTTP headers: '.preg_last_error_msg()); + } + // If these aren't the same, then one line didn't match and there's an invalid header. if ($count !== substr_count($rawHeaders, "\n")) { - // Folding is deprecated, see https://tools.ietf.org/html/rfc7230#section-3.2.4 - if (preg_match(Rfc7230::HEADER_FOLD_REGEX, $rawHeaders)) { + // Folding is deprecated, see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4 + $hasFoldedHeader = preg_match(Rfc7230::HEADER_FOLD_REGEX, $rawHeaders); + + if ($hasFoldedHeader === false) { + throw new \RuntimeException('Unable to inspect HTTP header folding: '.preg_last_error_msg()); + } + + if ($hasFoldedHeader === 1) { throw new \InvalidArgumentException('Invalid header syntax: Obsolete line folding'); } @@ -174,23 +261,52 @@ public static function parseMessage(string $message): array * @param array $headers Array of headers (each value an array). */ public static function parseRequestUri(string $path, array $headers): string + { + $host = self::getHostFromHeaders($headers); + + // If no host is found, then a full URI cannot be constructed. + // Collapse leading slashes so an origin-form target cannot be + // parsed as a network-path reference with its own authority. + if ($host === null) { + return self::normalizePathForOriginForm($path); + } + + $scheme = substr($host, -4) === ':443' ? 'https' : 'http'; + + return $scheme.'://'.$host.'/'.ltrim($path, '/'); + } + + private static function normalizePathForOriginForm(string $path): string + { + if (0 === strpos($path, '//')) { + return '/'.ltrim($path, '/'); + } + + return $path; + } + + /** + * @param array $headers Array of headers (each value an array). + */ + private static function getHostFromHeaders(array $headers): ?string { $hostKey = array_filter(array_keys($headers), function ($k) { // Numeric array keys are converted to int by PHP. $k = (string) $k; - return strtolower($k) === 'host'; + return Utils::asciiToLower($k) === 'host'; }); - // If no host is found, then a full URI cannot be constructed. if (!$hostKey) { - return $path; + return null; } $host = $headers[reset($hostKey)][0]; - $scheme = substr($host, -4) === ':443' ? 'https' : 'http'; + if (!is_string($host) || Rfc7230::parseHostHeader($host) === null) { + throw new \InvalidArgumentException('Invalid request string'); + } - return $scheme.'://'.$host.'/'.ltrim($path, '/'); + return $host; } /** @@ -201,8 +317,18 @@ public static function parseRequestUri(string $path, array $headers): string public static function parseRequest(string $message): RequestInterface { $data = self::parseMessage($message); + if (strpbrk($data['start-line'], "\r\n") !== false) { + throw new \InvalidArgumentException('Invalid request string'); + } + $matches = []; - if (!preg_match('/^[\S]+\s+([a-zA-Z]+:\/\/|\/).*/', $data['start-line'], $matches)) { + $requestStartLineMatch = preg_match('/^[\S]+\s+([a-zA-Z]+:\/\/|\/).*/', $data['start-line'], $matches); + + if ($requestStartLineMatch === false) { + throw new \RuntimeException('Unable to parse request start line: '.preg_last_error_msg()); + } + + if ($requestStartLineMatch === 0) { throw new \InvalidArgumentException('Invalid request string'); } $parts = explode(' ', $data['start-line'], 3); @@ -227,10 +353,20 @@ public static function parseRequest(string $message): RequestInterface public static function parseResponse(string $message): ResponseInterface { $data = self::parseMessage($message); - // According to https://tools.ietf.org/html/rfc7230#section-3.1.2 the space - // between status-code and reason-phrase is required. But browsers accept - // responses without space and reason as well. - if (!preg_match('/^HTTP\/.* [0-9]{3}( .*|$)/', $data['start-line'])) { + if (strpbrk($data['start-line'], "\r\n") !== false) { + throw new \InvalidArgumentException('Invalid response string'); + } + + // According to https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2 + // the space between status-code and reason-phrase is required. But + // browsers accept responses without space and reason as well. + $responseStartLineMatch = preg_match('/^HTTP\/.* [0-9]{3}( .*|$)/D', $data['start-line']); + + if ($responseStartLineMatch === false) { + throw new \RuntimeException('Unable to parse response start line: '.preg_last_error_msg()); + } + + if ($responseStartLineMatch === 0) { throw new \InvalidArgumentException('Invalid response string: '.$data['start-line']); } $parts = explode(' ', $data['start-line'], 3); diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/MessageTrait.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/MessageTrait.php index 4fc999ec..615faf1f 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/MessageTrait.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/MessageTrait.php @@ -29,8 +29,22 @@ public function getProtocolVersion(): string return $this->protocol; } + /** + * @return static + */ public function withProtocolVersion($version): MessageInterface { + if (!\is_string($version)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to MessageInterface::withProtocolVersion() is deprecated; guzzlehttp/psr7 3.0 requires string.', + \get_debug_type($version) + ); + } + + $this->assertProtocolVersion($version); + if ($this->protocol === $version) { return $this; } @@ -48,12 +62,12 @@ public function getHeaders(): array public function hasHeader($header): bool { - return isset($this->headerNames[strtolower($header)]); + return isset($this->headerNames[Utils::asciiToLower($header)]); } public function getHeader($header): array { - $header = strtolower($header); + $header = Utils::asciiToLower($header); if (!isset($this->headerNames[$header])) { return []; @@ -69,11 +83,27 @@ public function getHeaderLine($header): string return implode(', ', $this->getHeader($header)); } + /** + * @return static + */ public function withHeader($header, $value): MessageInterface { $this->assertHeader($header); + $values = \is_array($value) ? $value : [$value]; + foreach ($values as $item) { + if (!\is_string($item) && (\is_scalar($item) || $item === null)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to MessageInterface::withHeader() is deprecated; guzzlehttp/psr7 3.0 requires string|string[].', + \get_debug_type($item) + ); + + break; + } + } $value = $this->normalizeHeaderValue($value); - $normalized = strtolower($header); + $normalized = Utils::asciiToLower($header); $new = clone $this; if (isset($new->headerNames[$normalized])) { @@ -85,11 +115,27 @@ public function withHeader($header, $value): MessageInterface return $new; } + /** + * @return static + */ public function withAddedHeader($header, $value): MessageInterface { $this->assertHeader($header); + $values = \is_array($value) ? $value : [$value]; + foreach ($values as $item) { + if (!\is_string($item) && (\is_scalar($item) || $item === null)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to MessageInterface::withAddedHeader() is deprecated; guzzlehttp/psr7 3.0 requires string|string[].', + \get_debug_type($item) + ); + + break; + } + } $value = $this->normalizeHeaderValue($value); - $normalized = strtolower($header); + $normalized = Utils::asciiToLower($header); $new = clone $this; if (isset($new->headerNames[$normalized])) { @@ -103,9 +149,12 @@ public function withAddedHeader($header, $value): MessageInterface return $new; } + /** + * @return static + */ public function withoutHeader($header): MessageInterface { - $normalized = strtolower($header); + $normalized = Utils::asciiToLower($header); if (!isset($this->headerNames[$normalized])) { return $this; @@ -128,6 +177,9 @@ public function getBody(): StreamInterface return $this->stream; } + /** + * @return static + */ public function withBody(StreamInterface $body): MessageInterface { if ($body === $this->stream) { @@ -141,7 +193,7 @@ public function withBody(StreamInterface $body): MessageInterface } /** - * @param array $headers + * @param (string|string[])[] $headers */ private function setHeaders(array $headers): void { @@ -151,8 +203,22 @@ private function setHeaders(array $headers): void $header = (string) $header; $this->assertHeader($header); + $values = \is_array($value) ? $value : [$value]; + foreach ($values as $item) { + if (!\is_string($item) && (\is_scalar($item) || $item === null)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to %s::__construct() is deprecated; guzzlehttp/psr7 3.0 requires string|string[].', + \get_debug_type($item), + static::class + ); + + break; + } + } $value = $this->normalizeHeaderValue($value); - $normalized = strtolower($header); + $normalized = Utils::asciiToLower($header); if (isset($this->headerNames[$normalized])) { $header = $this->headerNames[$normalized]; $this->headers[$header] = array_merge($this->headers[$header], $value); @@ -170,12 +236,16 @@ private function setHeaders(array $headers): void */ private function normalizeHeaderValue($value): array { - if (!is_array($value)) { - return $this->trimAndValidateHeaderValues([$value]); + if (is_array($value) && $value === []) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing an empty array as a header value is deprecated; guzzlehttp/psr7 3.0 rejects empty header value arrays.' + ); } - if (count($value) === 0) { - throw new \InvalidArgumentException('Header value can not be an empty array.'); + if (!is_array($value)) { + return $this->trimAndValidateHeaderValues([$value]); } return $this->trimAndValidateHeaderValues($value); @@ -193,7 +263,7 @@ private function normalizeHeaderValue($value): array * * @return string[] Trimmed header values * - * @see https://tools.ietf.org/html/rfc7230#section-3.2.4 + * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4 */ private function trimAndValidateHeaderValues(array $values): array { @@ -205,6 +275,12 @@ private function trimAndValidateHeaderValues(array $values): array )); } + // Convert non-finite floats explicitly, as implicit coercion of + // NAN emits a warning on PHP 8.5. + if (is_float($value) && !is_finite($value)) { + $value = is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF'); + } + $trimmed = trim((string) $value, " \t"); $this->assertValue($trimmed); @@ -213,7 +289,7 @@ private function trimAndValidateHeaderValues(array $values): array } /** - * @see https://tools.ietf.org/html/rfc7230#section-3.2 + * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2 * * @param mixed $header */ @@ -234,7 +310,24 @@ private function assertHeader($header): void } /** - * @see https://tools.ietf.org/html/rfc7230#section-3.2 + * @param mixed $version + */ + private function assertProtocolVersion($version): void + { + if (is_string($version)) { + $this->assertNoLineSeparators($version, 'Protocol version'); + } + } + + private function assertNoLineSeparators(string $value, string $field): void + { + if (strpbrk($value, "\r\n") !== false) { + throw new \InvalidArgumentException($field.' must not contain CR or LF characters.'); + } + } + + /** + * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2 * * field-value = *( field-content / obs-fold ) * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/MimeType.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/MimeType.php index fc9824a0..a37b6996 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/MimeType.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/MimeType.php @@ -7,22 +7,23 @@ final class MimeType { private const MIME_TYPES = [ + '123' => 'application/vnd.lotus-1-2-3', '1km' => 'application/vnd.1000minds.decision-model+xml', + '210' => 'model/step', '3dml' => 'text/vnd.in3d.3dml', '3ds' => 'image/x-3ds', '3g2' => 'video/3gpp2', - '3gp' => 'video/3gp', + '3gp' => 'video/3gpp', '3gpp' => 'video/3gpp', '3mf' => 'model/3mf', '7z' => 'application/x-7z-compressed', '7zip' => 'application/x-7z-compressed', - '123' => 'application/vnd.lotus-1-2-3', 'aab' => 'application/x-authorware-bin', 'aac' => 'audio/aac', 'aam' => 'application/x-authorware-map', 'aas' => 'application/x-authorware-seg', 'abw' => 'application/x-abiword', - 'ac' => 'application/vnd.nokia.n-gage.ac+xml', + 'ac' => 'application/pkix-attr-cert', 'ac3' => 'audio/ac3', 'acc' => 'application/vnd.americandynamics.acc', 'ace' => 'application/x-ace-compressed', @@ -35,7 +36,7 @@ final class MimeType 'afp' => 'application/vnd.ibm.modcap', 'age' => 'application/vnd.age', 'ahead' => 'application/vnd.ahead.space', - 'ai' => 'application/pdf', + 'ai' => 'application/postscript', 'aif' => 'audio/x-aiff', 'aifc' => 'audio/x-aiff', 'aiff' => 'audio/x-aiff', @@ -55,7 +56,7 @@ final class MimeType 'apr' => 'application/vnd.lotus-approach', 'arc' => 'application/x-freearc', 'arj' => 'application/x-arj', - 'asc' => 'application/pgp-signature', + 'asc' => 'application/pgp-keys', 'asf' => 'video/x-ms-asf', 'asm' => 'text/x-asm', 'aso' => 'application/vnd.accpac.simply.aso', @@ -66,7 +67,7 @@ final class MimeType 'atomdeleted' => 'application/atomdeleted+xml', 'atomsvc' => 'application/atomsvc+xml', 'atx' => 'application/vnd.antix.game-component', - 'au' => 'audio/x-au', + 'au' => 'audio/basic', 'avci' => 'image/avci', 'avcs' => 'image/avcs', 'avi' => 'video/x-msvideo', @@ -77,15 +78,18 @@ final class MimeType 'azv' => 'image/vnd.airzip.accelerator.azv', 'azw' => 'application/vnd.amazon.ebook', 'b16' => 'image/vnd.pco.b16', + 'bary' => 'model/vnd.bary', 'bat' => 'application/x-msdownload', 'bcpio' => 'application/x-bcpio', 'bdf' => 'application/x-font-bdf', 'bdm' => 'application/vnd.syncml.dm+wbxml', - 'bdoc' => 'application/x-bdoc', + 'bdo' => 'application/vnd.nato.bindingdataobject+xml', + 'bdoc' => 'application/bdoc', 'bed' => 'application/vnd.realvnc.bed', 'bh2' => 'application/vnd.fujitsu.oasysprs', 'bin' => 'application/octet-stream', 'blb' => 'application/x-blorb', + 'blend' => 'application/x-blender', 'blorb' => 'application/x-blorb', 'bmi' => 'application/vnd.bmi', 'bmml' => 'application/vnd.balsamiq.bmml+xml', @@ -95,6 +99,8 @@ final class MimeType 'boz' => 'application/x-bzip2', 'bpk' => 'application/octet-stream', 'bpmn' => 'application/octet-stream', + 'brush' => 'application/vnd.procreate.brush', + 'brushset' => 'application/vnd.procreate.brushset', 'bsp' => 'model/vnd.valve.source.compiled-map', 'btf' => 'image/prs.btif', 'btif' => 'image/prs.btif', @@ -102,13 +108,13 @@ final class MimeType 'bz' => 'application/x-bzip', 'bz2' => 'application/x-bzip2', 'c' => 'text/x-c', + 'c11amc' => 'application/vnd.cluetrust.cartomobile-config', + 'c11amz' => 'application/vnd.cluetrust.cartomobile-config-pkg', 'c4d' => 'application/vnd.clonk.c4group', 'c4f' => 'application/vnd.clonk.c4group', 'c4g' => 'application/vnd.clonk.c4group', 'c4p' => 'application/vnd.clonk.c4group', 'c4u' => 'application/vnd.clonk.c4group', - 'c11amc' => 'application/vnd.cluetrust.cartomobile-config', - 'c11amz' => 'application/vnd.cluetrust.cartomobile-config-pkg', 'cab' => 'application/vnd.ms-cab-compressed', 'caf' => 'audio/x-caf', 'cap' => 'application/vnd.tcpdump.pcap', @@ -132,7 +138,6 @@ final class MimeType 'cdmid' => 'application/cdmi-domain', 'cdmio' => 'application/cdmi-object', 'cdmiq' => 'application/cdmi-queue', - 'cdr' => 'application/cdr', 'cdx' => 'chemical/x-cdx', 'cdxml' => 'application/vnd.chemdraw+xml', 'cdy' => 'application/vnd.cinderella', @@ -147,7 +152,7 @@ final class MimeType 'cil' => 'application/vnd.ms-artgalry', 'cjs' => 'application/node', 'cla' => 'application/vnd.claymore', - 'class' => 'application/octet-stream', + 'class' => 'application/java-vm', 'cld' => 'model/vnd.cld', 'clkk' => 'application/vnd.crick.clicker.keyboard', 'clkp' => 'application/vnd.crick.clicker.palette', @@ -194,6 +199,8 @@ final class MimeType 'davmount' => 'application/davmount+xml', 'dbf' => 'application/vnd.dbf', 'dbk' => 'application/docbook+xml', + 'dcm' => 'application/dicom', + 'dcmp' => 'application/vnd.dcmp+xml', 'dcr' => 'application/x-director', 'dcurl' => 'text/vnd.curl.dcurl', 'dd2' => 'application/vnd.oma.dd2+xml', @@ -221,19 +228,22 @@ final class MimeType 'dmp' => 'application/vnd.tcpdump.pcap', 'dms' => 'application/octet-stream', 'dna' => 'application/vnd.dna', + 'dng' => 'image/x-adobe-dng', 'doc' => 'application/msword', - 'docm' => 'application/vnd.ms-word.template.macroEnabled.12', + 'docm' => 'application/vnd.ms-word.document.macroenabled.12', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'dot' => 'application/msword', - 'dotm' => 'application/vnd.ms-word.template.macroEnabled.12', + 'dotm' => 'application/vnd.ms-word.template.macroenabled.12', 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', 'dp' => 'application/vnd.osgi.dp', 'dpg' => 'application/vnd.dpgraph', 'dpx' => 'image/dpx', 'dra' => 'audio/vnd.dra', 'drle' => 'image/dicom-rle', + 'drm' => 'application/vnd.procreate.dream', 'dsc' => 'text/prs.lines.tag', 'dssc' => 'application/dssc+der', + 'dst' => 'application/octet-stream', 'dtb' => 'application/x-dtbook+xml', 'dtd' => 'application/xml-dtd', 'dts' => 'audio/vnd.dts', @@ -285,10 +295,12 @@ final class MimeType 'f4v' => 'video/mp4', 'f77' => 'text/x-fortran', 'f90' => 'text/x-fortran', + 'facti' => 'image/vnd.blockfact.facti', 'fbs' => 'image/vnd.fastbidsheet', + 'fbx' => 'application/vnd.autodesk.fbx', 'fcdt' => 'application/vnd.adobe.formscentral.fcdt', 'fcs' => 'application/vnd.isac.fcs', - 'fdf' => 'application/vnd.fdf', + 'fdf' => 'application/fdf', 'fdt' => 'application/fdt+xml', 'fe_launch' => 'application/vnd.denovo.fcselayout-link', 'fg5' => 'application/vnd.fujitsu.oasysgp', @@ -330,21 +342,25 @@ final class MimeType 'gca' => 'application/x-gca-compressed', 'gdl' => 'model/vnd.gdl', 'gdoc' => 'application/vnd.google-apps.document', + 'gdraw' => 'application/vnd.google-apps.drawing', 'ged' => 'text/vnd.familysearch.gedcom', 'geo' => 'application/vnd.dynageo', 'geojson' => 'application/geo+json', 'gex' => 'application/vnd.geometry-explorer', + 'gform' => 'application/vnd.google-apps.form', 'ggb' => 'application/vnd.geogebra.file', + 'ggs' => 'application/vnd.geogebra.slides', 'ggt' => 'application/vnd.geogebra.tool', 'ghf' => 'application/vnd.groove-help', 'gif' => 'image/gif', 'gim' => 'application/vnd.groove-identity-message', + 'gjam' => 'application/vnd.google-apps.jam', 'glb' => 'model/gltf-binary', 'gltf' => 'model/gltf+json', + 'gmap' => 'application/vnd.google-apps.map', 'gml' => 'application/gml+xml', 'gmx' => 'application/vnd.gmx', 'gnumeric' => 'application/x-gnumeric', - 'gpg' => 'application/gpg-keys', 'gph' => 'application/vnd.flographit', 'gpx' => 'application/gpx+xml', 'gqf' => 'application/vnd.grafeq', @@ -354,8 +370,10 @@ final class MimeType 'gre' => 'application/vnd.geometry-explorer', 'grv' => 'application/vnd.groove-injector', 'grxml' => 'application/srgs+xml', + 'gscript' => 'application/vnd.google-apps.script', 'gsf' => 'application/x-font-ghostscript', 'gsheet' => 'application/vnd.google-apps.spreadsheet', + 'gsite' => 'application/vnd.google-apps.site', 'gslides' => 'application/vnd.google-apps.presentation', 'gtar' => 'application/x-gtar', 'gtm' => 'application/vnd.groove-tool-message', @@ -387,7 +405,6 @@ final class MimeType 'hpid' => 'application/vnd.hp-hpid', 'hps' => 'application/vnd.hp-hps', 'hqx' => 'application/mac-binhex40', - 'hsj2' => 'image/hsj2', 'htc' => 'text/x-component', 'htke' => 'application/vnd.kenameaapp', 'htm' => 'text/html', @@ -399,7 +416,7 @@ final class MimeType 'icc' => 'application/vnd.iccprofile', 'ice' => 'x-conference/x-cooltalk', 'icm' => 'application/vnd.iccprofile', - 'ico' => 'image/x-icon', + 'ico' => 'image/vnd.microsoft.icon', 'ics' => 'text/calendar', 'ief' => 'image/ief', 'ifb' => 'text/calendar', @@ -414,6 +431,7 @@ final class MimeType 'imp' => 'application/vnd.accpac.simply.imp', 'ims' => 'application/vnd.ms-ims', 'in' => 'text/plain', + 'indd' => 'application/x-indesign', 'ini' => 'text/plain', 'ink' => 'application/inkml+xml', 'inkml' => 'application/inkml+xml', @@ -421,6 +439,7 @@ final class MimeType 'iota' => 'application/vnd.astraea-software.iota', 'ipfix' => 'application/ipfix', 'ipk' => 'application/vnd.shana.informed.package', + 'ipynb' => 'application/x-ipynb+json', 'irm' => 'application/vnd.ibm.rights-management', 'irp' => 'application/vnd.irepository.package+xml', 'iso' => 'application/x-iso9660-image', @@ -430,10 +449,13 @@ final class MimeType 'ivu' => 'application/vnd.immervision-ivu', 'jad' => 'text/vnd.sun.j2me.app-descriptor', 'jade' => 'text/jade', + 'jaii' => 'image/jaii', + 'jais' => 'image/jais', 'jam' => 'application/vnd.jam', 'jar' => 'application/java-archive', 'jardiff' => 'application/x-java-archive-diff', 'java' => 'text/x-java-source', + 'jfif' => 'image/jpeg', 'jhc' => 'image/jphc', 'jisp' => 'application/vnd.jisp', 'jls' => 'image/jls', @@ -447,18 +469,19 @@ final class MimeType 'jpf' => 'image/jpx', 'jpg' => 'image/jpeg', 'jpg2' => 'image/jp2', - 'jpgm' => 'video/jpm', + 'jpgm' => 'image/jpm', 'jpgv' => 'video/jpeg', 'jph' => 'image/jph', - 'jpm' => 'video/jpm', + 'jpm' => 'image/jpm', 'jpx' => 'image/jpx', - 'js' => 'application/javascript', + 'js' => 'text/javascript', 'json' => 'application/json', 'json5' => 'application/json5', 'jsonld' => 'application/ld+json', 'jsonml' => 'application/jsonml+json', 'jsx' => 'text/jsx', 'jt' => 'model/jt', + 'jxl' => 'image/jxl', 'jxr' => 'image/jxr', 'jxra' => 'image/jxra', 'jxrs' => 'image/jxrs', @@ -468,9 +491,10 @@ final class MimeType 'jxss' => 'image/jxss', 'kar' => 'audio/midi', 'karbon' => 'application/vnd.kde.karbon', + 'kbl' => 'application/kbl+xml', 'kdb' => 'application/octet-stream', 'kdbx' => 'application/x-keepass2', - 'key' => 'application/x-iwork-keynote-sffkey', + 'key' => 'application/vnd.apple.keynote', 'kfo' => 'application/vnd.kde.kformula', 'kia' => 'application/vnd.kidspiration', 'kml' => 'application/vnd.google-earth.kml+xml', @@ -495,7 +519,7 @@ final class MimeType 'les' => 'application/vnd.hhe.lesson-player', 'less' => 'text/less', 'lgr' => 'application/lgr+xml', - 'lha' => 'application/octet-stream', + 'lha' => 'application/x-lzh-compressed', 'link66' => 'application/vnd.route66.link66+xml', 'list' => 'text/plain', 'list3820' => 'application/vnd.ibm.modcap', @@ -504,6 +528,7 @@ final class MimeType 'lnk' => 'application/x-ms-shortcut', 'log' => 'text/plain', 'lostxml' => 'application/lost+xml', + 'lottie' => 'application/zip+dotlottie', 'lrf' => 'application/octet-stream', 'lrm' => 'application/vnd.ms-lrm', 'ltf' => 'application/vnd.frogans.ltf', @@ -511,21 +536,24 @@ final class MimeType 'luac' => 'application/x-lua-bytecode', 'lvp' => 'audio/vnd.lucent.voice', 'lwp' => 'application/vnd.lotus-wordpro', - 'lzh' => 'application/octet-stream', + 'lzh' => 'application/x-lzh-compressed', + 'm13' => 'application/x-msmediaview', + 'm14' => 'application/x-msmediaview', 'm1v' => 'video/mpeg', + 'm21' => 'application/mp21', 'm2a' => 'audio/mpeg', + 'm2t' => 'video/mp2t', + 'm2ts' => 'video/mp2t', 'm2v' => 'video/mpeg', 'm3a' => 'audio/mpeg', - 'm3u' => 'text/plain', + 'm3u' => 'audio/x-mpegurl', 'm3u8' => 'application/vnd.apple.mpegurl', - 'm4a' => 'audio/x-m4a', + 'm4a' => 'audio/mp4', + 'm4b' => 'audio/mp4', 'm4p' => 'application/mp4', 'm4s' => 'video/iso.segment', - 'm4u' => 'application/vnd.mpegurl', + 'm4u' => 'video/vnd.mpegurl', 'm4v' => 'video/x-m4v', - 'm13' => 'application/x-msmediaview', - 'm14' => 'application/x-msmediaview', - 'm21' => 'application/mp21', 'ma' => 'application/mathematica', 'mads' => 'application/mads+xml', 'maei' => 'application/mmt-aei+xml', @@ -556,6 +584,8 @@ final class MimeType 'mft' => 'application/rpki-manifest', 'mgp' => 'application/vnd.osgeo.mapguide.package', 'mgz' => 'application/vnd.proteus.magazine', + 'mht' => 'message/rfc822', + 'mhtml' => 'message/rfc822', 'mid' => 'audio/midi', 'midi' => 'audio/midi', 'mie' => 'application/x-mie', @@ -564,11 +594,11 @@ final class MimeType 'mj2' => 'video/mj2', 'mjp2' => 'video/mj2', 'mjs' => 'text/javascript', - 'mk3d' => 'video/x-matroska', - 'mka' => 'audio/x-matroska', + 'mk3d' => 'video/matroska-3d', + 'mka' => 'audio/matroska', 'mkd' => 'text/x-markdown', 'mks' => 'video/x-matroska', - 'mkv' => 'video/x-matroska', + 'mkv' => 'video/matroska', 'mlp' => 'application/vnd.dolby.mlp', 'mmd' => 'application/vnd.chipnuts.karaoke-mmd', 'mmf' => 'application/vnd.smaf', @@ -581,13 +611,13 @@ final class MimeType 'mov' => 'video/quicktime', 'movie' => 'video/x-sgi-movie', 'mp2' => 'audio/mpeg', + 'mp21' => 'application/mp21', 'mp2a' => 'audio/mpeg', 'mp3' => 'audio/mpeg', 'mp4' => 'video/mp4', 'mp4a' => 'audio/mp4', 'mp4s' => 'application/mp4', 'mp4v' => 'video/mp4', - 'mp21' => 'application/mp21', 'mpc' => 'application/vnd.mophun.certificate', 'mpd' => 'application/dash+xml', 'mpe' => 'video/mpeg', @@ -612,7 +642,7 @@ final class MimeType 'msf' => 'application/vnd.epson.msf', 'msg' => 'application/vnd.ms-outlook', 'msh' => 'model/mesh', - 'msi' => 'application/x-msdownload', + 'msi' => 'application/octet-stream', 'msix' => 'application/msix', 'msixbundle' => 'application/msixbundle', 'msl' => 'application/vnd.mobius.msl', @@ -620,7 +650,7 @@ final class MimeType 'msp' => 'application/octet-stream', 'msty' => 'application/vnd.muvee.style', 'mtl' => 'model/mtl', - 'mts' => 'model/vnd.mts', + 'mts' => 'video/mp2t', 'mus' => 'application/vnd.musician', 'musd' => 'application/mmt-usd+xml', 'musicxml' => 'application/vnd.recordare.musicxml+xml', @@ -639,6 +669,7 @@ final class MimeType 'nbp' => 'application/vnd.wolfram.player', 'nc' => 'application/x-netcdf', 'ncx' => 'application/x-dtbncx+xml', + 'ndjson' => 'application/x-ndjson', 'nfo' => 'text/x-nfo', 'ngdat' => 'application/vnd.nokia.n-gage.data', 'nitf' => 'application/vnd.nitf', @@ -653,7 +684,7 @@ final class MimeType 'nsf' => 'application/vnd.lotus-notes', 'nt' => 'application/n-triples', 'ntf' => 'application/vnd.nitf', - 'numbers' => 'application/x-iwork-numbers-sffnumbers', + 'numbers' => 'application/vnd.apple.numbers', 'nzb' => 'application/x-nzb', 'oa2' => 'application/vnd.fujitsu.oasys2', 'oa3' => 'application/vnd.fujitsu.oasys3', @@ -678,6 +709,8 @@ final class MimeType 'ogv' => 'video/ogg', 'ogx' => 'application/ogg', 'omdoc' => 'application/omdoc+xml', + 'one' => 'application/onenote', + 'onea' => 'application/onenote', 'onepkg' => 'application/onenote', 'onetmp' => 'application/onenote', 'onetoc' => 'application/onenote', @@ -686,7 +719,7 @@ final class MimeType 'opml' => 'text/x-opml', 'oprc' => 'application/vnd.palm', 'opus' => 'audio/ogg', - 'org' => 'text/x-org', + 'org' => 'application/vnd.lotus-organizer', 'osf' => 'application/vnd.yamaha.openscoreformat', 'osfpvg' => 'application/vnd.yamaha.openscoreformat.osfpvg+xml', 'osm' => 'application/vnd.openstreetmap.data+xml', @@ -704,17 +737,20 @@ final class MimeType 'oxps' => 'application/oxps', 'oxt' => 'application/vnd.openofficeorg.extension', 'p' => 'text/x-pascal', + 'p10' => 'application/pkcs10', + 'p12' => 'application/x-pkcs12', + 'p21' => 'model/step', 'p7a' => 'application/x-pkcs7-signature', 'p7b' => 'application/x-pkcs7-certificates', 'p7c' => 'application/pkcs7-mime', + 'p7e' => 'application/pkcs7-mime', 'p7m' => 'application/pkcs7-mime', 'p7r' => 'application/x-pkcs7-certreqresp', 'p7s' => 'application/pkcs7-signature', 'p8' => 'application/pkcs8', - 'p10' => 'application/x-pkcs10', - 'p12' => 'application/x-pkcs12', 'pac' => 'application/x-ns-proxy-autoconfig', - 'pages' => 'application/x-iwork-pages-sffpages', + 'pages' => 'application/vnd.apple.pages', + 'parquet' => 'application/vnd.apache.parquet', 'pas' => 'text/x-pascal', 'paw' => 'application/vnd.pawaafile', 'pbd' => 'application/vnd.powerbuilder6', @@ -725,8 +761,8 @@ final class MimeType 'pclxl' => 'application/vnd.hp-pclxl', 'pct' => 'image/x-pict', 'pcurl' => 'application/vnd.curl.pcurl', - 'pcx' => 'image/x-pcx', - 'pdb' => 'application/x-pilot', + 'pcx' => 'image/vnd.zbrush.pcx', + 'pdb' => 'application/vnd.palm', 'pde' => 'text/x-processing', 'pdf' => 'application/pdf', 'pem' => 'application/x-x509-user-cert', @@ -737,7 +773,7 @@ final class MimeType 'pfx' => 'application/x-pkcs12', 'pgm' => 'image/x-portable-graymap', 'pgn' => 'application/x-chess-pgn', - 'pgp' => 'application/pgp', + 'pgp' => 'application/pgp-encrypted', 'phar' => 'application/octet-stream', 'php' => 'application/x-httpd-php', 'php3' => 'application/x-httpd-php', @@ -760,17 +796,17 @@ final class MimeType 'pnm' => 'image/x-portable-anymap', 'portpkg' => 'application/vnd.macports.portpkg', 'pot' => 'application/vnd.ms-powerpoint', - 'potm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12', + 'potm' => 'application/vnd.ms-powerpoint.template.macroenabled.12', 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template', 'ppa' => 'application/vnd.ms-powerpoint', - 'ppam' => 'application/vnd.ms-powerpoint.addin.macroEnabled.12', + 'ppam' => 'application/vnd.ms-powerpoint.addin.macroenabled.12', 'ppd' => 'application/vnd.cups-ppd', 'ppm' => 'image/x-portable-pixmap', 'pps' => 'application/vnd.ms-powerpoint', - 'ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12', + 'ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroenabled.12', 'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', - 'ppt' => 'application/powerpoint', - 'pptm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptm' => 'application/vnd.ms-powerpoint.presentation.macroenabled.12', 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'pqa' => 'application/vnd.palm', 'prc' => 'model/prc', @@ -779,14 +815,16 @@ final class MimeType 'provx' => 'application/provenance+xml', 'ps' => 'application/postscript', 'psb' => 'application/vnd.3gpp.pic-bw-small', - 'psd' => 'application/x-photoshop', + 'psd' => 'image/vnd.adobe.photoshop', 'psf' => 'application/x-font-linux-psf', 'pskcxml' => 'application/pskc+xml', 'pti' => 'image/prs.pti', 'ptid' => 'application/vnd.pvi.ptid1', 'pub' => 'application/x-mspublisher', + 'pv' => 'application/octet-stream', 'pvb' => 'application/vnd.3gpp.pic-bw-var', 'pwn' => 'application/vnd.3m.post-it-notes', + 'pxf' => 'application/octet-stream', 'pya' => 'audio/vnd.ms-playready.media.pya', 'pyo' => 'model/vnd.pytha.pyox', 'pyox' => 'model/vnd.pytha.pyox', @@ -806,7 +844,7 @@ final class MimeType 'ram' => 'audio/x-pn-realaudio', 'raml' => 'application/raml+yaml', 'rapd' => 'application/route-apd+xml', - 'rar' => 'application/x-rar', + 'rar' => 'application/vnd.rar', 'ras' => 'image/x-cmu-raster', 'rcprofile' => 'application/vnd.ipunplugged.rcprofile', 'rdf' => 'application/rdf+xml', @@ -821,7 +859,7 @@ final class MimeType 'rl' => 'application/resource-lists+xml', 'rlc' => 'image/vnd.fujixerox.edmics-rlc', 'rld' => 'application/resource-lists-diff+xml', - 'rm' => 'audio/x-pn-realaudio', + 'rm' => 'application/vnd.rn-realmedia', 'rmi' => 'audio/midi', 'rmp' => 'audio/x-pn-realaudio-plugin', 'rms' => 'application/vnd.jcp.javame.midlet-rms', @@ -831,7 +869,7 @@ final class MimeType 'roa' => 'application/rpki-roa', 'roff' => 'text/troff', 'rp9' => 'application/vnd.cloanto.rp9', - 'rpm' => 'audio/x-pn-realaudio-plugin', + 'rpm' => 'application/x-redhat-package-manager', 'rpss' => 'application/vnd.nokia.radio-presets', 'rpst' => 'application/vnd.nokia.radio-preset', 'rq' => 'application/sparql-query', @@ -865,7 +903,7 @@ final class MimeType 'sdkm' => 'application/vnd.solent.sdkm+xml', 'sdp' => 'application/sdp', 'sdw' => 'application/vnd.stardivision.writer', - 'sea' => 'application/octet-stream', + 'sea' => 'application/x-sea', 'see' => 'application/vnd.seemail', 'seed' => 'application/vnd.fdsn.seed', 'sema' => 'application/vnd.sema', @@ -910,8 +948,8 @@ final class MimeType 'slt' => 'application/vnd.epson.salt', 'sm' => 'application/vnd.stepmania.stepchart', 'smf' => 'application/vnd.stardivision.math', - 'smi' => 'application/smil', - 'smil' => 'application/smil', + 'smi' => 'application/smil+xml', + 'smil' => 'application/smil+xml', 'smv' => 'video/x-smv', 'smzip' => 'application/vnd.stepmania.package', 'snd' => 'audio/basic', @@ -925,7 +963,9 @@ final class MimeType 'spp' => 'application/scvp-vp-response', 'spq' => 'application/scvp-vp-request', 'spx' => 'audio/ogg', - 'sql' => 'application/x-sql', + 'sql' => 'application/sql', + 'sqlite' => 'application/vnd.sqlite3', + 'sqlite3' => 'application/vnd.sqlite3', 'src' => 'application/x-wais-source', 'srt' => 'application/x-subrip', 'sru' => 'application/sru+xml', @@ -938,12 +978,13 @@ final class MimeType 'st' => 'application/vnd.sailingtracker.track', 'stc' => 'application/vnd.sun.xml.calc.template', 'std' => 'application/vnd.sun.xml.draw.template', - 'step' => 'application/STEP', + 'step' => 'model/step', 'stf' => 'application/vnd.wt.stf', 'sti' => 'application/vnd.sun.xml.impress.template', 'stk' => 'application/hyperstudio', 'stl' => 'model/stl', - 'stp' => 'application/STEP', + 'stp' => 'model/step', + 'stpnc' => 'model/step', 'stpx' => 'model/step+xml', 'stpxz' => 'model/step-xml+zip', 'stpz' => 'model/step+zip', @@ -951,7 +992,7 @@ final class MimeType 'stw' => 'application/vnd.sun.xml.writer.template', 'styl' => 'text/stylus', 'stylus' => 'text/stylus', - 'sub' => 'text/vnd.dvb.subtitle', + 'sub' => 'image/vnd.dvb.subtitle', 'sus' => 'application/vnd.sus-calendar', 'susp' => 'application/vnd.sus-calendar', 'sv4cpio' => 'application/x-sv4cpio', @@ -970,6 +1011,7 @@ final class MimeType 'sxi' => 'application/vnd.sun.xml.impress', 'sxm' => 'application/vnd.sun.xml.math', 'sxw' => 'application/vnd.sun.xml.writer', + 'systemverify' => 'application/vnd.pp.systemverify+xml', 't' => 'text/troff', 't3' => 'application/x-t3vm-image', 't38' => 'image/t38', @@ -991,7 +1033,7 @@ final class MimeType 'tfm' => 'application/x-tex-tfm', 'tfx' => 'image/tiff-fx', 'tga' => 'image/x-tga', - 'tgz' => 'application/x-tar', + 'tgz' => 'application/gzip', 'thmx' => 'application/vnd.ms-officetheme', 'tif' => 'image/tiff', 'tiff' => 'image/tiff', @@ -1017,12 +1059,12 @@ final class MimeType 'txd' => 'application/vnd.genomatix.tuxedo', 'txf' => 'application/vnd.mobius.txf', 'txt' => 'text/plain', + 'u32' => 'application/x-authorware-bin', 'u3d' => 'model/u3d', 'u8dsn' => 'message/global-delivery-status', 'u8hdr' => 'message/global-headers', 'u8mdn' => 'message/global-disposition-notification', 'u8msg' => 'message/global', - 'u32' => 'application/x-authorware-bin', 'ubj' => 'application/ubjson', 'udeb' => 'application/x-debian-package', 'ufd' => 'application/vnd.ufdl', @@ -1078,16 +1120,18 @@ final class MimeType 'vcx' => 'application/vnd.vcx', 'vdi' => 'application/x-virtualbox-vdi', 'vds' => 'model/vnd.sap.vds', + 'vdx' => 'application/vnd.ms-visio.viewer', + 'vec' => 'application/vec+xml', 'vhd' => 'application/x-virtualbox-vhd', 'vis' => 'application/vnd.visionary', 'viv' => 'video/vnd.vivo', - 'vlc' => 'application/videolan', 'vmdk' => 'application/x-virtualbox-vmdk', 'vob' => 'video/x-ms-vob', 'vor' => 'application/vnd.stardivision.writer', 'vox' => 'application/x-authorware-bin', 'vrml' => 'model/vrml', 'vsd' => 'application/vnd.visio', + 'vsdx' => 'application/vnd.visio', 'vsf' => 'application/vnd.vsf', 'vss' => 'application/vnd.visio', 'vst' => 'application/vnd.visio', @@ -1095,17 +1139,18 @@ final class MimeType 'vtf' => 'image/vnd.valve.source.texture', 'vtt' => 'text/vtt', 'vtu' => 'model/vnd.vtu', + 'vtx' => 'application/vnd.visio', 'vxml' => 'application/voicexml+xml', 'w3d' => 'application/x-director', 'wad' => 'application/x-doom', 'wadl' => 'application/vnd.sun.wadl+xml', 'war' => 'application/java-archive', 'wasm' => 'application/wasm', - 'wav' => 'audio/x-wav', + 'wav' => 'audio/wav', 'wax' => 'audio/x-ms-wax', 'wbmp' => 'image/vnd.wap.wbmp', 'wbs' => 'application/vnd.criticaltools.wbs+xml', - 'wbxml' => 'application/wbxml', + 'wbxml' => 'application/vnd.wap.wbxml', 'wcm' => 'application/vnd.ms-works', 'wdb' => 'application/vnd.ms-works', 'wdp' => 'image/vnd.ms-photo', @@ -1124,12 +1169,12 @@ final class MimeType 'wmd' => 'application/x-ms-wmd', 'wmf' => 'image/wmf', 'wml' => 'text/vnd.wap.wml', - 'wmlc' => 'application/wmlc', + 'wmlc' => 'application/vnd.wap.wmlc', 'wmls' => 'text/vnd.wap.wmlscript', 'wmlsc' => 'application/vnd.wap.wmlscriptc', 'wmv' => 'video/x-ms-wmv', 'wmx' => 'video/x-ms-wmx', - 'wmz' => 'application/x-msmetafile', + 'wmz' => 'application/x-ms-wmz', 'woff' => 'font/woff', 'woff2' => 'font/woff2', 'word' => 'application/msword', @@ -1144,13 +1189,13 @@ final class MimeType 'wspolicy' => 'application/wspolicy+xml', 'wtb' => 'application/vnd.webturbo', 'wvx' => 'video/x-ms-wvx', + 'x32' => 'application/x-authorware-bin', 'x3d' => 'model/x3d+xml', 'x3db' => 'model/x3d+fastinfoset', 'x3dbz' => 'model/x3d+binary', 'x3dv' => 'model/x3d-vrml', 'x3dvz' => 'model/x3d+vrml', 'x3dz' => 'model/x3d+xml', - 'x32' => 'application/x-authorware-bin', 'x_b' => 'model/vnd.parasolid.transmit.binary', 'x_t' => 'model/vnd.parasolid.transmit.text', 'xaml' => 'application/xaml+xml', @@ -1162,6 +1207,7 @@ final class MimeType 'xbm' => 'image/x-xbitmap', 'xca' => 'application/xcap-caps+xml', 'xcs' => 'application/calendar+xml', + 'xdcf' => 'application/vnd.gov.sk.xmldatacontainer+xml', 'xdf' => 'application/xcap-diff+xml', 'xdm' => 'application/vnd.syncml.dm+xml', 'xdp' => 'application/vnd.adobe.xdp+xml', @@ -1177,18 +1223,18 @@ final class MimeType 'xhtml' => 'application/xhtml+xml', 'xhvml' => 'application/xv+xml', 'xif' => 'image/vnd.xiff', - 'xl' => 'application/excel', + 'xl' => 'application/vnd.ms-excel', 'xla' => 'application/vnd.ms-excel', - 'xlam' => 'application/vnd.ms-excel.addin.macroEnabled.12', + 'xlam' => 'application/vnd.ms-excel.addin.macroenabled.12', 'xlc' => 'application/vnd.ms-excel', 'xlf' => 'application/xliff+xml', 'xlm' => 'application/vnd.ms-excel', 'xls' => 'application/vnd.ms-excel', - 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12', - 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12', + 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroenabled.12', + 'xlsm' => 'application/vnd.ms-excel.sheet.macroenabled.12', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlt' => 'application/vnd.ms-excel', - 'xltm' => 'application/vnd.ms-excel.template.macroEnabled.12', + 'xltm' => 'application/vnd.ms-excel.template.macroenabled.12', 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', 'xlw' => 'application/vnd.ms-excel', 'xm' => 'audio/xm', @@ -1205,7 +1251,7 @@ final class MimeType 'xpx' => 'application/vnd.intercon.formnet', 'xsd' => 'application/xml', 'xsf' => 'application/prs.xsf+xml', - 'xsl' => 'application/xml', + 'xsl' => 'application/xslt+xml', 'xslt' => 'application/xslt+xml', 'xsm' => 'application/vnd.syncml+xml', 'xspf' => 'application/xspf+xml', @@ -1254,6 +1300,6 @@ public static function fromFilename(string $filename): ?string */ public static function fromExtension(string $extension): ?string { - return self::MIME_TYPES[strtolower($extension)] ?? null; + return self::MIME_TYPES[Utils::asciiToLower($extension)] ?? null; } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/MultipartStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/MultipartStream.php index 39db807c..e025580a 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/MultipartStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/MultipartStream.php @@ -20,20 +20,37 @@ final class MultipartStream implements StreamInterface /** @var StreamInterface */ private $stream; + private const BOUNDARY_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'()+_,-./:=? "; + /** - * @param array $elements Array of associative arrays, each containing a - * required "name" key mapping to the form field, - * name, a required "contents" key mapping to a - * StreamInterface/resource/string, an optional - * "headers" associative array of custom headers, - * and an optional "filename" key mapping to a - * string to send as the filename in the part. - * @param string $boundary You can optionally provide a specific boundary + * @param array $elements Array of associative arrays, each containing a + * required "name" key mapping to the form field, + * name, a required "contents" key mapping to any + * non-array value accepted by Utils::streamFor() + * (non-string scalar field values are cast to + * string), or an array for nested expansion. + * Optional keys include "headers" (associative + * array of custom headers) and "filename" (string + * to send as the filename in the part). + * When "contents" is an array, it is recursively + * expanded into multiple fields using bracket notation + * (e.g., name[0][key]). Empty arrays produce no fields. + * The "filename" and "headers" options cannot be used + * with array contents. + * @param string|null $boundary You can optionally provide a specific boundary * * @throws \InvalidArgumentException */ - public function __construct( array $elements = [], ?string $boundary = null ) + public function __construct(array $elements = [], ?string $boundary = null) { + if ($boundary !== null && !self::isValidBoundary($boundary)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing an invalid multipart boundary to MultipartStream::__construct() is deprecated; guzzlehttp/psr7 3.0 rejects invalid multipart boundaries.' + ); + } + $this->boundary = $boundary ?: bin2hex(random_bytes(20)); $this->stream = $this->createStream($elements); } @@ -51,16 +68,17 @@ public function isWritable(): bool /** * Get the headers needed before transferring the content of a POST file * - * @param array $headers + * @param array $headers */ private function getHeaders(array $headers): string { $str = ''; foreach ($headers as $key => $value) { + $key = (string) $key; $str .= "{$key}: {$value}\r\n"; } - return "--{$this->boundary}\r\n".trim($str)."\r\n\r\n"; + return "--{$this->boundary}\r\n".trim($str, " \n\r\t\0\x0B")."\r\n\r\n"; } /** @@ -91,7 +109,43 @@ private function addElement(AppendStream $stream, array $element): void } } - $element['contents'] = Utils::streamFor($element['contents']); + if (!is_string($element['name']) && !is_int($element['name'])) { + throw new \InvalidArgumentException("The 'name' key must be a string or integer"); + } + + if (is_array($element['contents'])) { + if (array_key_exists('filename', $element) || array_key_exists('headers', $element)) { + throw new \InvalidArgumentException( + "The 'filename' and 'headers' options cannot be used when 'contents' is an array" + ); + } + + $this->addNestedElements($stream, $element['contents'], (string) $element['name']); + + return; + } + + $contents = $element['contents']; + if (is_scalar($contents) && !is_string($contents)) { + // Multipart field values are byte strings on the wire, so finite + // numeric and boolean field values are cast to string here rather + // than tripping streamFor()'s non-string-scalar deprecation. Non-finite + // floats are deprecated and normalized here too, so the deprecation is + // reported against MultipartStream instead of transitively through + // streamFor(). + if (is_float($contents) && !is_finite($contents)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.12', + 'Passing a non-finite float as multipart contents is deprecated; guzzlehttp/psr7 3.0 rejects non-finite floats.' + ); + + $contents = is_nan($contents) ? 'NAN' : ($contents > 0 ? 'INF' : '-INF'); + } + + $contents = (string) $contents; + } + $element['contents'] = Utils::streamFor($contents); if (empty($element['filename'])) { $uri = $element['contents']->getMetadata('uri'); @@ -101,7 +155,7 @@ private function addElement(AppendStream $stream, array $element): void } [$body, $headers] = $this->createElement( - $element['name'], + (string) $element['name'], $element['contents'], $element['filename'] ?? null, $element['headers'] ?? [] @@ -112,10 +166,35 @@ private function addElement(AppendStream $stream, array $element): void $stream->addStream(Utils::streamFor("\r\n")); } + /** + * Recursively expand array contents into multiple form fields. + * + * @param array $contents + */ + private function addNestedElements(AppendStream $stream, array $contents, string $root): void + { + foreach ($contents as $key => $value) { + $fieldName = $root === '' ? sprintf('[%s]', (string) $key) : sprintf('%s[%s]', $root, (string) $key); + + if (is_array($value)) { + $this->addNestedElements($stream, $value, $fieldName); + } else { + $this->addElement($stream, ['name' => $fieldName, 'contents' => $value]); + } + } + } + + /** + * @param array $headers + * + * @return array{0: StreamInterface, 1: array} + */ private function createElement(string $name, StreamInterface $stream, ?string $filename, array $headers): array { + $headers = self::normalizePartHeaders($headers); + // Set a default content-disposition header if one was no provided - $disposition = $this->getHeader($headers, 'content-disposition'); + $disposition = self::getHeader($headers, 'content-disposition'); if (!$disposition) { $headers['Content-Disposition'] = ($filename === '0' || $filename) ? sprintf( @@ -127,7 +206,7 @@ private function createElement(string $name, StreamInterface $stream, ?string $f } // Set a default content-length header if one was no provided - $length = $this->getHeader($headers, 'content-length'); + $length = self::getHeader($headers, 'content-length'); if (!$length) { if ($length = $stream->getSize()) { $headers['Content-Length'] = (string) $length; @@ -135,7 +214,7 @@ private function createElement(string $name, StreamInterface $stream, ?string $f } // Set a default Content-Type if one was not supplied - $type = $this->getHeader($headers, 'content-type'); + $type = self::getHeader($headers, 'content-type'); if (!$type && ($filename === '0' || $filename)) { $headers['Content-Type'] = MimeType::fromFilename($filename) ?? 'application/octet-stream'; } @@ -143,15 +222,89 @@ private function createElement(string $name, StreamInterface $stream, ?string $f return [$stream, $headers]; } - private function getHeader(array $headers, string $key) + /** + * @param array $headers + */ + private static function getHeader(array $headers, string $key): ?string { - $lowercaseHeader = strtolower($key); + $lowercaseHeader = Utils::asciiToLower($key); foreach ($headers as $k => $v) { - if (strtolower($k) === $lowercaseHeader) { + if (Utils::asciiToLower((string) $k) === $lowercaseHeader) { return $v; } } return null; } + + private static function isValidBoundary(string $boundary): bool + { + $length = strlen($boundary); + + if ($length < 1 || $length > 70 || $boundary[$length - 1] === ' ') { + return false; + } + + return strspn($boundary, self::BOUNDARY_CHARS) === $length; + } + + /** + * @param array $headers + * + * @return array + */ + private static function normalizePartHeaders(array $headers): array + { + $normalized = []; + + foreach ($headers as $key => $value) { + self::deprecateInvalidPartHeaderName((string) $key); + + if (!is_string($value)) { + if (!is_scalar($value) && $value !== null && !(is_object($value) && method_exists($value, '__toString'))) { + throw new \InvalidArgumentException(sprintf( + 'Multipart part header value must be a string or stringable value but %s provided.', + \get_debug_type($value) + )); + } + + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s as a multipart part header value is deprecated; guzzlehttp/psr7 3.0 requires string multipart part header values.', + \get_debug_type($value) + ); + } + + $value = (string) $value; + + self::deprecateInvalidPartHeaderValue($value); + + $normalized[$key] = $value; + } + + return $normalized; + } + + private static function deprecateInvalidPartHeaderName(string $name): void + { + if (!preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $name)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing an invalid multipart part header name to MultipartStream is deprecated; guzzlehttp/psr7 3.0 rejects invalid multipart part header names.' + ); + } + } + + private static function deprecateInvalidPartHeaderValue(string $value): void + { + if (!preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing an invalid multipart part header value to MultipartStream is deprecated; guzzlehttp/psr7 3.0 rejects invalid multipart part header values.' + ); + } + } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/NoSeekStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/NoSeekStream.php index c02841a1..cf8fed0d 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/NoSeekStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/NoSeekStream.php @@ -18,6 +18,24 @@ final class NoSeekStream implements StreamInterface public function seek($offset, $whence = SEEK_SET): void { + if (!\is_int($offset)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.', + \get_debug_type($offset) + ); + } + + if (!\is_int($whence)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.', + \get_debug_type($whence) + ); + } + throw new \RuntimeException('Cannot seek a NoSeekStream'); } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/PumpStream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/PumpStream.php index b9fdd9e4..aa6d500f 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/PumpStream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/PumpStream.php @@ -9,12 +9,15 @@ /** * Provides a read only stream that pumps data from a PHP callable. * - * When invoking the provided callable, the PumpStream will pass the amount of - * data requested to read to the callable. The callable can choose to ignore + * When invoking the provided callable, the PumpStream will pass the suggested + * number of bytes to read to the callable. The callable can choose to ignore * this value and return fewer or more bytes than requested. Any extra data - * returned by the provided callable is buffered internally until drained using - * the read() function of the PumpStream. The provided callable MUST return - * false when there is no more data to read. + * returned by the callable is buffered internally until drained using the + * read() function of the PumpStream. The callable MUST return false or null + * when there is no more data to read. + * + * Userland callables that declare no parameters are tolerated by PHP, but + * length-aware callables remain the recommended formal shape. */ final class PumpStream implements StreamInterface { @@ -34,14 +37,17 @@ final class PumpStream implements StreamInterface private $buffer; /** - * @param callable(int): (string|false|null) $source Source of the stream data. The callable MAY - * accept an integer argument used to control the - * amount of data to return. The callable MUST - * return a string when called, or false|null on error - * or EOF. - * @param array{size?: int, metadata?: array} $options Stream options: - * - metadata: Hash of metadata to use with stream. - * - size: Size of the stream, if known. + * @param (callable(): (string|false|null))|(callable(int): (string|false|null)) $source Source of the stream data. The callable receives + * the suggested number of bytes to read, may ignore + * that value, and may return fewer or more bytes. + * Extra bytes are buffered. The callable MUST return + * a string when called, or false|null on error or EOF. + * Userland callables that declare no parameters are + * tolerated by PHP, but length-aware callables remain + * the recommended formal shape. + * @param array{size?: int, metadata?: array} $options Stream options: + * - metadata: Hash of metadata to use with stream. + * - size: Size of the stream, if known. */ public function __construct(callable $source, array $options = []) { @@ -105,6 +111,24 @@ public function rewind(): void public function seek($offset, $whence = SEEK_SET): void { + if (!\is_int($offset)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.', + \get_debug_type($offset) + ); + } + + if (!\is_int($whence)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.', + \get_debug_type($whence) + ); + } + throw new \RuntimeException('Cannot seek a PumpStream'); } @@ -115,6 +139,15 @@ public function isWritable(): bool public function write($string): int { + if (!\is_string($string)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.', + \get_debug_type($string) + ); + } + throw new \RuntimeException('Cannot write to a PumpStream'); } @@ -125,6 +158,15 @@ public function isReadable(): bool public function read($length): string { + if (!\is_int($length)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.', + \get_debug_type($length) + ); + } + $data = $this->buffer->read($length); $readLen = strlen($data); $this->tellPos += $readLen; @@ -154,6 +196,15 @@ public function getContents(): string */ public function getMetadata($key = null) { + if ($key !== null && !\is_string($key)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.', + \get_debug_type($key) + ); + } + if (!$key) { return $this->metadata; } @@ -163,9 +214,9 @@ public function getMetadata($key = null) private function pump(int $length): void { - if ($this->source) { + if ($this->source !== null) { do { - $data = call_user_func($this->source, $length); + $data = ($this->source)($length); if ($data === false || $data === null) { $this->source = null; diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/Query.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/Query.php index 31fe1c42..0237b8ea 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/Query.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/Query.php @@ -63,12 +63,15 @@ public static function parse(string $str, $urlEncoding = true): array * string. This function does not modify the provided keys when an array is * encountered (like `http_build_query()` would). * - * @param array $params Query string parameters. - * @param int|false $encoding Set to false to not encode, PHP_QUERY_RFC3986 - * to encode using RFC3986, or PHP_QUERY_RFC1738 - * to encode using RFC1738. + * @param array $params Query string parameters. + * @param int|false $encoding Set to false to not encode, + * PHP_QUERY_RFC3986 to encode using + * RFC3986, or PHP_QUERY_RFC1738 to + * encode using RFC1738. + * @param bool $treatBoolsAsInts Set to true to encode as 0/1, and + * false as false/true. */ - public static function build(array $params, $encoding = PHP_QUERY_RFC3986): string + public static function build(array $params, $encoding = PHP_QUERY_RFC3986, bool $treatBoolsAsInts = true): string { if (!$params) { return ''; @@ -86,12 +89,14 @@ public static function build(array $params, $encoding = PHP_QUERY_RFC3986): stri throw new \InvalidArgumentException('Invalid type'); } + $castBool = $treatBoolsAsInts ? static function ($v) { return (int) $v; } : static function ($v) { return $v ? 'true' : 'false'; }; + $qs = ''; foreach ($params as $k => $v) { $k = $encoder((string) $k); if (!is_array($v)) { $qs .= $k; - $v = is_bool($v) ? (int) $v : $v; + $v = is_bool($v) ? $castBool($v) : self::normalizeNonFiniteFloat($v); if ($v !== null) { $qs .= '='.$encoder((string) $v); } @@ -99,7 +104,7 @@ public static function build(array $params, $encoding = PHP_QUERY_RFC3986): stri } else { foreach ($v as $vv) { $qs .= $k; - $vv = is_bool($vv) ? (int) $vv : $vv; + $vv = is_bool($vv) ? $castBool($vv) : self::normalizeNonFiniteFloat($vv); if ($vv !== null) { $qs .= '='.$encoder((string) $vv); } @@ -110,4 +115,27 @@ public static function build(array $params, $encoding = PHP_QUERY_RFC3986): stri return $qs ? (string) substr($qs, 0, -1) : ''; } + + /** + * Converts non-finite floats to the strings PHP coerces them to, as + * implicit coercion of NAN emits a warning on PHP 8.5. + * + * @param mixed $value + * + * @return mixed + */ + private static function normalizeNonFiniteFloat($value) + { + if (is_float($value) && !is_finite($value)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.12', + 'Passing a non-finite float to Query::build() is deprecated; guzzlehttp/psr7 3.0 rejects non-finite floats.' + ); + + return is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF'); + } + + return $value; + } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/Request.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/Request.php index 94910967..5fc8e00a 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/Request.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/Request.php @@ -28,7 +28,7 @@ class Request implements RequestInterface /** * @param string $method HTTP method * @param string|UriInterface $uri URI - * @param array $headers Request headers + * @param (string|string[])[] $headers Request headers * @param string|resource|StreamInterface|null $body Request body * @param string $version Protocol version */ @@ -40,11 +40,14 @@ public function __construct( string $version = '1.1' ) { $this->assertMethod($method); - if (!($uri instanceof UriInterface)) { + $this->assertProtocolVersion($version); + + if (!$uri instanceof UriInterface) { $uri = new Uri($uri); } - $this->method = strtoupper($method); + self::warnOnMethodCasingChange($method); + $this->method = Utils::asciiToUpper($method); $this->uri = $uri; $this->setHeaders($headers); $this->protocol = $version; @@ -77,7 +80,13 @@ public function getRequestTarget(): string public function withRequestTarget($requestTarget): RequestInterface { - if (preg_match('#\s#', $requestTarget)) { + $hasWhitespace = preg_match('#\s#', $requestTarget); + + if ($hasWhitespace === false) { + throw new \RuntimeException('Unable to validate request target: '.preg_last_error_msg()); + } + + if ($hasWhitespace === 1) { throw new InvalidArgumentException( 'Invalid request target provided; cannot contain whitespace' ); @@ -97,8 +106,9 @@ public function getMethod(): string public function withMethod($method): RequestInterface { $this->assertMethod($method); + self::warnOnMethodCasingChange($method); $new = clone $this; - $new->method = strtoupper($method); + $new->method = Utils::asciiToUpper($method); return $new; } @@ -110,6 +120,15 @@ public function getUri(): UriInterface public function withUri(UriInterface $uri, $preserveHost = false): RequestInterface { + if (!\is_bool($preserveHost)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to RequestInterface::withUri() is deprecated; guzzlehttp/psr7 3.0 requires bool for $preserveHost.', + \get_debug_type($preserveHost) + ); + } + if ($uri === $this->uri) { return $this; } @@ -132,10 +151,14 @@ private function updateHostFromUri(): void return; } + Uri::assertValidHost($host); + if (($port = $this->uri->getPort()) !== null) { $host .= ':'.$port; } + $this->assertValue($host); + if (isset($this->headerNames['host'])) { $header = $this->headerNames['host']; } else { @@ -143,7 +166,7 @@ private function updateHostFromUri(): void $this->headerNames['host'] = 'Host'; } // Ensure Host is the first header. - // See: http://tools.ietf.org/html/rfc7230#section-5.4 + // See: https://datatracker.ietf.org/doc/html/rfc7230#section-5.4 $this->headers = [$header => [$host]] + $this->headers; } @@ -155,5 +178,18 @@ private function assertMethod($method): void if (!is_string($method) || $method === '') { throw new InvalidArgumentException('Method must be a non-empty string.'); } + + $this->assertNoLineSeparators($method, 'Method'); + } + + private static function warnOnMethodCasingChange(string $method): void + { + if ($method !== Utils::asciiToUpper($method)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing a non-uppercase HTTP method is deprecated; guzzlehttp/psr7 3.0 preserves method casing and will no longer uppercase it. Normalize the method before constructing or modifying requests if uppercase is required.' + ); + } } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/Response.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/Response.php index 9d1c77bd..c6d64cc9 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/Response.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/Response.php @@ -86,7 +86,7 @@ class Response implements ResponseInterface /** * @param int $status Status code - * @param array $headers Response headers + * @param (string|string[])[] $headers Response headers * @param string|resource|StreamInterface|null $body Response body * @param string $version Protocol version * @param string|null $reason Reason phrase (when empty a default will be used based on the status code) @@ -96,9 +96,10 @@ public function __construct( array $headers = [], $body = null, string $version = '1.1', - ?string $reason = null + ?string $reason = null ) { $this->assertStatusCodeRange($status); + $this->assertProtocolVersion($version); $this->statusCode = $status; @@ -108,11 +109,14 @@ public function __construct( $this->setHeaders($headers); if ($reason == '' && isset(self::PHRASES[$this->statusCode])) { - $this->reasonPhrase = self::PHRASES[$this->statusCode]; + $reasonPhrase = self::PHRASES[$this->statusCode]; } else { - $this->reasonPhrase = (string) $reason; + $reasonPhrase = (string) $reason; } + $this->assertNoLineSeparators($reasonPhrase, 'Reason phrase'); + $this->reasonPhrase = $reasonPhrase; + $this->protocol = $version; } @@ -128,6 +132,24 @@ public function getReasonPhrase(): string public function withStatus($code, $reasonPhrase = ''): ResponseInterface { + if (!\is_int($code) && \filter_var($code, \FILTER_VALIDATE_INT) !== false) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to ResponseInterface::withStatus() is deprecated; guzzlehttp/psr7 3.0 requires int for $code.', + \get_debug_type($code) + ); + } + + if (!\is_string($reasonPhrase)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to ResponseInterface::withStatus() is deprecated; guzzlehttp/psr7 3.0 requires string for $reasonPhrase.', + \get_debug_type($reasonPhrase) + ); + } + $this->assertStatusCodeIsInteger($code); $code = (int) $code; $this->assertStatusCodeRange($code); @@ -137,7 +159,9 @@ public function withStatus($code, $reasonPhrase = ''): ResponseInterface if ($reasonPhrase == '' && isset(self::PHRASES[$new->statusCode])) { $reasonPhrase = self::PHRASES[$new->statusCode]; } - $new->reasonPhrase = (string) $reasonPhrase; + $reasonPhrase = (string) $reasonPhrase; + $this->assertNoLineSeparators($reasonPhrase, 'Reason phrase'); + $new->reasonPhrase = $reasonPhrase; return $new; } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/Rfc3986.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/Rfc3986.php new file mode 100644 index 00000000..488c412f --- /dev/null +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/Rfc3986.php @@ -0,0 +1,25 @@ +@,;:\\\"/[\]?={}\x01-\x20\x7F]++):[ \t]*+((?:[ \t]*+[\x21-\x7E\x80-\xFF]++)*+)[ \t]*+\r?\n)m"; public const HEADER_FOLD_REGEX = "(\r?\n[ \t]++)"; + + /** + * @return array{0: string, 1: int|null}|null + */ + public static function parseHostHeader(string $authority): ?array + { + if ($authority === '') { + return null; + } + + $host = $authority; + $port = null; + + if ($authority[0] === '[') { + $closingBracket = strpos($authority, ']'); + if ($closingBracket === false) { + return null; + } + + $host = substr($authority, 0, $closingBracket + 1); + $remainder = substr($authority, $closingBracket + 1); + if ($remainder !== '') { + if ($remainder[0] !== ':') { + return null; + } + + $port = self::parseAuthorityPort(substr($remainder, 1)); + if ($port === null) { + return null; + } + } + } elseif (false !== ($colon = strpos($authority, ':'))) { + $host = substr($authority, 0, $colon); + $port = self::parseAuthorityPort(substr($authority, $colon + 1)); + if ($port === null) { + return null; + } + } + + if ($host === '' || !self::isValidHostHeaderHost($host)) { + return null; + } + + return [$host, $port]; + } + + private static function isValidHostHeaderHost(string $host): bool + { + $invalidHost = preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host); + + if ($invalidHost === false) { + return false; + } + + if ($invalidHost === 1) { + return false; + } + + if (strpos($host, '[') !== false || strpos($host, ']') !== false) { + if ($host[0] !== '[' || substr($host, -1) !== ']') { + return false; + } + + $address = substr($host, 1, -1); + + return filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) !== false + || preg_match('/^v[0-9a-f]+\.['.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.':]+$/iD', $address) === 1; + } + + return strpos($host, ':') === false; + } + + private static function parseAuthorityPort(string $port): ?int + { + if ($port === '' || !ctype_digit($port)) { + return null; + } + + $normalized = ltrim($port, '0'); + if ($normalized === '') { + return 0; + } + + if (strlen($normalized) > 5 || (int) $normalized > 0xFFFF) { + return null; + } + + return (int) $normalized; + } } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/ServerRequest.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/ServerRequest.php index 80bafe07..9980715a 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/ServerRequest.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/ServerRequest.php @@ -59,7 +59,7 @@ class ServerRequest extends Request implements ServerRequestInterface /** * @param string $method HTTP method * @param string|UriInterface $uri URI - * @param array $headers Request headers + * @param (string|string[])[] $headers Request headers * @param string|resource|StreamInterface|null $body Request body * @param string $version Protocol version * @param array $serverParams Typically the $_SERVER superglobal @@ -165,11 +165,12 @@ private static function normalizeNestedFileSpec(array $files = []): array */ public static function fromGlobals(): ServerRequestInterface { - $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; - $headers = getallheaders(); + $method = Utils::asciiToUpper(self::getServerParam('REQUEST_METHOD') ?? 'GET'); + $headers = self::removeInvalidHostHeader(self::getAllHeaders()); $uri = self::getUriFromGlobals(); $body = new CachingStream(new LazyOpenStream('php://input', 'r+')); - $protocol = isset($_SERVER['SERVER_PROTOCOL']) ? str_replace('HTTP/', '', $_SERVER['SERVER_PROTOCOL']) : '1.1'; + $serverProtocol = self::getServerParam('SERVER_PROTOCOL'); + $protocol = $serverProtocol !== null ? str_replace('HTTP/', '', $serverProtocol) : '1.1'; $serverRequest = new ServerRequest($method, $uri, $headers, $body, $protocol, $_SERVER); @@ -180,18 +181,63 @@ public static function fromGlobals(): ServerRequestInterface ->withUploadedFiles(self::normalizeFiles($_FILES)); } - private static function extractHostAndPortFromAuthority(string $authority): array + /** + * @return array + */ + private static function getAllHeaders(): array { - $uri = 'http://'.$authority; - $parts = parse_url($uri); - if (false === $parts) { - return [null, null]; + return self::normalizeHeaderValues(getallheaders()); + } + + /** + * @param array $headers + * + * @return array + */ + private static function normalizeHeaderValues(array $headers): array + { + $normalized = []; + + foreach ($headers as $name => $value) { + if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) { + $normalized[$name] = (string) $value; + } + } + + return $normalized; + } + + private static function getServerParam(string $key): ?string + { + return isset($_SERVER[$key]) && is_string($_SERVER[$key]) ? $_SERVER[$key] : null; + } + + /** + * @param array $headers + * + * @return array + */ + private static function removeInvalidHostHeader(array $headers): array + { + foreach ($headers as $name => $value) { + if (Utils::asciiToLower((string) $name) !== 'host') { + continue; + } + + if (Rfc7230::parseHostHeader($value) === null) { + unset($headers[$name]); + } } - $host = $parts['host'] ?? null; - $port = $parts['port'] ?? null; + return $headers; + } - return [$host, $port]; + /** + * @return array{0: string|null, 1: int|null} + */ + private static function extractHostAndPortFromAuthority(string $authority): array + { + return Rfc7230::parseHostHeader($authority) ?? [null, null]; } /** @@ -201,11 +247,13 @@ public static function getUriFromGlobals(): UriInterface { $uri = new Uri(''); - $uri = $uri->withScheme(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http'); + $https = self::getServerParam('HTTPS'); + $uri = $uri->withScheme(!empty($https) && $https !== 'off' ? 'https' : 'http'); $hasPort = false; - if (isset($_SERVER['HTTP_HOST'])) { - [$host, $port] = self::extractHostAndPortFromAuthority($_SERVER['HTTP_HOST']); + $authority = self::getServerParam('HTTP_HOST'); + if ($authority !== null) { + [$host, $port] = self::extractHostAndPortFromAuthority($authority); if ($host !== null) { $uri = $uri->withHost($host); } @@ -214,19 +262,21 @@ public static function getUriFromGlobals(): UriInterface $hasPort = true; $uri = $uri->withPort($port); } - } elseif (isset($_SERVER['SERVER_NAME'])) { - $uri = $uri->withHost($_SERVER['SERVER_NAME']); - } elseif (isset($_SERVER['SERVER_ADDR'])) { - $uri = $uri->withHost($_SERVER['SERVER_ADDR']); + } elseif (($serverName = self::getServerParam('SERVER_NAME')) !== null) { + $uri = $uri->withHost($serverName); + } elseif (($serverAddr = self::getServerParam('SERVER_ADDR')) !== null) { + $uri = $uri->withHost($serverAddr); } - if (!$hasPort && isset($_SERVER['SERVER_PORT'])) { - $uri = $uri->withPort($_SERVER['SERVER_PORT']); + $serverPort = self::getServerParam('SERVER_PORT'); + if (!$hasPort && $serverPort !== null && preg_match('/^[+-]?\d+$/D', $serverPort) === 1) { + $uri = $uri->withPort((int) $serverPort); } $hasQuery = false; - if (isset($_SERVER['REQUEST_URI'])) { - $requestUriParts = explode('?', $_SERVER['REQUEST_URI'], 2); + $requestUri = self::getServerParam('REQUEST_URI'); + if ($requestUri !== null) { + $requestUriParts = explode('?', $requestUri, 2); $uri = $uri->withPath($requestUriParts[0]); if (isset($requestUriParts[1])) { $hasQuery = true; @@ -234,8 +284,9 @@ public static function getUriFromGlobals(): UriInterface } } - if (!$hasQuery && isset($_SERVER['QUERY_STRING'])) { - $uri = $uri->withQuery($_SERVER['QUERY_STRING']); + $queryString = self::getServerParam('QUERY_STRING'); + if (!$hasQuery && $queryString !== null) { + $uri = $uri->withQuery($queryString); } return $uri; @@ -253,6 +304,37 @@ public function getUploadedFiles(): array public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface { + $invalidUploadedFileFound = false; + $invalidUploadedFile = null; + $stack = [$uploadedFiles]; + + while ($stack !== []) { + foreach (\array_pop($stack) as $uploadedFile) { + if ($uploadedFile instanceof UploadedFileInterface) { + continue; + } + + if (\is_array($uploadedFile)) { + $stack[] = $uploadedFile; + continue; + } + + $invalidUploadedFileFound = true; + $invalidUploadedFile = $uploadedFile; + + break 2; + } + } + + if ($invalidUploadedFileFound) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s inside ServerRequestInterface::withUploadedFiles() is deprecated; guzzlehttp/psr7 3.0 requires an UploadedFileInterface[] tree.', + \get_debug_type($invalidUploadedFile) + ); + } + $new = clone $this; $new->uploadedFiles = $uploadedFiles; @@ -295,6 +377,15 @@ public function getParsedBody() public function withParsedBody($data): ServerRequestInterface { + if ($data !== null && !\is_array($data) && !\is_object($data)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to ServerRequestInterface::withParsedBody() is deprecated; guzzlehttp/psr7 3.0 requires array|object|null.', + \get_debug_type($data) + ); + } + $new = clone $this; $new->parsedBody = $data; @@ -311,6 +402,15 @@ public function getAttributes(): array */ public function getAttribute($attribute, $default = null) { + if (!\is_string($attribute)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to ServerRequestInterface::getAttribute() is deprecated; guzzlehttp/psr7 3.0 requires string for $attribute.', + \get_debug_type($attribute) + ); + } + if (false === array_key_exists($attribute, $this->attributes)) { return $default; } @@ -320,6 +420,15 @@ public function getAttribute($attribute, $default = null) public function withAttribute($attribute, $value): ServerRequestInterface { + if (!\is_string($attribute)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to ServerRequestInterface::withAttribute() is deprecated; guzzlehttp/psr7 3.0 requires string for $attribute.', + \get_debug_type($attribute) + ); + } + $new = clone $this; $new->attributes[$attribute] = $value; @@ -328,6 +437,15 @@ public function withAttribute($attribute, $value): ServerRequestInterface public function withoutAttribute($attribute): ServerRequestInterface { + if (!\is_string($attribute)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to ServerRequestInterface::withoutAttribute() is deprecated; guzzlehttp/psr7 3.0 requires string for $attribute.', + \get_debug_type($attribute) + ); + } + if (false === array_key_exists($attribute, $this->attributes)) { return $this; } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/Stream.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/Stream.php index f1e95cda..5721a016 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/Stream.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/Stream.php @@ -12,8 +12,8 @@ class Stream implements StreamInterface { /** - * @see http://php.net/manual/function.fopen.php - * @see http://php.net/manual/en/function.gzopen.php + * @see https://www.php.net/manual/en/function.fopen.php + * @see https://www.php.net/manual/en/function.gzopen.php */ private const READABLE_MODES = '/r|a\+|ab\+|w\+|wb\+|x\+|xb\+|c\+|cb\+/'; private const WRITABLE_MODES = '/a|w|r\+|rb\+|rw|x|c/'; @@ -63,7 +63,7 @@ public function __construct($stream, array $options = []) $this->seekable = $meta['seekable']; $this->readable = (bool) preg_match(self::READABLE_MODES, $meta['mode']); $this->writable = (bool) preg_match(self::WRITABLE_MODES, $meta['mode']); - $this->uri = $this->getMetadata('uri'); + $this->uri = $meta['uri'] ?? null; } /** @@ -200,6 +200,24 @@ public function rewind(): void public function seek($offset, $whence = SEEK_SET): void { + if (!\is_int($offset)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.', + \get_debug_type($offset) + ); + } + + if (!\is_int($whence)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.', + \get_debug_type($whence) + ); + } + $whence = (int) $whence; if (!isset($this->stream)) { @@ -216,6 +234,15 @@ public function seek($offset, $whence = SEEK_SET): void public function read($length): string { + if (!\is_int($length)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.', + \get_debug_type($length) + ); + } + if (!isset($this->stream)) { throw new \RuntimeException('Stream is detached'); } @@ -245,6 +272,15 @@ public function read($length): string public function write($string): int { + if (!\is_string($string)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.', + \get_debug_type($string) + ); + } + if (!isset($this->stream)) { throw new \RuntimeException('Stream is detached'); } @@ -268,6 +304,15 @@ public function write($string): int */ public function getMetadata($key = null) { + if ($key !== null && !\is_string($key)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.', + \get_debug_type($key) + ); + } + if (!isset($this->stream)) { return $key ? null : []; } elseif (!$key) { diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/StreamDecoratorTrait.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/StreamDecoratorTrait.php index 38205dc2..094bc526 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/StreamDecoratorTrait.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/StreamDecoratorTrait.php @@ -70,7 +70,7 @@ public function __call(string $method, array $args) { /** @var callable $callable */ $callable = [$this->stream, $method]; - $result = call_user_func_array($callable, $args); + $result = ($callable)(...$args); // Always return the wrapped object if the result is a return $this return $result === $this->stream ? $this : $result; @@ -86,6 +86,15 @@ public function close(): void */ public function getMetadata($key = null) { + if ($key !== null && !\is_string($key)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.', + \get_debug_type($key) + ); + } + return $this->stream->getMetadata($key); } @@ -131,16 +140,52 @@ public function rewind(): void public function seek($offset, $whence = SEEK_SET): void { + if (!\is_int($offset)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.', + \get_debug_type($offset) + ); + } + + if (!\is_int($whence)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.', + \get_debug_type($whence) + ); + } + $this->stream->seek($offset, $whence); } public function read($length): string { + if (!\is_int($length)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.', + \get_debug_type($length) + ); + } + return $this->stream->read($length); } public function write($string): int { + if (!\is_string($string)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.', + \get_debug_type($string) + ); + } + return $this->stream->write($string); } diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/StreamWrapper.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/StreamWrapper.php index 1024fd54..0bf0f9a8 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/StreamWrapper.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/StreamWrapper.php @@ -44,7 +44,13 @@ public static function getResource(StreamInterface $stream) .'writable, or both.'); } - return fopen('guzzle://stream', $mode, false, self::createStreamContext($stream)); + $resource = @fopen('guzzle://stream', $mode, false, self::createStreamContext($stream)); + + if ($resource === false) { + throw new \RuntimeException('Unable to create stream resource'); + } + + return $resource; } /** @@ -69,7 +75,7 @@ public static function register(): void } } - public function stream_open( string $path, string $mode, int $options, ?string &$opened_path = null ): bool + public function stream_open(string $path, string $mode, int $options, ?string &$opened_path = null): bool { $options = stream_context_get_options($this->context); @@ -122,10 +128,28 @@ public function stream_cast(int $cast_as) } /** - * @return array + * @return array{ + * dev: int, + * ino: int, + * mode: int, + * nlink: int, + * uid: int, + * gid: int, + * rdev: int, + * size: int, + * atime: int, + * mtime: int, + * ctime: int, + * blksize: int, + * blocks: int + * }|false */ - public function stream_stat(): array + public function stream_stat() { + if ($this->stream->getSize() === null) { + return false; + } + static $modeMap = [ 'r' => 33060, 'rb' => 33060, @@ -152,7 +176,21 @@ public function stream_stat(): array } /** - * @return array + * @return array{ + * dev: int, + * ino: int, + * mode: int, + * nlink: int, + * uid: int, + * gid: int, + * rdev: int, + * size: int, + * atime: int, + * mtime: int, + * ctime: int, + * blksize: int, + * blocks: int + * } */ public function url_stat(string $path, int $flags): array { diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/UploadedFile.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/UploadedFile.php index 66125740..5564d58c 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/UploadedFile.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/UploadedFile.php @@ -11,15 +11,15 @@ class UploadedFile implements UploadedFileInterface { - private const ERRORS = [ - UPLOAD_ERR_OK, - UPLOAD_ERR_INI_SIZE, - UPLOAD_ERR_FORM_SIZE, - UPLOAD_ERR_PARTIAL, - UPLOAD_ERR_NO_FILE, - UPLOAD_ERR_NO_TMP_DIR, - UPLOAD_ERR_CANT_WRITE, - UPLOAD_ERR_EXTENSION, + private const ERROR_MAP = [ + UPLOAD_ERR_OK => 'UPLOAD_ERR_OK', + UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE', + UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE', + UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL', + UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE', + UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR', + UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE', + UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION', ]; /** @@ -64,8 +64,8 @@ public function __construct( $streamOrFile, ?int $size, int $errorStatus, - ?string $clientFilename = null, - ?string $clientMediaType = null + ?string $clientFilename = null, + ?string $clientMediaType = null ) { $this->setError($errorStatus); $this->size = $size; @@ -104,7 +104,7 @@ private function setStreamOrFile($streamOrFile): void */ private function setError(int $error): void { - if (false === in_array($error, UploadedFile::ERRORS, true)) { + if (!isset(UploadedFile::ERROR_MAP[$error])) { throw new InvalidArgumentException( 'Invalid error status for UploadedFile' ); @@ -113,7 +113,7 @@ private function setError(int $error): void $this->error = $error; } - private function isStringNotEmpty($param): bool + private static function isStringNotEmpty($param): bool { return is_string($param) && false === empty($param); } @@ -137,7 +137,7 @@ public function isMoved(): bool private function validateActive(): void { if (false === $this->isOk()) { - throw new RuntimeException('Cannot retrieve stream due to upload error'); + throw new RuntimeException(\sprintf('Cannot retrieve stream due to upload error (%s)', self::ERROR_MAP[$this->error])); } if ($this->isMoved()) { @@ -163,7 +163,7 @@ public function moveTo($targetPath): void { $this->validateActive(); - if (false === $this->isStringNotEmpty($targetPath)) { + if (false === self::isStringNotEmpty($targetPath)) { throw new InvalidArgumentException( 'Invalid path provided for move operation; must be a non-empty string' ); diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/Uri.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/Uri.php index 8537e604..dc336621 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/Uri.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/Uri.php @@ -38,20 +38,7 @@ class Uri implements UriInterface, \JsonSerializable 'ldap' => 389, ]; - /** - * Unreserved characters for use in a regex. - * - * @see https://tools.ietf.org/html/rfc3986#section-2.3 - */ - private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; - - /** - * Sub-delims for use in a regex. - * - * @see https://tools.ietf.org/html/rfc3986#section-2.2 - */ - private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; - private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26']; + private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26', '+' => '%2B']; /** @var string Uri scheme. */ private $scheme = ''; @@ -74,9 +61,6 @@ class Uri implements UriInterface, \JsonSerializable /** @var string Uri fragment. */ private $fragment = ''; - /** @var string|null String representation */ - private $composedComponents; - public function __construct(string $uri = '') { if ($uri !== '') { @@ -84,7 +68,13 @@ public function __construct(string $uri = '') if ($parts === false) { throw new MalformedUriException("Unable to parse URI: $uri"); } - $this->applyParts($parts); + try { + $this->applyParts($parts); + } catch (MalformedUriException $e) { + throw $e; + } catch (\InvalidArgumentException $e) { + throw new MalformedUriException($e->getMessage(), 0, $e); + } } } @@ -105,15 +95,37 @@ public function __construct(string $uri = '') */ private static function parse(string $url) { - // If IPv6 + if (self::isPathNoSchemeReference($url)) { + return self::parsePathNoSchemeReference($url); + } + + // Preserve bracketed IPv6 literals before encoding, including dotted IPv4 + // tails. DEL (\x7F) is excluded so a raw-DEL host falls through to the + // general path and is rejected rather than silently mutated by parse_url(). $prefix = ''; - if (preg_match('%^(.*://\[[0-9:a-f]+\])(.*?)$%', $url, $matches)) { + $ipv6Prefix = preg_match('%\A([0-9A-Za-z+.-]+://\[[^\]\x00-\x20\x7F/?#@]+\])(.*)\z%s', $url, $matches); + + if ($ipv6Prefix === false) { + return false; + } + + if ($ipv6Prefix === 1) { /** @var array{0:string, 1:string, 2:string} $matches */ + $suffix = $matches[2]; + + // After the bracketed host only an optional numeric port and/or a + // path, query, or fragment may follow. Anything else (for example + // `:80@evil` or `:80x`) would let parse_url() reinterpret a + // different host. + if (preg_match('%\A(?::[0-9]*)?(?:[/?#].*)?\z%s', $suffix) !== 1) { + return false; + } + $prefix = $matches[1]; - $url = $matches[2]; + $url = $suffix; } - /** @var string */ + /** @var string|null */ $encodedUrl = preg_replace_callback( '%[^:/@?&=#]+%usD', static function ($matches) { @@ -122,6 +134,10 @@ static function ($matches) { $url ); + if ($encodedUrl === null) { + return false; + } + $result = parse_url($prefix.$encodedUrl); if ($result === false) { @@ -131,19 +147,48 @@ static function ($matches) { return array_map('urldecode', $result); } - public function __toString(): string + private static function isPathNoSchemeReference(string $url): bool { - if ($this->composedComponents === null) { - $this->composedComponents = self::composeComponents( - $this->scheme, - $this->getAuthority(), - $this->path, - $this->query, - $this->fragment - ); + if ($url === '' || $url[0] === '/' || $url[0] === '?' || $url[0] === '#') { + return false; } - return $this->composedComponents; + $firstSegment = substr($url, 0, strcspn($url, '/?#')); + + return strpos($firstSegment, ':') === false; + } + + /** + * @return array{path: string, query?: string, fragment?: string} + */ + private static function parsePathNoSchemeReference(string $url): array + { + $parts = []; + + if (false !== ($fragmentPosition = strpos($url, '#'))) { + $parts['fragment'] = substr($url, $fragmentPosition + 1); + $url = substr($url, 0, $fragmentPosition); + } + + if (false !== ($queryPosition = strpos($url, '?'))) { + $parts['query'] = substr($url, $queryPosition + 1); + $url = substr($url, 0, $queryPosition); + } + + $parts['path'] = $url; + + return $parts; + } + + public function __toString(): string + { + return self::composeComponents( + $this->scheme, + $this->getAuthority(), + $this->path, + $this->query, + $this->fragment + ); } /** @@ -162,7 +207,7 @@ public function __toString(): string * `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to * that format). * - * @see https://tools.ietf.org/html/rfc3986#section-5.3 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.3 */ public static function composeComponents(?string $scheme, ?string $authority, string $path, ?string $query, ?string $fragment): string { @@ -219,7 +264,7 @@ public static function isDefaultPort(UriInterface $uri): bool * @see Uri::isNetworkPathReference * @see Uri::isAbsolutePathReference * @see Uri::isRelativePathReference - * @see https://tools.ietf.org/html/rfc3986#section-4 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4 */ public static function isAbsolute(UriInterface $uri): bool { @@ -231,7 +276,7 @@ public static function isAbsolute(UriInterface $uri): bool * * A relative reference that begins with two slash characters is termed an network-path reference. * - * @see https://tools.ietf.org/html/rfc3986#section-4.2 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 */ public static function isNetworkPathReference(UriInterface $uri): bool { @@ -243,7 +288,7 @@ public static function isNetworkPathReference(UriInterface $uri): bool * * A relative reference that begins with a single slash character is termed an absolute-path reference. * - * @see https://tools.ietf.org/html/rfc3986#section-4.2 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 */ public static function isAbsolutePathReference(UriInterface $uri): bool { @@ -258,7 +303,7 @@ public static function isAbsolutePathReference(UriInterface $uri): bool * * A relative reference that does not begin with a slash character is termed a relative-path reference. * - * @see https://tools.ietf.org/html/rfc3986#section-4.2 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 */ public static function isRelativePathReference(UriInterface $uri): bool { @@ -277,9 +322,9 @@ public static function isRelativePathReference(UriInterface $uri): bool * @param UriInterface $uri The URI to check * @param UriInterface|null $base An optional base URI to compare against * - * @see https://tools.ietf.org/html/rfc3986#section-4.4 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.4 */ - public static function isSameDocumentReference( UriInterface $uri, ?UriInterface $base = null ): bool + public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool { if ($base !== null) { $uri = UriResolver::resolve($base, $uri); @@ -336,36 +381,104 @@ public static function withQueryValue(UriInterface $uri, string $key, ?string $v * * It has the same behavior as withQueryValue() but for an associative array of key => value. * - * @param UriInterface $uri URI to use as a base. - * @param array $keyValueArray Associative array of key and values + * @param UriInterface $uri URI to use as a base. + * @param (string|null)[] $keyValueArray Associative array of key and values */ public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface { $result = self::getFilteredQueryString($uri, array_keys($keyValueArray)); foreach ($keyValueArray as $key => $value) { - $result[] = self::generateQueryString((string) $key, $value !== null ? (string) $value : null); + $result[] = self::generateQueryString((string) $key, $value !== null ? self::stringifyQueryValue($value) : null); } return $uri->withQuery(implode('&', $result)); } + /** + * Stringifies a non-null query value, deprecating non-string values that + * guzzlehttp/psr7 3.0 will reject. Non-finite floats are normalized to the + * strings PHP coerces them to, as implicit coercion of NAN emits a warning + * on PHP 8.5. + * + * @param mixed $value + */ + private static function stringifyQueryValue($value): string + { + if (!is_string($value)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.12', + 'Passing %s to Uri::withQueryValues() is deprecated; cast it to a string. guzzlehttp/psr7 3.0 will only accept string or null query values.', + \gettype($value) + ); + + if (is_float($value) && !is_finite($value)) { + return is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF'); + } + } + + return (string) $value; + } + /** * Creates a URI from a hash of `parse_url` components. * - * @see http://php.net/manual/en/function.parse-url.php + * @see https://www.php.net/manual/en/function.parse-url.php * * @throws MalformedUriException If the components do not form a valid URI. */ public static function fromParts(array $parts): UriInterface { $uri = new self(); - $uri->applyParts($parts); - $uri->validateState(); + try { + $uri->applyParts($parts); + $uri->validateState(); + } catch (MalformedUriException $e) { + throw $e; + } catch (\InvalidArgumentException $e) { + throw new MalformedUriException($e->getMessage(), 0, $e); + } return $uri; } + /** + * @throws \InvalidArgumentException If the host is invalid. + * + * @internal + */ + public static function assertValidHost(string $host): void + { + if ($host === '') { + return; + } + + // Reject control characters and URI authority delimiters so getHost() + // cannot disagree with the on-wire authority. + $invalidHost = preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host); + + if ($invalidHost === false) { + throw new \RuntimeException('Unable to validate URI host: '.preg_last_error_msg()); + } + + if ($invalidHost === 1) { + throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host)); + } + + if (strpos($host, '[') !== false || strpos($host, ']') !== false) { + if ($host[0] !== '[' || substr($host, -1) !== ']') { + throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host)); + } + + return; + } + + if (strpos($host, ':') !== false) { + throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host)); + } + } + public function getScheme(): string { return $this->scheme; @@ -425,7 +538,6 @@ public function withScheme($scheme): UriInterface $new = clone $this; $new->scheme = $scheme; - $new->composedComponents = null; $new->removeDefaultPort(); $new->validateState(); @@ -445,7 +557,6 @@ public function withUserInfo($user, $password = null): UriInterface $new = clone $this; $new->userInfo = $info; - $new->composedComponents = null; $new->validateState(); return $new; @@ -461,7 +572,6 @@ public function withHost($host): UriInterface $new = clone $this; $new->host = $host; - $new->composedComponents = null; $new->validateState(); return $new; @@ -469,6 +579,15 @@ public function withHost($host): UriInterface public function withPort($port): UriInterface { + if ($port !== null && !\is_int($port)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to UriInterface::withPort() is deprecated; guzzlehttp/psr7 3.0 requires int|null.', + \get_debug_type($port) + ); + } + $port = $this->filterPort($port); if ($this->port === $port) { @@ -477,7 +596,6 @@ public function withPort($port): UriInterface $new = clone $this; $new->port = $port; - $new->composedComponents = null; $new->removeDefaultPort(); $new->validateState(); @@ -494,7 +612,6 @@ public function withPath($path): UriInterface $new = clone $this; $new->path = $path; - $new->composedComponents = null; $new->validateState(); return $new; @@ -510,7 +627,6 @@ public function withQuery($query): UriInterface $new = clone $this; $new->query = $query; - $new->composedComponents = null; return $new; } @@ -525,7 +641,6 @@ public function withFragment($fragment): UriInterface $new = clone $this; $new->fragment = $fragment; - $new->composedComponents = null; return $new; } @@ -581,7 +696,18 @@ private function filterScheme($scheme): string throw new \InvalidArgumentException('Scheme must be a string'); } - return \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + $scheme = Utils::asciiToLower($scheme); + + if ($scheme !== '' && !preg_match('/^[a-z][a-z0-9.+-]*$/D', $scheme)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing "%s" as a URI scheme is deprecated; guzzlehttp/psr7 3.0 requires URI schemes to match RFC 3986 syntax and begin with a letter.', + $scheme + ); + } + + return $scheme; } /** @@ -595,10 +721,10 @@ private function filterUserInfoComponent($component): string throw new \InvalidArgumentException('User info must be a string'); } - return preg_replace_callback( - '/(?:[^%'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.']+|%(?![A-Fa-f0-9]{2}))/', - [$this, 'rawurlencodeMatchZero'], - $component + return $this->filterComponent( + '/(?:[^%'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.']+|%(?![A-Fa-f0-9]{2}))/', + $component, + 'Unable to filter URI user info' ); } @@ -613,7 +739,10 @@ private function filterHost($host): string throw new \InvalidArgumentException('Host must be a string'); } - return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + $host = Utils::asciiToLower($host); + self::assertValidHost($host); + + return $host; } /** @@ -638,7 +767,7 @@ private function filterPort($port): ?int } /** - * @param string[] $keys + * @param (string|int)[] $keys * * @return string[] */ @@ -650,7 +779,9 @@ private static function getFilteredQueryString(UriInterface $uri, array $keys): return []; } - $decodedKeys = array_map('rawurldecode', $keys); + $decodedKeys = array_map(function ($k): string { + return rawurldecode((string) $k); + }, $keys); return array_filter(explode('&', $current), function ($part) use ($decodedKeys) { return !in_array(rawurldecode(explode('=', $part)[0]), $decodedKeys, true); @@ -659,7 +790,8 @@ private static function getFilteredQueryString(UriInterface $uri, array $keys): private static function generateQueryString(string $key, ?string $value): string { - // Query string separators ("=", "&") within the key or value need to be encoded + // Query string separators ("=", "&") and literal plus signs ("+") within the + // key or value need to be encoded // (while preventing double-encoding) before setting the query string. All other // chars that need percent-encoding will be encoded by withQuery(). $queryString = strtr($key, self::QUERY_SEPARATORS_REPLACEMENT); @@ -691,10 +823,10 @@ private function filterPath($path): string throw new \InvalidArgumentException('Path must be a string'); } - return preg_replace_callback( - '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/]++|%(?![A-Fa-f0-9]{2}))/', - [$this, 'rawurlencodeMatchZero'], - $path + return $this->filterComponent( + '/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/]++|%(?![A-Fa-f0-9]{2}))/', + $path, + 'Unable to filter URI path' ); } @@ -711,13 +843,24 @@ private function filterQueryAndFragment($str): string throw new \InvalidArgumentException('Query and fragment must be a string'); } - return preg_replace_callback( - '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', - [$this, 'rawurlencodeMatchZero'], - $str + return $this->filterComponent( + '/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', + $str, + 'Unable to filter URI query or fragment' ); } + private function filterComponent(string $pattern, string $component, string $context): string + { + $filtered = preg_replace_callback($pattern, [$this, 'rawurlencodeMatchZero'], $component); + + if ($filtered === null) { + throw new \RuntimeException($context.': '.preg_last_error_msg()); + } + + return $filtered; + } + private function rawurlencodeMatchZero(array $match): string { return rawurlencode($match[0]); diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/UriComparator.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/UriComparator.php index 7e75bb4b..a62848c5 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/UriComparator.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/UriComparator.php @@ -19,7 +19,7 @@ final class UriComparator */ public static function isCrossOrigin(UriInterface $original, UriInterface $modified): bool { - if (\strcasecmp($original->getHost(), $modified->getHost()) !== 0) { + if (!Utils::caselessEquals($original->getHost(), $modified->getHost())) { return true; } @@ -34,7 +34,7 @@ public static function isCrossOrigin(UriInterface $original, UriInterface $modif return false; } - private static function computePort(UriInterface $uri): int + private static function computePort(UriInterface $uri): ?int { $port = $uri->getPort(); @@ -42,7 +42,15 @@ private static function computePort(UriInterface $uri): int return $port; } - return 'https' === $uri->getScheme() ? 443 : 80; + if ('http' === $uri->getScheme()) { + return 80; + } + + if ('https' === $uri->getScheme()) { + return 443; + } + + return null; } private function __construct() diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/UriNormalizer.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/UriNormalizer.php index 6fdcd7e1..92e96d8f 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/UriNormalizer.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/UriNormalizer.php @@ -11,7 +11,7 @@ * * @author Tobias Schultze * - * @see https://tools.ietf.org/html/rfc3986#section-6 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-6 */ final class UriNormalizer { @@ -116,10 +116,15 @@ final class UriNormalizer * treated equivalent which is not necessarily true according to RFC 3986. But that difference * is highly uncommon in reality. So this potential normalization is implied in PSR-7 as well. * + * When a normalization rewrites the path of a relative-path reference so that its first + * segment contains a colon, which would be mistaken for a scheme name (RFC 3986 Section 4.2), + * the path is prefixed with "./" instead of throwing, e.g. "a%41:" becomes "./aA:" as "aA:" + * would be an absolute URI with the scheme "aa". + * * @param UriInterface $uri The URI to normalize * @param int $flags A bitmask of normalizations to apply, see constants * - * @see https://tools.ietf.org/html/rfc3986#section-6.2 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-6.2 */ public static function normalize(UriInterface $uri, int $flags = self::PRESERVING_NORMALIZATIONS): UriInterface { @@ -150,7 +155,13 @@ public static function normalize(UriInterface $uri, int $flags = self::PRESERVIN } if ($flags & self::REMOVE_DUPLICATE_SLASHES) { - $uri = $uri->withPath(preg_replace('#//++#', '/', $uri->getPath())); + $path = preg_replace('#//++#', '/', $uri->getPath()); + + if ($path === null) { + throw new \RuntimeException('Unable to remove duplicate slashes from URI path: '.preg_last_error_msg()); + } + + $uri = self::withGuardedPath($uri, $path); } if ($flags & self::SORT_QUERY_PARAMETERS && $uri->getQuery() !== '') { @@ -174,7 +185,7 @@ public static function normalize(UriInterface $uri, int $flags = self::PRESERVIN * @param UriInterface $uri2 An URI to compare * @param int $normalizations A bitmask of normalizations to apply, see constants * - * @see https://tools.ietf.org/html/rfc3986#section-6.1 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-6.1 */ public static function isEquivalent(UriInterface $uri1, UriInterface $uri2, int $normalizations = self::PRESERVING_NORMALIZATIONS): bool { @@ -185,32 +196,52 @@ private static function capitalizePercentEncoding(UriInterface $uri): UriInterfa { $regex = '/(?:%[A-Fa-f0-9]{2})++/'; - $callback = function (array $match) { - return strtoupper($match[0]); + $callback = function (array $match): string { + return Utils::asciiToUpper($match[0]); }; - return - $uri->withPath( - preg_replace_callback($regex, $callback, $uri->getPath()) - )->withQuery( - preg_replace_callback($regex, $callback, $uri->getQuery()) - ); + return self::withGuardedPath($uri, self::normalizePercentEncodingInComponent($uri->getPath(), $regex, $callback)) + ->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback)) + ->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback)); } private static function decodeUnreservedCharacters(UriInterface $uri): UriInterface { $regex = '/%(?:2D|2E|5F|7E|3[0-9]|[46][1-9A-F]|[57][0-9A])/i'; - $callback = function (array $match) { + $callback = function (array $match): string { return rawurldecode($match[0]); }; - return - $uri->withPath( - preg_replace_callback($regex, $callback, $uri->getPath()) - )->withQuery( - preg_replace_callback($regex, $callback, $uri->getQuery()) - ); + return self::withGuardedPath($uri, self::normalizePercentEncodingInComponent($uri->getPath(), $regex, $callback)) + ->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback)) + ->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback)); + } + + /** + * Writes the given path only when it differs from the current one, guarded so the write cannot throw. + */ + private static function withGuardedPath(UriInterface $uri, string $path): UriInterface + { + if ($path === $uri->getPath()) { + return $uri; + } + + return $uri->withPath(UriResolver::guardedPath($uri, $path)); + } + + /** + * @param callable(array): string $callback + */ + private static function normalizePercentEncodingInComponent(string $component, string $regex, callable $callback): string + { + $normalized = preg_replace_callback($regex, $callback, $component); + + if ($normalized === null) { + throw new \RuntimeException('Unable to normalize URI component percent-encoding: '.preg_last_error_msg()); + } + + return $normalized; } private function __construct() diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/UriResolver.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/UriResolver.php index 0024148e..b557ffec 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/UriResolver.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/UriResolver.php @@ -11,14 +11,14 @@ * * @author Tobias Schultze * - * @see https://tools.ietf.org/html/rfc3986#section-5 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5 */ final class UriResolver { /** * Removes dot segments from a path and returns the new path. * - * @see http://tools.ietf.org/html/rfc3986#section-5.2.4 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4 */ public static function removeDotSegments(string $path): string { @@ -50,10 +50,36 @@ public static function removeDotSegments(string $path): string return $newPath; } + /** + * Returns the path, prefixed with "./" when it would otherwise begin a relative-path reference with a segment + * containing a colon. + * + * Such a segment would be mistaken for a scheme name (RFC 3986 Section 4.2), so a URI without a scheme and + * authority cannot hold the path, but reference resolution and percent-encoding normalization can produce one. + * The "./" prefix the RFC prescribes resolves back to the same path. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 + * + * @internal + */ + public static function guardedPath(UriInterface $uri, string $path): string + { + if ($uri->getScheme() !== '' || $uri->getAuthority() !== '' || strpos(explode('/', $path, 2)[0], ':') === false) { + return $path; + } + + return './'.$path; + } + /** * Converts the relative URI into a new URI that is resolved against the base URI. * - * @see http://tools.ietf.org/html/rfc3986#section-5.2 + * When the resolved path is a relative-path reference whose first segment contains a colon, + * which would be mistaken for a scheme name (RFC 3986 Section 4.2), it is prefixed with "./", + * e.g. "./a:b". + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2 + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 */ public static function resolve(UriInterface $base, UriInterface $rel): UriInterface { @@ -67,41 +93,41 @@ public static function resolve(UriInterface $base, UriInterface $rel): UriInterf } if ($rel->getAuthority() != '') { - $targetAuthority = $rel->getAuthority(); - $targetPath = self::removeDotSegments($rel->getPath()); - $targetQuery = $rel->getQuery(); + return $rel + ->withScheme($base->getScheme()) + ->withPath(self::removeDotSegments($rel->getPath())); + } + + if ($rel->getPath() === '') { + $targetPath = $base->getPath(); + $targetQuery = $rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery(); } else { - $targetAuthority = $base->getAuthority(); - if ($rel->getPath() === '') { - $targetPath = $base->getPath(); - $targetQuery = $rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery(); + if ($rel->getPath()[0] === '/') { + $targetPath = $rel->getPath(); } else { - if ($rel->getPath()[0] === '/') { - $targetPath = $rel->getPath(); + if ($base->getAuthority() != '' && $base->getPath() === '') { + $targetPath = '/'.$rel->getPath(); } else { - if ($targetAuthority != '' && $base->getPath() === '') { - $targetPath = '/'.$rel->getPath(); + $lastSlashPos = strrpos($base->getPath(), '/'); + if ($lastSlashPos === false) { + $targetPath = $rel->getPath(); } else { - $lastSlashPos = strrpos($base->getPath(), '/'); - if ($lastSlashPos === false) { - $targetPath = $rel->getPath(); - } else { - $targetPath = substr($base->getPath(), 0, $lastSlashPos + 1).$rel->getPath(); - } + $targetPath = substr($base->getPath(), 0, $lastSlashPos + 1).$rel->getPath(); } } - $targetPath = self::removeDotSegments($targetPath); - $targetQuery = $rel->getQuery(); } + $targetPath = self::removeDotSegments($targetPath); + $targetQuery = $rel->getQuery(); + } + + if ($targetPath !== $base->getPath()) { + $targetPath = self::guardedPath($base, $targetPath); } - return new Uri(Uri::composeComponents( - $base->getScheme(), - $targetAuthority, - $targetPath, - $targetQuery, - $rel->getFragment() - )); + return $base + ->withPath($targetPath) + ->withQuery($targetQuery) + ->withFragment($rel->getFragment()); } /** diff --git a/src/Client/lib/Lib/GuzzleHttp/Psr7/Utils.php b/src/Client/lib/Lib/GuzzleHttp/Psr7/Utils.php index 05d3d59b..32cc9028 100644 --- a/src/Client/lib/Lib/GuzzleHttp/Psr7/Utils.php +++ b/src/Client/lib/Lib/GuzzleHttp/Psr7/Utils.php @@ -5,27 +5,85 @@ namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface; -use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\ServerRequestInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\StreamInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\UriInterface; final class Utils { + /** + * Converts ASCII uppercase letters in a string to lowercase. + * + * Unlike strtolower(), which honors LC_CTYPE before PHP 8.2, the + * conversion is locale-independent and leaves every non-ASCII byte + * unchanged, as HTTP protocol elements require. + */ + public static function asciiToLower(string $string): string + { + return strtr($string, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + } + + /** + * Converts ASCII lowercase letters in a string to uppercase. + * + * Unlike strtoupper(), which honors LC_CTYPE before PHP 8.2, the + * conversion is locale-independent and leaves every non-ASCII byte + * unchanged, as HTTP protocol elements require. + */ + public static function asciiToUpper(string $string): string + { + return strtr($string, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + } + + /** + * Converts the first character of a string to uppercase when it is an + * ASCII lowercase letter. + * + * Unlike ucfirst(), which honors LC_CTYPE before PHP 8.2, the conversion + * is locale-independent and leaves every non-ASCII byte unchanged, as + * HTTP protocol elements require. + */ + public static function asciiUcFirst(string $string): string + { + if ($string === '') { + return ''; + } + + return self::asciiToUpper($string[0]).substr($string, 1); + } + + /** + * Checks whether the haystack contains the needle, comparing ASCII + * letters case-insensitively and without locale sensitivity. + */ + public static function caselessContains(string $haystack, string $needle): bool + { + return str_contains(self::asciiToLower($haystack), self::asciiToLower($needle)); + } + + /** + * Checks whether two strings are equal, comparing ASCII letters + * case-insensitively and without locale sensitivity. + */ + public static function caselessEquals(string $left, string $right): bool + { + return self::asciiToLower($left) === self::asciiToLower($right); + } + /** * Remove the items given by the keys, case insensitively from the data. * - * @param string[] $keys + * @param (string|int)[] $keys */ public static function caselessRemove(array $keys, array $data): array { $result = []; foreach ($keys as &$key) { - $key = strtolower($key); + $key = self::asciiToLower((string) $key); } foreach ($data as $k => $v) { - if (!is_string($k) || !in_array(strtolower($k), $keys)) { + if (!in_array(self::asciiToLower((string) $k), $keys)) { $result[$k] = $v; } } @@ -37,6 +95,11 @@ public static function caselessRemove(array $keys, array $data): array * Copy the contents of a stream into another stream until the given number * of bytes have been read. * + * The copy stops if the destination write returns 0, for example a + * BufferStream at its high water mark or a full DroppingStream. For a + * guaranteed full copy use a normal writable stream such as a file or + * php://temp stream. + * * @param StreamInterface $source Stream to read from * @param StreamInterface $dest Stream to write to * @param int $maxLen Maximum number of bytes to read. Pass -1 @@ -50,7 +113,12 @@ public static function copyToStream(StreamInterface $source, StreamInterface $de if ($maxLen === -1) { while (!$source->eof()) { - if (!$dest->write($source->read($bufferSize))) { + $buf = $source->read($bufferSize); + if ($buf === '') { + break; + } + + if (!self::writeAll($dest, $buf)) { break; } } @@ -63,9 +131,33 @@ public static function copyToStream(StreamInterface $source, StreamInterface $de break; } $remaining -= $len; - $dest->write($buf); + if (!self::writeAll($dest, $buf)) { + break; + } + } + } + } + + /** + * Writes the full buffer to the destination, retrying short writes. + * + * Returns false when the destination write returns 0 or less. + */ + private static function writeAll(StreamInterface $dest, string $buf): bool + { + $written = 0; + $len = strlen($buf); + + while ($written < $len) { + $result = $dest->write(substr($buf, $written)); + if ($result <= 0) { + return false; } + + $written += $result; } + + return true; } /** @@ -146,9 +238,14 @@ public static function hash(StreamInterface $stream, string $algo, bool $rawOutp * * The changes can be one of: * - method: (string) Changes the HTTP method. - * - set_headers: (array) Sets the given headers. - * - remove_headers: (array) Remove the given headers. - * - body: (mixed) Sets the given body. + * - set_headers: (array) Sets the given headers. Values must be strings + * or non-empty arrays of strings. + * - remove_headers: (array) Remove the given headers. Values may be + * strings or integers. + * - body: (mixed) Sets the given body. Present non-null values are converted + * with self::streamFor(), including scalar values, resources, streams, + * iterators, callable arrays, closures, invokable objects, and objects + * with __toString(). String inputs remain literal bodies. * - uri: (UriInterface) Set the URI. * - query: (string) Set the query string value of the URI. * - version: (string) Set the protocol version. @@ -162,13 +259,26 @@ public static function modifyRequest(RequestInterface $request, array $changes): return $request; } + self::warnOnInvalidModifyRequestChanges($changes); + $headers = $request->getHeaders(); if (!isset($changes['uri'])) { $uri = $request->getUri(); } else { // Remove the host header if one is on the URI - if ($host = $changes['uri']->getHost()) { + $host = $changes['uri']->getHost(); + if ($host !== '') { + if (isset($changes['set_headers']) && is_array($changes['set_headers'])) { + foreach (array_keys($changes['set_headers']) as $header) { + if (self::asciiToLower((string) $header) === 'host') { + throw new \InvalidArgumentException( + 'Cannot modify request with both a URI containing a host and an explicit Host header.' + ); + } + } + } + $changes['set_headers']['Host'] = $host; if ($port = $changes['uri']->getPort()) { @@ -195,33 +305,150 @@ public static function modifyRequest(RequestInterface $request, array $changes): $uri = $uri->withQuery($changes['query']); } - if ($request instanceof ServerRequestInterface) { - $new = (new ServerRequest( - $changes['method'] ?? $request->getMethod(), - $uri, - $headers, - $changes['body'] ?? $request->getBody(), - $changes['version'] ?? $request->getProtocolVersion(), - $request->getServerParams() - )) - ->withParsedBody($request->getParsedBody()) - ->withQueryParams($request->getQueryParams()) - ->withCookieParams($request->getCookieParams()) - ->withUploadedFiles($request->getUploadedFiles()); - - foreach ($request->getAttributes() as $key => $value) { - $new = $new->withAttribute($key, $value); + $hasHost = false; + foreach (array_keys($headers) as $header) { + if (self::asciiToLower((string) $header) === 'host') { + $hasHost = true; + break; + } + } + + // Match Request::__construct() by adding a Host header when one is not provided. + if (!$hasHost && $uri->getHost() !== '') { + $host = $uri->getHost(); + + if (($port = $uri->getPort()) !== null) { + $host .= ':'.$port; + } + + $headers = ['Host' => [$host]] + $headers; + } + + $new = $request; + + if (isset($changes['method'])) { + $new = $new->withMethod($changes['method']); + } + + if (isset($changes['uri']) || isset($changes['query'])) { + $new = $new->withUri($uri, true); + } + + if ($headers !== $new->getHeaders()) { + foreach (array_keys($new->getHeaders()) as $header) { + /** @var RequestInterface */ + $new = $new->withoutHeader((string) $header); + } + + $addedHeaders = []; + foreach ($headers as $header => $value) { + $header = (string) $header; + $normalized = self::asciiToLower($header); + + if (isset($addedHeaders[$normalized])) { + /** @var RequestInterface */ + $new = $new->withAddedHeader($addedHeaders[$normalized], $value); + } else { + /** @var RequestInterface */ + $new = $new->withHeader($header, $value); + $addedHeaders[$normalized] = $header; + } } + } + + if (isset($changes['body'])) { + /** @var RequestInterface */ + $new = $new->withBody(self::streamFor($changes['body'])); + } - return $new; + if (isset($changes['version'])) { + /** @var RequestInterface */ + $new = $new->withProtocolVersion($changes['version']); } - return new Request( - $changes['method'] ?? $request->getMethod(), - $uri, - $headers, - $changes['body'] ?? $request->getBody(), - $changes['version'] ?? $request->getProtocolVersion() + return $new; + } + + /** + * @param array $changes + */ + private static function warnOnInvalidModifyRequestChanges(array $changes): void + { + foreach (['method', 'query', 'version'] as $key) { + if (\array_key_exists($key, $changes) && !\is_string($changes[$key])) { + self::warnOnInvalidModifyRequestChange($key, 'string', $changes[$key]); + } + } + + if (\array_key_exists('uri', $changes) && !$changes['uri'] instanceof UriInterface) { + self::warnOnInvalidModifyRequestChange('uri', 'UriInterface', $changes['uri']); + } + + if (\array_key_exists('body', $changes) && $changes['body'] === null) { + self::warnOnInvalidModifyRequestChange('body', 'resource|string|int|float|bool|StreamInterface|callable|\Iterator|\Stringable', $changes['body']); + } + + if (\array_key_exists('set_headers', $changes)) { + if (!\is_array($changes['set_headers'])) { + self::warnOnInvalidModifyRequestChange('set_headers', 'array>', $changes['set_headers']); + } else { + foreach ($changes['set_headers'] as $header => $value) { + $headerPath = \sprintf('set_headers.%s', (string) $header); + + if (\is_array($value)) { + if ($value === []) { + self::warnOnInvalidModifyRequestChange($headerPath, 'string|non-empty-array', $value); + + break; + } + + foreach ($value as $index => $item) { + if (!\is_string($item)) { + self::warnOnInvalidModifyRequestChange(\sprintf('%s.%s', $headerPath, (string) $index), 'string', $item); + + break 2; + } + } + } elseif (!\is_string($value)) { + self::warnOnInvalidModifyRequestChange($headerPath, 'string|non-empty-array', $value); + + break; + } + } + } + } + + if (!\array_key_exists('remove_headers', $changes)) { + return; + } + + if (!\is_array($changes['remove_headers'])) { + self::warnOnInvalidModifyRequestChange('remove_headers', 'array', $changes['remove_headers']); + + return; + } + + foreach ($changes['remove_headers'] as $index => $header) { + if (!\is_string($header) && !\is_int($header)) { + self::warnOnInvalidModifyRequestChange(\sprintf('remove_headers.%s', (string) $index), 'string|int', $header); + + return; + } + } + } + + /** + * @param mixed $value + */ + private static function warnOnInvalidModifyRequestChange(string $key, string $expected, $value): void + { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.11', + 'Passing %s to Utils::modifyRequest() change "%s" is deprecated; guzzlehttp/psr7 3.0 requires %s.', + \get_debug_type($value), + $key, + $expected ); } @@ -231,7 +458,7 @@ public static function modifyRequest(RequestInterface $request, array $changes): * @param StreamInterface $stream Stream to read from * @param int|null $maxLength Maximum buffer length */ - public static function readLine( StreamInterface $stream, ?int $maxLength = null ): string + public static function readLine(StreamInterface $stream, ?int $maxLength = null): string { $buffer = ''; $size = 0; @@ -250,6 +477,20 @@ public static function readLine( StreamInterface $stream, ?int $maxLength = null return $buffer; } + /** + * Redact the password in the user info part of a URI. + */ + public static function redactUserInfo(UriInterface $uri): UriInterface + { + $userInfo = $uri->getUserInfo(); + + if (false !== ($pos = \strpos($userInfo, ':'))) { + return $uri->withUserInfo(\substr($userInfo, 0, $pos), '***'); + } + + return $uri; + } + /** * Create a new stream based on the input type. * @@ -271,13 +512,17 @@ public static function readLine( StreamInterface $stream, ?int $maxLength = null * the object will be cast to a string and then a stream will be returned that * uses the string value. * - `NULL`: When `null` is passed, an empty stream object is returned. - * - `callable` When a callable is passed, a read-only stream object will be - * created that invokes the given callable. The callable is invoked with the - * number of suggested bytes to read. The callable can return any number of - * bytes, but MUST return `false` when there is no more data to return. The - * stream object that wraps the callable will invoke the callable until the - * number of requested bytes are available. Any additional bytes will be - * buffered and used in subsequent reads. + * - `callable`: When a callable array, closure, or invokable object is passed + * and no earlier resource or object rule applies, a read-only stream object + * will be created that invokes the given callable. The callable is invoked + * with the suggested number of bytes to read. The callable can return fewer + * or more bytes than requested, but MUST return `false` or `null` when there + * is no more data to return. Any additional bytes will be buffered and used + * in subsequent reads. String inputs are always treated as string bodies, + * even when they name callable functions. + * + * Passing a non-string scalar (`int`, `float`, or `bool`) is deprecated; cast + * it to a string instead. guzzlehttp/psr7 3.0 will reject non-string scalars. * * @param resource|string|int|float|bool|StreamInterface|callable|\Iterator|null $resource Entity body data * @param array{size?: int, metadata?: array} $options Additional options @@ -287,6 +532,22 @@ public static function readLine( StreamInterface $stream, ?int $maxLength = null public static function streamFor($resource = '', array $options = []): StreamInterface { if (is_scalar($resource)) { + if (!is_string($resource)) { + \trigger_deprecation( + 'guzzlehttp/psr7', + '2.12', + 'Passing %s to Utils::streamFor() is deprecated; cast it to a string. guzzlehttp/psr7 3.0 will only accept string, resource, StreamInterface, Stringable, Iterator, callable, or null.', + \gettype($resource) + ); + + if (is_float($resource) && !is_finite($resource)) { + // Normalized only to avoid PHP 8.5's (string) NAN warning + // while deprecated; 3.0 rejects non-finite floats with every + // other non-string scalar. + $resource = is_nan($resource) ? 'NAN' : ($resource > 0 ? 'INF' : '-INF'); + } + } + $stream = self::tryFopen('php://temp', 'r+'); if ($resource !== '') { fwrite($stream, (string) $resource); @@ -383,7 +644,7 @@ public static function tryFopen(string $filename, string $mode) restore_error_handler(); if ($ex) { - /** @var $ex \RuntimeException */ + /** @var \RuntimeException $ex */ throw $ex; } @@ -430,7 +691,7 @@ public static function tryGetContents($stream): string restore_error_handler(); if ($ex) { - /** @var $ex \RuntimeException */ + /** @var \RuntimeException $ex */ throw $ex; } diff --git a/src/Client/lib/Lib/GuzzleHttp/RedirectMiddleware.php b/src/Client/lib/Lib/GuzzleHttp/RedirectMiddleware.php index 9dd17ddb..ea227386 100644 --- a/src/Client/lib/Lib/GuzzleHttp/RedirectMiddleware.php +++ b/src/Client/lib/Lib/GuzzleHttp/RedirectMiddleware.php @@ -3,6 +3,7 @@ namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\BadResponseException; +use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\RequestException; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\TooManyRedirectsException; use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface; use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface; @@ -13,7 +14,7 @@ * Request redirect middleware. * * Apply this middleware like other middleware using - * {@see \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Middleware::redirect()}. + * {@see Middleware::redirect()}. * * @final */ @@ -104,6 +105,10 @@ public function checkRedirect(RequestInterface $request, array $options, Respons ); } + // 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. @@ -169,28 +174,43 @@ public function modifyRequest(RequestInterface $request, array $options, Respons 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'] = ''; + if ($requestMethod !== 'QUERY' || !\in_array($statusCode, [301, 302], true)) { + $modify['method'] = \in_array($requestMethod, ['GET', 'HEAD', 'OPTIONS'], true) ? $requestMethod : 'GET'; + $modify['body'] = ''; + $modify['remove_headers'] = ['Content-Length', 'Transfer-Encoding']; + } } $uri = self::redirectUri($request, $response, $protocols); - if (isset($options['idn_conversion']) && ($options['idn_conversion'] !== false)) { - $idnOptions = ($options['idn_conversion'] === true) ? \IDNA_DEFAULT : $options['idn_conversion']; + $idnOptions = Utils::normalizeIdnConversionOption($options['idn_conversion'] ?? null); + if ($idnOptions !== null) { $uri = Utils::idnUriConvert($uri, $idnOptions); } $modify['uri'] = $uri; - Psr7\Message::rewindBody($request); + + // The body only needs to be rewound when the next request reuses it. + if (!isset($modify['body'])) { + try { + Psr7\Message::rewindBody($request); + } catch (\RuntimeException $e) { + throw new RequestException( + 'Redirect failed because the request body could not be rewound: '.$e->getMessage(), + $request, + $response, + $e + ); + } + } // 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. if ($options['allow_redirects']['referer'] && $modify['uri']->getScheme() === $request->getUri()->getScheme() ) { - $uri = $request->getUri()->withUserInfo(''); + $uri = $request->getUri()->withUserInfo('')->withFragment(''); $modify['set_headers']['Referer'] = (string) $uri; } else { $modify['remove_headers'][] = 'Referer'; diff --git a/src/Client/lib/Lib/GuzzleHttp/RequestOptions.php b/src/Client/lib/Lib/GuzzleHttp/RequestOptions.php index bfcfe047..40b894a7 100644 --- a/src/Client/lib/Lib/GuzzleHttp/RequestOptions.php +++ b/src/Client/lib/Lib/GuzzleHttp/RequestOptions.php @@ -5,9 +5,7 @@ /** * This class contains a list of built-in Guzzle request options. * - * More documentation for each option can be found at http://guzzlephp.org/. - * - * @see http://docs.guzzlephp.org/en/v6/request-options.html + * @see https://github.com/guzzle/guzzle/blob/7.15/docs/request-options.md */ final class RequestOptions { @@ -22,55 +20,69 @@ final class RequestOptions * - 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: (array, default=['http', 'https']) Allowed redirect - * protocols. + * - protocols: (non-empty-array, 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. + * - track_redirects: (bool, default=false) Track redirected URI and status + * history in response headers. */ public const ALLOW_REDIRECTS = 'allow_redirects'; /** - * auth: (array) Pass an array of HTTP authentication parameters to use - * with the request. 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 null to disable authentication - * for a request. + * auth: (array{0: string, 1: string, 2?: string|null}|string|false|null) + * Pass an array of HTTP authentication parameters to use with the request. + * 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. */ public const AUTH = 'auth'; /** - * body: (resource|string|null|int|float|StreamInterface|callable|\Iterator) - * Body to send in the request. + * body: (resource|string|null|int|float|bool|\Plausible\Analytics\WP\Client\Lib\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. */ public const BODY = 'body'; /** - * cert: (string|array) Set to a string to specify the path to a file - * containing a PEM formatted SSL client side certificate. If a password - * is required, then set cert to an array containing the path to the PEM - * file in the first array element followed by the certificate password - * in the second array element. + * cert: (string|array{0: string, 1?: string|null}) Set to a string to + * specify the path to a client certificate file. PEM is the default + * certificate format. If a password is required, set cert to an array + * containing the certificate path in the first array element followed by + * 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. */ public const CERT = 'cert'; /** - * cookies: (bool|Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie\CookieJarInterface, default=false) + * cert_type: (string) Specify the SSL client certificate file type. + */ + public const CERT_TYPE = 'cert_type'; + + /** + * cookies: (false|Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie\CookieJarInterface, default=false) * Specifies whether or not cookies are used in a request or what cookie * jar to use or what cookies to send. This option only works if your * handler has the `cookie` middleware. Valid values are `false` and - * an instance of {@see \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie\CookieJarInterface}. + * an instance of {@see Cookie\CookieJarInterface}. */ public const COOKIES = 'cookies'; /** - * connect_timeout: (float, default=0) Float describing the 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=0) Number of seconds to wait while + * trying to connect to a server. Use 0 to wait 300 seconds (the default + * behavior). */ public const CONNECT_TIMEOUT = 'connect_timeout'; @@ -86,6 +98,23 @@ final class RequestOptions */ public const CRYPTO_METHOD = 'crypto_method'; + /** + * crypto_method_max: (int) A value describing the maximum TLS protocol + * version to use. + * + * This setting must be set to one of the + * ``STREAM_CRYPTO_METHOD_TLS*_CLIENT`` constants. On the stream handler, + * PHP 7.3 or higher is required to set a maximum TLS version, and PHP 7.4 + * or higher is required to use TLS 1.3. 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 @@ -94,14 +123,15 @@ final class RequestOptions public const DEBUG = 'debug'; /** - * decode_content: (bool, default=true) Specify whether or not + * decode_content: (bool|string, default=true) Specify whether or not * Content-Encoding responses (gzip, deflate, etc.) are automatically * decoded. */ public const DECODE_CONTENT = 'decode_content'; /** - * delay: (int) The amount of time to delay before sending in milliseconds. + * delay: (int|float) The amount of time to delay before sending in + * milliseconds. */ public const DELAY = 'delay'; @@ -124,16 +154,17 @@ final class RequestOptions public const EXPECT = 'expect'; /** - * form_params: (array) Associative array of form field names to values - * where each value is a string or array of strings. Sets the Content-Type - * header to application/x-www-form-urlencoded when no Content-Type header - * is already present. + * form_params: (array) + * Associative array of form field names to scalar, null, or nested array + * values. Sets the Content-Type header to application/x-www-form-urlencoded + * when no Content-Type header is already present. */ public const FORM_PARAMS = 'form_params'; /** - * headers: (array) Associative array of HTTP headers. Each value MUST be - * a string or array of strings. + * headers: (array>|null) + * Associative array of HTTP headers. Each value MUST be a string or non-empty + * array of strings. */ public const HEADERS = 'headers'; @@ -146,10 +177,10 @@ final class RequestOptions public const HTTP_ERRORS = 'http_errors'; /** - * idn: (bool|int, default=true) A combination of IDNA_* constants for - * idn_to_ascii() PHP's function (see "options" parameter). Set to false to - * disable IDN support completely, or to true to use the default - * configuration (IDNA_DEFAULT constant). + * 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 + * (IDNA_DEFAULT constant). */ public const IDN_CONVERSION = 'idn_conversion'; @@ -161,16 +192,103 @@ final class RequestOptions public const JSON = 'json'; /** - * multipart: (array) Array of associative arrays, each containing a - * required "name" key mapping to the form field, name, a required - * "contents" key mapping to a StreamInterface|resource|string, an - * optional "headers" associative array of custom headers, and an - * optional "filename" key mapping to a string to send as the filename in - * the part. If no "filename" key is present, then no "filename" attribute - * will be added to the part. + * multipart: (array) Array of part arrays, each containing a required + * "name" key mapping to the string or integer form field name, a required + * "contents" key mapping to any non-array value accepted by PSR-7 + * Utils::streamFor() or a nested array of field values, an optional + * "headers" array of string custom header values, and an optional + * "filename" key mapping to a string to send as the filename in the part. + * "headers" and "filename" cannot be used when "contents" is an array. */ public const MULTIPART = 'multipart'; + /** + * multiplex: (string) Controls how a request sent through a built-in + * cURL handler relates to shared, multiplexed connections: how an HTTP/2 + * request pursues one, or, with Multiplexing::NONE, whether the transfer + * may share its connection at all. When the option is not set, + * multiplexing is left to libcurl: nothing waits, and established + * multiplex-capable connections are still shared. Use + * Multiplexing::EAGER to explicitly never wait 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. Explicit + * modes reject deprecated raw cURL options they conflict with: the + * required family cannot be combined with a raw CURLOPT_HTTP_VERSION, + * CURLOPT_URL, or CURLOPT_FOLLOWLOCATION; no explicit mode can be + * combined with a raw CURLOPT_PIPEWAIT on the CurlMultiHandler; and + * Multiplexing::NONE on a CurlMultiHandler that permits multiplexing + * cannot be combined with the raw CURLOPT_HTTP_VERSION, CURLOPT_HTTPAUTH + * (including the "auth" request option's "digest" and "ntlm" modes, + * which set it), CURLOPT_PROXYAUTH, CURLOPT_FOLLOWLOCATION, + * CURLOPT_HTTPHEADER, CURLOPT_ALTSVC, CURLOPT_ALTSVC_CTRL, or + * CURLOPT_PROXYTYPE cURL options. The required family also + * rejects final CURLOPT_HTTPAUTH masks that permit NTLM, which libcurl + * retries over HTTP/1.1. The required family validates its cleartext + * proxy rule against the final cURL configuration, after raw options + * such as CURLOPT_PROXY and CURLOPT_PRE_PROXY are applied; only the + * exact raw CURLOPT_NOPROXY wildcard '*' disables the primary proxy and + * pre-proxy there, and raw host-specific patterns are conservatively + * treated as leaving them active. These rejections are + * configuration-conflict checks, not remote security checks. + * + * 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 rejects explicitly requested wait + * modes as a configuration conflict when the transfer would actually + * wait, and always rejects the required modes, 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 request with a Multiplexing::NONE request + * option is rejected on a CurlMultiHandler that permits multiplexing. + * On a CurlMultiHandler that permits multiplexing, Multiplexing::NONE + * is also rejected with a custom "handle_factory", alongside a raw + * CURLMOPT_PIPELINING cURL multi option, and combined with the raw + * CURLOPT_HTTP_VERSION, CURLOPT_HTTPAUTH (including the "auth" request + * option's "digest" and "ntlm" modes, which set it), CURLOPT_PROXYAUTH, + * CURLOPT_FOLLOWLOCATION, CURLOPT_HTTPHEADER, CURLOPT_ALTSVC, + * CURLOPT_ALTSVC_CTRL, or CURLOPT_PROXYTYPE cURL options. It is also + * rejected 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). + * + * 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) A callable that is invoked when the HTTP headers * of the response have been received but the body has not yet begun to @@ -189,6 +307,17 @@ final class RequestOptions */ public const ON_STATS = 'on_stats'; + /** + * on_trailers: (callable) A callable that is invoked by the built-in cURL + * handlers once per successful transfer, after the response body has been + * received, with an associative array of the parsed HTTP trailers followed + * by the response. Trailer field names are lowercased and grouped + * case-insensitively; values keep their wire order. Malformed trailer + * field lines are discarded before parsing. Trailer fields are reported + * separately from response headers and are never merged into the response. + */ + public const ON_TRAILERS = 'on_trailers'; + /** * progress: (callable) Defines a function to invoke when transfer * progress is made. The function accepts the following positional @@ -198,25 +327,34 @@ final class RequestOptions */ public const PROGRESS = 'progress'; + /** + * protocols: (non-empty-array, default=['http', 'https']) + * Allowed URI schemes. Built-in handlers accept only the case-sensitive + * values "http" and "https". + */ + 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). + * 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. */ public const PROXY = 'proxy'; /** - * query: (array|string) Associative array of query string values to add - * to the request. This option uses PHP's http_build_query() to create - * the string representation. Pass a string value if you need more - * control than what this method provides + * query: (array|string) Associative array of query string + * values to add to the request. This option uses PHP's http_build_query() + * to create the string representation. Pass a string value if you need + * more control than what this method provides */ public const QUERY = 'query'; /** - * 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. + * sink: (resource|string|\Plausible\Analytics\WP\Client\Lib\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. */ public const SINK = 'sink'; @@ -229,19 +367,32 @@ final class RequestOptions public const SYNCHRONOUS = 'synchronous'; /** - * ssl_key: (array|string) Specify the path to a file containing a private - * SSL key in PEM format. If a password is required, then set to an array - * containing the path to the SSL key in the first array element followed - * by the password required for the certificate in the second element. + * ssl_key: (array{0: string, 1?: string|null}|string) Specify the path to + * a private SSL key file. PEM is the default private key format. If a + * password is required, set ssl_key to an array containing the key path in + * 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. */ public const SSL_KEY = 'ssl_key'; /** - * stream: Set to true to attempt to stream a response rather than + * ssl_key_type: (string) Specify the SSL private key file type. + */ + public const SSL_KEY_TYPE = 'ssl_key_type'; + + /** + * stream: (bool) Set to true to attempt to stream a response rather than * download it all up-front. */ 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 @@ -253,24 +404,31 @@ final class RequestOptions public const VERIFY = 'verify'; /** - * timeout: (float, default=0) Float describing the timeout of the + * timeout: (int|float, default=0) Number describing the timeout of the * request in seconds. Use 0 to wait indefinitely (the default behavior). */ public const TIMEOUT = 'timeout'; /** - * read_timeout: (float, default=default_socket_timeout ini setting) Float describing - * the body read timeout, for stream requests. + * read_timeout: (int|float, default=default_socket_timeout ini setting) + * Number describing the body read timeout, for stream requests. */ public const READ_TIMEOUT = 'read_timeout'; /** - * version: (float) Specifies the HTTP protocol version to attempt to use. + * retries: (int) Current retry count used by the retry middleware. + */ + public const RETRIES = 'retries'; + + /** + * version: (string|int|float) Specifies the HTTP protocol version to attempt + * to use. */ public const VERSION = 'version'; /** - * force_ip_resolve: (bool) Force client to use only ipv4 or ipv6 protocol + * force_ip_resolve: (string) Set to "v4" to force IPv4 resolution or "v6" + * for IPv6 resolution when supported by the handler. */ public const FORCE_IP_RESOLVE = 'force_ip_resolve'; } diff --git a/src/Client/lib/Lib/GuzzleHttp/RetryMiddleware.php b/src/Client/lib/Lib/GuzzleHttp/RetryMiddleware.php index 6256e0c8..78313227 100644 --- a/src/Client/lib/Lib/GuzzleHttp/RetryMiddleware.php +++ b/src/Client/lib/Lib/GuzzleHttp/RetryMiddleware.php @@ -40,20 +40,26 @@ class RetryMiddleware * and returns the number of * milliseconds to delay. */ - public function __construct( callable $decider, callable $nextHandler, ?callable $delay = null ) + public function __construct(callable $decider, callable $nextHandler, ?callable $delay = null) { $this->decider = $decider; $this->nextHandler = $nextHandler; - $this->delay = $delay ?: __CLASS__.'::exponentialDelay'; + $this->delay = $delay ?: static function (int $retries): int { + return (int) 2 ** ($retries - 1) * 1000; + }; } /** * Default exponential backoff delay function. * * @return int milliseconds. + * + * @deprecated since 7.11, will be removed in 8.0. */ 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; } @@ -110,7 +116,7 @@ private function onRejected(RequestInterface $req, array $options): callable }; } - private function doRetry( RequestInterface $request, array $options, ?ResponseInterface $response = null ): PromiseInterface + private function doRetry(RequestInterface $request, array $options, ?ResponseInterface $response = null): PromiseInterface { $options['delay'] = ($this->delay)(++$options['retries'], $response, $request); diff --git a/src/Client/lib/Lib/GuzzleHttp/TransferStats.php b/src/Client/lib/Lib/GuzzleHttp/TransferStats.php index 79782666..5e76d42c 100644 --- a/src/Client/lib/Lib/GuzzleHttp/TransferStats.php +++ b/src/Client/lib/Lib/GuzzleHttp/TransferStats.php @@ -46,8 +46,8 @@ final class TransferStats */ public function __construct( RequestInterface $request, - ?ResponseInterface $response = null, - ?float $transferTime = null, + ?ResponseInterface $response = null, + ?float $transferTime = null, $handlerErrorData = null, array $handlerStats = [] ) { diff --git a/src/Client/lib/Lib/GuzzleHttp/TransportSharing.php b/src/Client/lib/Lib/GuzzleHttp/TransportSharing.php new file mode 100644 index 00000000..92172e58 --- /dev/null +++ b/src/Client/lib/Lib/GuzzleHttp/TransportSharing.php @@ -0,0 +1,14 @@ + + */ + 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): Promise\PromiseInterface)|null $handler + * @param array{max_host_connections?: int, max_total_connections?: int} $connectionCapOptions + * + * @return callable(RequestInterface, array): Promise\PromiseInterface + */ + private static function addStreamHandler(?callable $handler, string $sharingMode, bool $sharingRequired, array $connectionCapOptions): callable + { + $streamHandler = new StreamHandler(['transport_sharing' => $sharingMode] + $connectionCapOptions); + + if ($handler === null) { + return $streamHandler; + } + + if (!$sharingRequired) { + $handler = Proxy::wrapTlsFallback($handler, $streamHandler); + } + + return Proxy::wrapStreaming($handler, $streamHandler); } /** @@ -129,10 +264,12 @@ public static function defaultUserAgent(): string * * @throws \RuntimeException if no bundle can be found. * - * @deprecated Utils::defaultCaBundle will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. This method is not needed in PHP 5.6+. + * @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 { + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. This method is not needed in PHP 5.6+.', __METHOD__); + static $cached = null; static $cafiles = [ // Red Hat, CentOS, Fedora (provided by the ca-certificates package) @@ -175,15 +312,14 @@ public static function defaultCaBundle(): string 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: http://docs.guzzlephp.org/en/latest/clients.html#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. +supply the path on disk to a certificate bundle to the 'verify' request option: +https://github.com/guzzle/guzzle/blob/7.15/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.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.se/docs/sslcerts.html for more information. EOT ); } @@ -196,28 +332,61 @@ public static function normalizeHeaderKeys(array $headers): array { $result = []; foreach (\array_keys($headers) as $key) { - $result[\strtolower($key)] = $key; + $result[Psr7\Utils::asciiToLower((string) $key)] = $key; } return $result; } + /** + * @param mixed $protocols + * + * @return string[] + * + * @throws InvalidArgumentException + */ + public static function normalizeProtocols($protocols): array + { + if (!\is_array($protocols) || $protocols === []) { + throw new InvalidArgumentException('protocols must be a non-empty array of "http" and/or "https"'); + } + + $normalized = []; + + foreach ($protocols as $protocol) { + if (!\is_string($protocol)) { + throw new InvalidArgumentException('protocols must contain only strings'); + } + + if ($protocol !== 'http' && $protocol !== 'https') { + throw new InvalidArgumentException('protocols may only contain "http" and "https"'); + } + + $normalized[$protocol] = true; + } + + 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"). + * 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 match. - * 3. The area starts with "." and the area is the last part of the host. e.g. + * 2. An exact domain or IP literal match. + * 3. A bare domain matches itself and its subdomains. e.g. 'mit.edu' will + * match 'mit.edu' and 'foo.mit.edu'. + * 4. 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'. + * 5. 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[] $noProxyArray An array of host patterns. + * @param string[] $noProxyArray An array of host or CIDR patterns. * * @throws InvalidArgumentException */ @@ -227,28 +396,64 @@ public static function isHostInNoProxy(string $host, array $noProxyArray): bool throw new InvalidArgumentException('Empty host provided'); } - // Strip port if present. - [$host] = \explode(':', $host, 2); + $target = self::parseNoProxyHostString($host); + if ($target === null) { + return false; + } + + return self::matchesNoProxyList($target, $noProxyArray); + } - foreach ($noProxyArray as $area) { - // Always match on wildcards. - if ($area === '*') { - return true; - } + /** + * Returns true if the provided URI matches any of the no proxy areas. + * + * Matching follows the same rules as isHostInNoProxy(), with the + * addition that areas may carry a port (e.g. "example.com:8080" or + * "[::1]:8080") which is compared against the URI port (or the scheme + * default port when the URI has none). + * + * @param mixed $noProxy No-proxy host, host-and-port, or CIDR patterns. + * + * @internal + */ + public static function isUriInNoProxy(UriInterface $uri, $noProxy): bool + { + if (\is_string($noProxy)) { + $noProxy = \explode(',', $noProxy); + } + + if (!\is_array($noProxy)) { + return false; + } + + $target = self::parseNoProxyTarget($uri); + if ($target === null) { + return false; + } - if (empty($area)) { - // Don't match on empty values. + return self::matchesNoProxyList($target, $noProxy); + } + + /** + * @param array{type: string, value: string, port: int|null, matchesRoot: bool} $target + * @param array $noProxy + */ + private static function matchesNoProxyList(array $target, array $noProxy): bool + { + foreach ($noProxy as $area) { + if (!\is_string($area)) { continue; } - if ($area === $host) { - // Exact matches. + $area = \trim($area, " \n\r\t\0\x0B"); + + // Always match on wildcards. + if ($area === '*') { return true; } - // Special match if the area when prefixed with ".". Remove any - // existing leading "." and add a new leading ".". - $area = '.'.\ltrim($area, '.'); - if (\substr($host, -\strlen($area)) === $area) { + + $rule = self::parseNoProxyRule($area); + if ($rule !== null && self::noProxyRuleMatches($target, $rule)) { return true; } } @@ -256,6 +461,315 @@ public static function isHostInNoProxy(string $host, array $noProxyArray): bool return false; } + /** + * @return array{type: string, value: string, port: int|null, matchesRoot: bool}|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()), true); + } + + /** + * @return array{type: string, value: string, port: int|null, matchesRoot: bool}|null + */ + private static function parseNoProxyHostString(string $host): ?array + { + $hostAndPort = self::splitNoProxyHostAndPort($host); + if ($hostAndPort === null) { + return null; + } + + [$host] = $hostAndPort; + + return self::parseNoProxyHost($host, null, true); + } + + /** + * @return array{type: string, value: string, port: int|null, matchesRoot: bool}|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; + } + + if (\strpos($area, '/') !== false) { + return self::parseNoProxyCidrRule($area); + } + + $matchesRoot = true; + if ($area[0] === '.') { + $matchesRoot = false; + $area = \substr($area, 1); + } + + $hostAndPort = self::splitNoProxyHostAndPort($area); + if ($hostAndPort === null) { + return null; + } + + [$host, $port] = $hostAndPort; + + if ($host === '*') { + if (!$matchesRoot) { + return null; + } + + return [ + 'type' => 'wildcard', + 'value' => '*', + 'port' => $port, + 'matchesRoot' => true, + ]; + } + + $rule = self::parseNoProxyHost($host, $port, $matchesRoot); + if ($rule !== null && !$matchesRoot && $rule['type'] === 'ip') { + return null; + } + + return $rule; + } + + /** + * @return array{type: string, value: string, port: int|null, matchesRoot: bool}|null + */ + private static function parseNoProxyHost(string $host, ?int $port, bool $matchesRoot): ?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, + 'matchesRoot' => $matchesRoot, + ]; + } + + 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, + 'matchesRoot' => $matchesRoot, + ]; + } + + /** + * @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); + } + + /** + * @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, matchesRoot: bool} $target + * @param array{type: string, value: string, port?: int|null, matchesRoot?: bool, 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 (($rule['matchesRoot'] ?? false) && $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); + } + + 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. * @@ -270,9 +784,16 @@ public static function isHostInNoProxy(string $host, array $noProxyArray): bool * @throws InvalidArgumentException if the JSON cannot be decoded. * * @see https://www.php.net/manual/en/function.json-decode.php + * @deprecated Utils::jsonDecode() will be removed in guzzlehttp/guzzle:8.0. Use PHP's json_decode() instead. */ public static function jsonDecode(string $json, bool $assoc = false, int $depth = 512, int $options = 0) { + \trigger_deprecation('guzzlehttp/guzzle', '7.15', '%s() is deprecated and will be removed in 8.0. Use PHP\'s json_decode() instead.', __METHOD__); + + 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()); @@ -291,9 +812,12 @@ public static function jsonDecode(string $json, bool $assoc = false, int $depth * @throws InvalidArgumentException if the JSON cannot be encoded. * * @see https://www.php.net/manual/en/function.json-encode.php + * @deprecated Utils::jsonEncode() will be removed in guzzlehttp/guzzle:8.0. Use PHP's json_encode() instead. */ public static function jsonEncode($value, int $options = 0, int $depth = 512): string { + \trigger_deprecation('guzzlehttp/guzzle', '7.15', '%s() is deprecated and will be removed in 8.0. Use PHP\'s json_encode() instead.', __METHOD__); + $json = \json_encode($value, $options, $depth); if (\JSON_ERROR_NONE !== \json_last_error()) { throw new InvalidArgumentException('json_encode error: '.\json_last_error_msg()); @@ -316,6 +840,39 @@ 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.', + \get_debug_type($value) + ); + + return (int) $value; + } + + throw new InvalidArgumentException('idn_conversion must be true, false, null, or an integer IDNA_* bitmask'); + } + /** * @throws InvalidArgumentException * diff --git a/src/Client/lib/Lib/GuzzleHttp/functions.php b/src/Client/lib/Lib/GuzzleHttp/functions.php index 18083115..303b531c 100644 --- a/src/Client/lib/Lib/GuzzleHttp/functions.php +++ b/src/Client/lib/Lib/GuzzleHttp/functions.php @@ -11,11 +11,26 @@ * @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 Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. Use Utils::describeType instead. + * @deprecated describe_type will be removed in guzzlehttp/guzzle:8.0. Use get_debug_type() instead. */ function describe_type($input): string { - return Utils::describeType($input); + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use get_debug_type() instead.', __FUNCTION__); + + 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, " \n\r\t\0\x0B")); + } } /** @@ -24,10 +39,12 @@ function describe_type($input): string * @param iterable $lines Header lines array of strings in the following * format: "Name: Value" * - * @deprecated headers_from_lines will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. Use Utils::headersFromLines instead. + * @deprecated headers_from_lines will be removed in guzzlehttp/guzzle:8.0. Use Utils::headersFromLines instead. */ function headers_from_lines(iterable $lines): array { + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::headersFromLines() instead.', __FUNCTION__); + return Utils::headersFromLines($lines); } @@ -38,10 +55,12 @@ function headers_from_lines(iterable $lines): array * * @return resource * - * @deprecated debug_resource will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. Use Utils::debugResource instead. + * @deprecated debug_resource will be removed in guzzlehttp/guzzle:8.0. Use Utils::debugResource instead. */ function debug_resource($value = null) { + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::debugResource() instead.', __FUNCTION__); + return Utils::debugResource($value); } @@ -50,24 +69,28 @@ function debug_resource($value = null) * * The returned handler is not wrapped by any default middlewares. * - * @return callable(\Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface, array): \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface Returns the best handler for the given system. + * @return callable(\Plausible\Analytics\WP\Client\Lib\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 Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. Use Utils::chooseHandler instead. + * @deprecated choose_handler will be removed in guzzlehttp/guzzle:8.0. Use Utils::chooseHandler instead. */ function choose_handler(): callable { + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::chooseHandler() instead.', __FUNCTION__); + return Utils::chooseHandler(); } /** * Get the default User-Agent string to use with Guzzle. * - * @deprecated default_user_agent will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. Use Utils::defaultUserAgent instead. + * @deprecated default_user_agent will be removed in guzzlehttp/guzzle:8.0. Use Utils::defaultUserAgent instead. */ function default_user_agent(): string { + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::defaultUserAgent() instead.', __FUNCTION__); + return Utils::defaultUserAgent(); } @@ -84,21 +107,76 @@ function default_user_agent(): string * * @throws \RuntimeException if no bundle can be found. * - * @deprecated default_ca_bundle will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. This function is not needed in PHP 5.6+. + * @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(); + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. This function is not needed in PHP 5.6+.', __FUNCTION__); + + 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.15/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.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.se/docs/sslcerts.html for more information. +EOT + ); } /** * Creates an associative array of lowercase header names to the actual * header casing. * - * @deprecated normalize_header_keys will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. Use Utils::normalizeHeaderKeys instead. + * @deprecated normalize_header_keys will be removed in guzzlehttp/guzzle:8.0. Use Utils::normalizeHeaderKeys instead. */ function normalize_header_keys(array $headers): array { + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::normalizeHeaderKeys() instead.', __FUNCTION__); + return Utils::normalizeHeaderKeys($headers); } @@ -121,10 +199,12 @@ function normalize_header_keys(array $headers): array * * @throws Exception\InvalidArgumentException * - * @deprecated is_host_in_noproxy will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. Use Utils::isHostInNoProxy instead. + * @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 { + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::isHostInNoProxy() instead.', __FUNCTION__); + return Utils::isHostInNoProxy($host, $noProxyArray); } @@ -142,11 +222,23 @@ function is_host_in_noproxy(string $host, array $noProxyArray): bool * @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 Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. Use Utils::jsonDecode instead. + * @deprecated json_decode will be removed in guzzlehttp/guzzle:8.0. Use PHP's json_decode() instead. */ function json_decode(string $json, bool $assoc = false, int $depth = 512, int $options = 0) { - return Utils::jsonDecode($json, $assoc, $depth, $options); + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use PHP\'s json_decode() instead.', __FUNCTION__); + + if ($depth < 1) { + throw new Exception\InvalidArgumentException('json_decode error: Maximum stack depth exceeded'); + } + + $data = \json_decode($json, $assoc, $depth, $options); + if (\JSON_ERROR_NONE !== \json_last_error()) { + throw new Exception\InvalidArgumentException('json_decode error: '.\json_last_error_msg()); + } + + /** @var object|array|string|int|float|bool|null $data */ + return $data; } /** @@ -159,9 +251,18 @@ function json_decode(string $json, bool $assoc = false, int $depth = 512, int $o * @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 Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0. Use Utils::jsonEncode instead. + * @deprecated json_encode will be removed in guzzlehttp/guzzle:8.0. Use PHP's json_encode() instead. */ function json_encode($value, int $options = 0, int $depth = 512): string { - return Utils::jsonEncode($value, $options, $depth); + \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use PHP\'s json_encode() instead.', __FUNCTION__); + + /** @var positive-int $depth */ + $json = \json_encode($value, $options, $depth); + if (\JSON_ERROR_NONE !== \json_last_error()) { + throw new Exception\InvalidArgumentException('json_encode error: '.\json_last_error_msg()); + } + + /** @var non-empty-string $json */ + return $json; } diff --git a/src/Client/lib/Lib/Psr/Http/Message/UploadedFileFactoryInterface.php b/src/Client/lib/Lib/Psr/Http/Message/UploadedFileFactoryInterface.php index 8de0b5e5..4cba4404 100644 --- a/src/Client/lib/Lib/Psr/Http/Message/UploadedFileFactoryInterface.php +++ b/src/Client/lib/Lib/Psr/Http/Message/UploadedFileFactoryInterface.php @@ -15,10 +15,10 @@ interface UploadedFileFactoryInterface * * @param StreamInterface $stream Underlying stream representing the * uploaded file content. - * @param int $size in bytes + * @param int|null $size in bytes * @param int $error PHP file upload error - * @param string $clientFilename Filename as provided by the client, if any. - * @param string $clientMediaType Media type as provided by the client, if any. + * @param string|null $clientFilename Filename as provided by the client, if any. + * @param string|null $clientMediaType Media type as provided by the client, if any. * * @return UploadedFileInterface * @@ -26,9 +26,9 @@ interface UploadedFileFactoryInterface */ public function createUploadedFile( StreamInterface $stream, - ?int $size = null, + ?int $size = null, int $error = \UPLOAD_ERR_OK, - ?string $clientFilename = null, - ?string $clientMediaType = null + ?string $clientFilename = null, + ?string $clientMediaType = null ): UploadedFileInterface; } diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/LICENSE b/src/Client/lib/Lib/Symfony/Polyfill/Php80/LICENSE new file mode 100644 index 00000000..0ed3a246 --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/Php80.php b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Php80.php new file mode 100644 index 00000000..cbfebcc8 --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Php80.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Plausible\Analytics\WP\Client\Lib\Symfony\Polyfill\Php80; + +/** + * @author Ion Bazan + * @author Nico Oelgart + * @author Nicolas Grekas + * + * @internal + */ +final class Php80 +{ + public static function fdiv(float $dividend, float $divisor): float + { + return @($dividend / $divisor); + } + + public static function get_debug_type($value): string + { + switch (true) { + case null === $value: return 'null'; + case \is_bool($value): return 'bool'; + case \is_string($value): return 'string'; + case \is_array($value): return 'array'; + case \is_int($value): return 'int'; + case \is_float($value): return 'float'; + case \is_object($value): break; + case $value instanceof \__PHP_Incomplete_Class: return '__PHP_Incomplete_Class'; + default: + if (null === $type = @get_resource_type($value)) { + return 'unknown'; + } + + if ('Unknown' === $type) { + $type = 'closed'; + } + + return "resource ($type)"; + } + + $class = \get_class($value); + + if (false === strpos($class, '@')) { + return $class; + } + + return (get_parent_class($class) ?: key(class_implements($class)) ?: 'class').'@anonymous'; + } + + public static function get_resource_id($res): int + { + if (!\is_resource($res) && null === @get_resource_type($res)) { + throw new \TypeError(\sprintf('Argument 1 passed to get_resource_id() must be of the type resource, %s given', get_debug_type($res))); + } + + return (int) $res; + } + + public static function preg_last_error_msg(): string + { + switch (preg_last_error()) { + case \PREG_INTERNAL_ERROR: + return 'Internal error'; + case \PREG_BAD_UTF8_ERROR: + return 'Malformed UTF-8 characters, possibly incorrectly encoded'; + case \PREG_BAD_UTF8_OFFSET_ERROR: + return 'The offset did not correspond to the beginning of a valid UTF-8 code point'; + case \PREG_BACKTRACK_LIMIT_ERROR: + return 'Backtrack limit exhausted'; + case \PREG_RECURSION_LIMIT_ERROR: + return 'Recursion limit exhausted'; + case \PREG_JIT_STACKLIMIT_ERROR: + return 'JIT stack limit exhausted'; + case \PREG_NO_ERROR: + return 'No error'; + default: + return 'Unknown error'; + } + } + + public static function str_contains(string $haystack, string $needle): bool + { + return '' === $needle || false !== strpos($haystack, $needle); + } + + public static function str_starts_with(string $haystack, string $needle): bool + { + return 0 === strncmp($haystack, $needle, \strlen($needle)); + } + + public static function str_ends_with(string $haystack, string $needle): bool + { + if ('' === $needle || $needle === $haystack) { + return true; + } + + if ('' === $haystack) { + return false; + } + + $needleLength = \strlen($needle); + + return $needleLength <= \strlen($haystack) && 0 === substr_compare($haystack, $needle, -$needleLength); + } +} diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/PhpToken.php b/src/Client/lib/Lib/Symfony/Polyfill/Php80/PhpToken.php new file mode 100644 index 00000000..06c2c01e --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/PhpToken.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Plausible\Analytics\WP\Client\Lib\Symfony\Polyfill\Php80; + +/** + * @author Fedonyuk Anton + * + * @internal + */ +class PhpToken implements \Stringable +{ + /** + * @var int + */ + public $id; + + /** + * @var string + */ + public $text; + + /** + * @var -1|positive-int + */ + public $line; + + /** + * @var int + */ + public $pos; + + /** + * @param -1|positive-int $line + */ + public function __construct(int $id, string $text, int $line = -1, int $position = -1) + { + $this->id = $id; + $this->text = $text; + $this->line = $line; + $this->pos = $position; + } + + public function getTokenName(): ?string + { + if ('UNKNOWN' === $name = token_name($this->id)) { + $name = \strlen($this->text) > 1 || \ord($this->text) < 32 ? null : $this->text; + } + + return $name; + } + + /** + * @param int|string|array $kind + */ + public function is($kind): bool + { + foreach ((array) $kind as $value) { + if (\in_array($value, [$this->id, $this->text], true)) { + return true; + } + } + + return false; + } + + public function isIgnorable(): bool + { + return \in_array($this->id, [\T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT, \T_OPEN_TAG], true); + } + + public function __toString(): string + { + return (string) $this->text; + } + + /** + * @return list + */ + public static function tokenize(string $code, int $flags = 0): array + { + $line = 1; + $position = 0; + $tokens = token_get_all($code, $flags); + foreach ($tokens as $index => $token) { + if (\is_string($token)) { + $id = \ord($token); + $text = $token; + } else { + [$id, $text, $line] = $token; + } + $tokens[$index] = new static($id, $text, $line, $position); + $position += \strlen($text); + } + + return $tokens; + } +} diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/README.md b/src/Client/lib/Lib/Symfony/Polyfill/Php80/README.md new file mode 100644 index 00000000..3816c559 --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/README.md @@ -0,0 +1,25 @@ +Symfony Polyfill / Php80 +======================== + +This component provides features added to PHP 8.0 core: + +- [`Stringable`](https://php.net/stringable) interface +- [`fdiv`](https://php.net/fdiv) +- [`ValueError`](https://php.net/valueerror) class +- [`UnhandledMatchError`](https://php.net/unhandledmatcherror) class +- `FILTER_VALIDATE_BOOL` constant +- [`get_debug_type`](https://php.net/get_debug_type) +- [`PhpToken`](https://php.net/phptoken) class +- [`preg_last_error_msg`](https://php.net/preg_last_error_msg) +- [`str_contains`](https://php.net/str_contains) +- [`str_starts_with`](https://php.net/str_starts_with) +- [`str_ends_with`](https://php.net/str_ends_with) +- [`get_resource_id`](https://php.net/get_resource_id) + +More information can be found in the +[main Polyfill README](https://github.com/symfony/polyfill/blob/main/README.md). + +License +======= + +This library is released under the [MIT license](LICENSE). diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/Attribute.php b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/Attribute.php new file mode 100644 index 00000000..2b955423 --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/Attribute.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +#[Attribute(Attribute::TARGET_CLASS)] +final class Attribute +{ + public const TARGET_CLASS = 1; + public const TARGET_FUNCTION = 2; + public const TARGET_METHOD = 4; + public const TARGET_PROPERTY = 8; + public const TARGET_CLASS_CONSTANT = 16; + public const TARGET_PARAMETER = 32; + public const TARGET_ALL = 63; + public const IS_REPEATABLE = 64; + + /** @var int */ + public $flags; + + public function __construct(int $flags = self::TARGET_ALL) + { + $this->flags = $flags; + } +} diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/PhpToken.php b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/PhpToken.php new file mode 100644 index 00000000..d3bd6831 --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/PhpToken.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (\PHP_VERSION_ID < 80000 && extension_loaded('tokenizer')) { + class PhpToken extends Plausible\Analytics\WP\Client\Lib\Symfony\Polyfill\Php80\PhpToken + { + } +} diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/Stringable.php b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/Stringable.php new file mode 100644 index 00000000..7c62d750 --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/Stringable.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (\PHP_VERSION_ID < 80000) { + interface Stringable + { + /** + * @return string + */ + public function __toString(); + } +} diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/UnhandledMatchError.php b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/UnhandledMatchError.php new file mode 100644 index 00000000..01c6c6c8 --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/UnhandledMatchError.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (\PHP_VERSION_ID < 80000) { + class UnhandledMatchError extends Error + { + } +} diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/ValueError.php b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/ValueError.php new file mode 100644 index 00000000..783dbc28 --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/Resources/stubs/ValueError.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (\PHP_VERSION_ID < 80000) { + class ValueError extends Error + { + } +} diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/bootstrap.php b/src/Client/lib/Lib/Symfony/Polyfill/Php80/bootstrap.php new file mode 100644 index 00000000..bc0ef24b --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/bootstrap.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Plausible\Analytics\WP\Client\Lib\Symfony\Polyfill\Php80 as p; + +if (\PHP_VERSION_ID >= 80000) { + return; +} + +if (!defined('FILTER_VALIDATE_BOOL') && defined('FILTER_VALIDATE_BOOLEAN')) { + define('FILTER_VALIDATE_BOOL', \FILTER_VALIDATE_BOOLEAN); +} + +if (!function_exists('fdiv')) { + function fdiv(float $num1, float $num2): float { return p\Php80::fdiv($num1, $num2); } +} +if (!function_exists('preg_last_error_msg')) { + function preg_last_error_msg(): string { return p\Php80::preg_last_error_msg(); } +} +if (!function_exists('str_contains')) { + function str_contains(?string $haystack, ?string $needle): bool { return p\Php80::str_contains($haystack ?? '', $needle ?? ''); } +} +if (!function_exists('str_starts_with')) { + function str_starts_with(?string $haystack, ?string $needle): bool { return p\Php80::str_starts_with($haystack ?? '', $needle ?? ''); } +} +if (!function_exists('str_ends_with')) { + function str_ends_with(?string $haystack, ?string $needle): bool { return p\Php80::str_ends_with($haystack ?? '', $needle ?? ''); } +} +if (!function_exists('get_debug_type')) { + function get_debug_type($value): string { return p\Php80::get_debug_type($value); } +} +if (!function_exists('get_resource_id')) { + function get_resource_id($resource): int { return p\Php80::get_resource_id($resource); } +} diff --git a/src/Client/lib/Lib/Symfony/Polyfill/Php80/composer.json b/src/Client/lib/Lib/Symfony/Polyfill/Php80/composer.json new file mode 100644 index 00000000..a503b039 --- /dev/null +++ b/src/Client/lib/Lib/Symfony/Polyfill/Php80/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/polyfill-php80", + "type": "library", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "keywords": ["polyfill", "shim", "compatibility", "portable"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2" + }, + "autoload": { + "psr-4": { "Symfony\\Polyfill\\Php80\\": "" }, + "files": [ "bootstrap.php" ], + "classmap": [ "Resources/stubs" ] + }, + "minimum-stability": "dev", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + } +} diff --git a/src/polyfills.php b/src/polyfills.php new file mode 100644 index 00000000..c4c78d69 --- /dev/null +++ b/src/polyfills.php @@ -0,0 +1,43 @@ +