1
0
Fork 0

Refactor proxy handling to require https_proxy (#11915)

Composer has always allowed a single http_proxy (or CGI_HTTP_PROXY)
environment variable to be used for both HTTP and HTTPS requests. But
many other tools and libraries require scheme-specific values.

The landscape is already complicated by the use of and need for upper
and lower case values, so to bring matters inline with current practice
https_proxy is now required for HTTPS requests.

The new proxy handler incorporates a transition mechanism, which allows
http_proxy to be used for all requests when https_proxy is not set and
provides a `needsTransitionWarning` method for the main application.

Moving to scheme-specific environment variables means that a user may
set a single proxy for either HTTP or HTTPS requests. To accomodate this
situation during the transition period, an https_proxy value can be set
to an empty string which will prevent http_proxy being used for HTTPS
requests.
pull/11932/head
John Stevenson 2024-04-17 13:34:26 +01:00 committed by GitHub
parent 92f641ac3d
commit 3cc490d4c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 717 additions and 734 deletions

View File

@ -4008,16 +4008,6 @@ parameters:
count: 1
path: ../src/Composer/Util/Http/CurlDownloader.php
-
message: "#^Constant CURLOPT_PROXY_CAINFO not found\\.$#"
count: 1
path: ../src/Composer/Util/Http/CurlDownloader.php
-
message: "#^Constant CURLOPT_PROXY_CAPATH not found\\.$#"
count: 1
path: ../src/Composer/Util/Http/CurlDownloader.php
-
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 3
@ -4199,72 +4189,17 @@ parameters:
path: ../src/Composer/Util/Http/CurlDownloader.php
-
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyHelper.php
-
message: "#^Foreach overwrites \\$name with its value variable\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyHelper.php
-
message: "#^Implicit array creation is not allowed \\- variable \\$options does not exist\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyHelper.php
-
message: "#^Only booleans are allowed in a negated boolean, int\\<0, 65535\\>\\|false\\|null given\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyHelper.php
-
message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#"
count: 3
path: ../src/Composer/Util/Http/ProxyHelper.php
-
message: "#^Parameter \\#1 \\$proxy of static method Composer\\\\Util\\\\Http\\\\ProxyHelper\\:\\:formatParsedUrl\\(\\) expects array\\{scheme\\?\\: string, host\\: string, port\\?\\: int, user\\?\\: string, pass\\?\\: string\\}, array\\{scheme\\?\\: string, host\\?\\: string, port\\?\\: int\\<0, 65535\\>, user\\?\\: string, pass\\?\\: string, path\\?\\: string, query\\?\\: string, fragment\\?\\: string\\}\\|false given\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyHelper.php
-
message: "#^Only booleans are allowed in &&, Composer\\\\Util\\\\NoProxyPattern\\|null given on the left side\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyManager.php
-
message: "#^Only booleans are allowed in &&, string\\|null given on the right side\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyManager.php
-
message: "#^Only booleans are allowed in a negated boolean, Composer\\\\Util\\\\Http\\\\ProxyManager\\|null given\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyManager.php
-
message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#"
count: 4
path: ../src/Composer/Util/Http/ProxyManager.php
-
message: "#^Parameter \\#3 \\$formattedUrl of class Composer\\\\Util\\\\Http\\\\RequestProxy constructor expects string, string\\|null given\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyManager.php
-
message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#"
count: 1
path: ../src/Composer/Util/Http/ProxyManager.php
-
message: "#^Only booleans are allowed in an if condition, string given\\.$#"
message: "#^Method Composer\\\\Util\\\\Http\\\\RequestProxy::getCurlOptions\\(\\) should return array\\<int, int\\|string\\> but returns array\\<int\\|string, int\\|string\\>.$#"
count: 1
path: ../src/Composer/Util/Http/RequestProxy.php
-
message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#"
message: "#^Constant CURLOPT_PROXY_CAINFO not found\\.$#"
count: 1
path: ../src/Composer/Util/Http/RequestProxy.php
-
message: "#^Constant CURLOPT_PROXY_CAPATH not found\\.$#"
count: 1
path: ../src/Composer/Util/Http/RequestProxy.php
@ -5281,26 +5216,6 @@ parameters:
count: 1
path: ../tests/Composer/Test/Util/GitTest.php
-
message: "#^Call to method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with 'Composer\\\\\\\\Util\\\\\\\\Http…' and Composer\\\\Util\\\\Http\\\\ProxyManager will always evaluate to true\\.$#"
count: 1
path: ../tests/Composer/Test/Util/Http/ProxyManagerTest.php
-
message: "#^Call to method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with 'Composer\\\\\\\\Util\\\\\\\\Http…' and Composer\\\\Util\\\\Http\\\\RequestProxy will always evaluate to true\\.$#"
count: 1
path: ../tests/Composer/Test/Util/Http/ProxyManagerTest.php
-
message: "#^Only booleans are allowed in an if condition, string given\\.$#"
count: 1
path: ../tests/Composer/Test/Util/Http/ProxyManagerTest.php
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, string\\|null given\\.$#"
count: 1
path: ../tests/Composer/Test/Util/Http/ProxyManagerTest.php
-
message: "#^Only booleans are allowed in an if condition, string\\|false\\|null given\\.$#"
count: 1

View File

@ -41,6 +41,7 @@ use Composer\EventDispatcher\ScriptExecutionException;
use Composer\Exception\NoSslException;
use Composer\XdebugHandler\XdebugHandler;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Composer\Util\Http\ProxyManager;
/**
* The console application that handles the commands
@ -398,6 +399,13 @@ class Application extends BaseApplication
$io->writeError('<info>Memory usage: '.round(memory_get_usage() / 1024 / 1024, 2).'MiB (peak: '.round(memory_get_peak_usage() / 1024 / 1024, 2).'MiB), time: '.round(microtime(true) - $startTime, 2).'s');
}
if (ProxyManager::getInstance()->needsTransitionWarning()) {
$io->writeError('');
$io->writeError('<warning>Composer now requires separate proxy environment variables for HTTP and HTTPS requests.</warning>');
$io->writeError('<warning>Please set `https_proxy` in addition to your existing proxy environment variables.</warning>');
$io->writeError('<warning>This fallback (and warning) will be removed in Composer 2.8.0.</warning>');
}
return $result;
} catch (ScriptExecutionException $e) {
if ($this->getDisablePluginsByDefault() && $this->isRunningAsRoot() && !$this->io->isInteractive()) {

View File

@ -52,10 +52,6 @@ class CurlDownloader
private $maxRedirects = 20;
/** @var int */
private $maxRetries = 3;
/** @var ProxyManager */
private $proxyManager;
/** @var bool */
private $supportsSecureProxy;
/** @var array<int, string[]> */
protected $multiErrors = [
CURLM_BAD_HANDLE => ['CURLM_BAD_HANDLE', 'The passed-in handle is not a valid CURLM handle.'],
@ -117,11 +113,6 @@ class CurlDownloader
}
$this->authHelper = new AuthHelper($io, $config);
$this->proxyManager = ProxyManager::getInstance();
$version = curl_version();
$features = $version['features'];
$this->supportsSecureProxy = defined('CURL_VERSION_HTTPS_PROXY') && ($features & CURL_VERSION_HTTPS_PROXY);
}
/**
@ -245,26 +236,8 @@ class CurlDownloader
}
}
// Always set CURLOPT_PROXY to enable/disable proxy handling
// Any proxy authorization is included in the proxy url
$proxy = $this->proxyManager->getProxyForRequest($url);
if ($proxy->getUrl() !== '') {
curl_setopt($curlHandle, CURLOPT_PROXY, $proxy->getUrl());
}
// Curl needs certificate locations for secure proxies.
// CURLOPT_PROXY_SSL_VERIFY_PEER/HOST are enabled by default
if ($proxy->isSecure()) {
if (!$this->supportsSecureProxy) {
throw new TransportException('Connecting to a secure proxy using curl is not supported on PHP versions below 7.3.0.');
}
if (!empty($options['ssl']['cafile'])) {
curl_setopt($curlHandle, CURLOPT_PROXY_CAINFO, $options['ssl']['cafile']);
}
if (!empty($options['ssl']['capath'])) {
curl_setopt($curlHandle, CURLOPT_PROXY_CAPATH, $options['ssl']['capath']);
}
}
$proxy = ProxyManager::getInstance()->getProxyForRequest($url);
curl_setopt_array($curlHandle, $proxy->getCurlOptions($options['ssl'] ?? []));
$progress = array_diff_key(curl_getinfo($curlHandle), self::$timeInfo);
@ -283,7 +256,7 @@ class CurlDownloader
'primaryIp' => '',
];
$usingProxy = $proxy->getFormattedUrl(' using proxy (%s)');
$usingProxy = $proxy->getStatus(' using proxy (%s)');
$ifModified = false !== stripos(implode(',', $options['http']['header']), 'if-modified-since:') ? ' if modified' : '';
if ($attributes['redirects'] === 0 && $attributes['retries'] === 0) {
$this->io->writeError('Downloading ' . Url::sanitize($url) . $usingProxy . $ifModified, true, IOInterface::DEBUG);

View File

@ -1,183 +0,0 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Util\Http;
/**
* Proxy discovery and helper class
*
* @internal
* @author John Stevenson <john-stevenson@blueyonder.co.uk>
*/
class ProxyHelper
{
/**
* Returns proxy environment values
*
* @return array{string|null, string|null, string|null} httpProxy, httpsProxy, noProxy values
*
* @throws \RuntimeException on malformed url
*/
public static function getProxyData(): array
{
$httpProxy = null;
$httpsProxy = null;
// Handle http_proxy/HTTP_PROXY on CLI only for security reasons
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
if ($env = self::getProxyEnv(['http_proxy', 'HTTP_PROXY'], $name)) {
$httpProxy = self::checkProxy($env, $name);
}
}
// Prefer CGI_HTTP_PROXY if available
if ($env = self::getProxyEnv(['CGI_HTTP_PROXY'], $name)) {
$httpProxy = self::checkProxy($env, $name);
}
// Handle https_proxy/HTTPS_PROXY
if ($env = self::getProxyEnv(['https_proxy', 'HTTPS_PROXY'], $name)) {
$httpsProxy = self::checkProxy($env, $name);
} else {
$httpsProxy = $httpProxy;
}
// Handle no_proxy
$noProxy = self::getProxyEnv(['no_proxy', 'NO_PROXY'], $name);
return [$httpProxy, $httpsProxy, $noProxy];
}
/**
* Returns http context options for the proxy url
*
* @return array{http: array{proxy: string, header?: string}}
*/
public static function getContextOptions(string $proxyUrl): array
{
$proxy = parse_url($proxyUrl);
// Remove any authorization
$proxyUrl = self::formatParsedUrl($proxy, false);
$proxyUrl = str_replace(['http://', 'https://'], ['tcp://', 'ssl://'], $proxyUrl);
$options['http']['proxy'] = $proxyUrl;
// Handle any authorization
if (isset($proxy['user'])) {
$auth = rawurldecode($proxy['user']);
if (isset($proxy['pass'])) {
$auth .= ':' . rawurldecode($proxy['pass']);
}
$auth = base64_encode($auth);
// Set header as a string
$options['http']['header'] = "Proxy-Authorization: Basic {$auth}";
}
return $options;
}
/**
* Sets/unsets request_fulluri value in http context options array
*
* @param mixed[] $options Set by method
*/
public static function setRequestFullUri(string $requestUrl, array &$options): void
{
if ('http' === parse_url($requestUrl, PHP_URL_SCHEME)) {
$options['http']['request_fulluri'] = true;
} else {
unset($options['http']['request_fulluri']);
}
}
/**
* Searches $_SERVER for case-sensitive values
*
* @param non-empty-list<string> $names Names to search for
* @param string|null $name Name of any found value, you should only rely on it if the function returned a non-null value
*
* @return string|null The found value
*
* @param-out string $name
*/
private static function getProxyEnv(array $names, ?string &$name): ?string
{
foreach ($names as $name) {
if (!empty($_SERVER[$name])) {
return $_SERVER[$name];
}
}
return null;
}
/**
* Checks and formats a proxy url from the environment
*
* @throws \RuntimeException on malformed url
* @return string The formatted proxy url
*/
private static function checkProxy(string $proxyUrl, string $envName): string
{
$error = sprintf('malformed %s url', $envName);
$proxy = parse_url($proxyUrl);
// We need parse_url to have identified a host
if (!isset($proxy['host'])) {
throw new \RuntimeException($error);
}
$proxyUrl = self::formatParsedUrl($proxy, true);
// We need a port because streams and curl use different defaults
if (!parse_url($proxyUrl, PHP_URL_PORT)) {
throw new \RuntimeException($error);
}
return $proxyUrl;
}
/**
* Formats a url from its component parts
*
* @param array{scheme?: string, host: string, port?: int, user?: string, pass?: string} $proxy
*
* @return string The formatted value
*/
private static function formatParsedUrl(array $proxy, bool $includeAuth): string
{
$proxyUrl = isset($proxy['scheme']) ? strtolower($proxy['scheme']) . '://' : '';
if ($includeAuth && isset($proxy['user'])) {
$proxyUrl .= $proxy['user'];
if (isset($proxy['pass'])) {
$proxyUrl .= ':' . $proxy['pass'];
}
$proxyUrl .= '@';
}
$proxyUrl .= $proxy['host'];
if (isset($proxy['port'])) {
$proxyUrl .= ':' . $proxy['port'];
} elseif (strpos($proxyUrl, 'http://') === 0) {
$proxyUrl .= ':80';
} elseif (strpos($proxyUrl, 'https://') === 0) {
$proxyUrl .= ':443';
}
return $proxyUrl;
}
}

View File

@ -0,0 +1,119 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Util\Http;
/**
* @internal
* @author John Stevenson <john-stevenson@blueyonder.co.uk>
*/
class ProxyItem
{
/** @var non-empty-string */
private $url;
/** @var non-empty-string */
private $safeUrl;
/** @var ?non-empty-string */
private $curlAuth;
/** @var string */
private $optionsProxy;
/** @var ?non-empty-string */
private $optionsAuth;
/**
* @param string $proxyUrl The value from the environment
* @param string $envName The name of the environment variable
* @throws \RuntimeException If the proxy url is invalid
*/
public function __construct(string $proxyUrl, string $envName)
{
$syntaxError = sprintf('unsupported `%s` syntax', $envName);
if (strpbrk($proxyUrl, "\r\n\t") !== false) {
throw new \RuntimeException($syntaxError);
}
if (false === ($proxy = parse_url($proxyUrl))) {
throw new \RuntimeException($syntaxError);
}
if (!isset($proxy['host'])) {
throw new \RuntimeException('unable to find proxy host in ' . $envName);
}
$scheme = isset($proxy['scheme']) ? strtolower($proxy['scheme']) . '://' : 'http://';
$safe = '';
if (isset($proxy['user'])) {
$safe = '***';
$user = $proxy['user'];
$auth = rawurldecode($proxy['user']);
if (isset($proxy['pass'])) {
$safe .= ':***';
$user .= ':' . $proxy['pass'];
$auth .= ':' . rawurldecode($proxy['pass']);
}
$safe .= '@';
if (strlen($user) > 0) {
$this->curlAuth = $user;
$this->optionsAuth = 'Proxy-Authorization: Basic ' . base64_encode($auth);
}
}
$host = $proxy['host'];
$port = null;
if (isset($proxy['port'])) {
$port = $proxy['port'];
} elseif ($scheme === 'http://') {
$port = 80;
} elseif ($scheme === 'https://') {
$port = 443;
}
// We need a port because curl uses 1080 for http. Port 0 is reserved,
// but is considered valid depending on the PHP or Curl version.
if ($port === null) {
throw new \RuntimeException('unable to find proxy port in ' . $envName);
}
if ($port === 0) {
throw new \RuntimeException('port 0 is reserved in ' . $envName);
}
$this->url = sprintf('%s%s:%d', $scheme, $host, $port);
$this->safeUrl = sprintf('%s%s%s:%d', $scheme, $safe, $host, $port);
$scheme = str_replace(['http://', 'https://'], ['tcp://', 'ssl://'], $scheme);
$this->optionsProxy = sprintf('%s%s:%d', $scheme, $host, $port);
}
/**
* Returns a RequestProxy instance for the scheme of the request url
*
* @param string $scheme The scheme of the request url
*/
public function toRequestProxy(string $scheme): RequestProxy
{
$options = ['http' => ['proxy' => $this->optionsProxy]];
if ($this->optionsAuth !== null) {
$options['http']['header'] = $this->optionsAuth;
}
if ($scheme === 'http') {
$options['http']['request_fulluri'] = true;
}
return new RequestProxy($this->url, $this->curlAuth, $options, $this->safeUrl);
}
}

View File

@ -14,7 +14,6 @@ namespace Composer\Util\Http;
use Composer\Downloader\TransportException;
use Composer\Util\NoProxyPattern;
use Composer\Util\Url;
/**
* @internal
@ -24,40 +23,40 @@ class ProxyManager
{
/** @var ?string */
private $error = null;
/** @var array{http: ?string, https: ?string} */
private $fullProxy;
/** @var array{http: ?string, https: ?string} */
private $safeProxy;
/** @var array{http: array{options: mixed[]|null}, https: array{options: mixed[]|null}} */
private $streams;
/** @var bool */
private $hasProxy;
/** @var ?string */
private $info = null;
/** @var ?ProxyItem */
private $httpProxy = null;
/** @var ?ProxyItem */
private $httpsProxy = null;
/** @var ?NoProxyPattern */
private $noProxyHandler = null;
/** @var ?ProxyManager */
/** @var ?self */
private static $instance = null;
/** The following 3 properties can be removed after the transition period */
/** @var bool */
private $ignoreHttpsProxy = false;
/** @var bool */
private $isTransitional = false;
/** @var bool */
private $needsTransitionWarning = false;
private function __construct()
{
$this->fullProxy = $this->safeProxy = [
'http' => null,
'https' => null,
];
// this can be removed after the transition period
$this->isTransitional = true;
$this->streams['http'] = $this->streams['https'] = [
'options' => null,
];
$this->hasProxy = false;
$this->initProxyData();
try {
$this->getProxyData();
} catch (\RuntimeException $e) {
$this->error = $e->getMessage();
}
}
public static function getInstance(): ProxyManager
{
if (!self::$instance) {
if (self::$instance === null) {
self::$instance = new self();
}
@ -79,96 +78,116 @@ class ProxyManager
*/
public function getProxyForRequest(string $requestUrl): RequestProxy
{
if ($this->error) {
if ($this->error !== null) {
throw new TransportException('Unable to use a proxy: '.$this->error);
}
$scheme = parse_url($requestUrl, PHP_URL_SCHEME) ?: 'http';
$proxyUrl = '';
$options = [];
$formattedProxyUrl = '';
$scheme = (string) parse_url($requestUrl, PHP_URL_SCHEME);
$proxy = $this->getProxyForScheme($scheme);
if ($this->hasProxy && in_array($scheme, ['http', 'https'], true) && $this->fullProxy[$scheme]) {
if ($this->noProxy($requestUrl)) {
$formattedProxyUrl = 'excluded by no_proxy';
} else {
$proxyUrl = $this->fullProxy[$scheme];
$options = $this->streams[$scheme]['options'];
assert(is_array($options));
ProxyHelper::setRequestFullUri($requestUrl, $options);
$formattedProxyUrl = $this->safeProxy[$scheme];
if ($proxy === null) {
return RequestProxy::none();
}
if ($this->noProxy($requestUrl)) {
return RequestProxy::noProxy();
}
return $proxy->toRequestProxy($scheme);
}
/**
* Returns true if the user needs to set an https_proxy environment variable
*
* This method can be removed after the transition period
*/
public function needsTransitionWarning(): bool
{
return $this->needsTransitionWarning;
}
/**
* Returns a ProxyItem if one is set for the scheme, otherwise null
*/
private function getProxyForScheme(string $scheme): ?ProxyItem
{
if ($scheme === 'http') {
return $this->httpProxy;
}
if ($scheme === 'https') {
// this can be removed after the transition period
if ($this->isTransitional && $this->httpsProxy === null) {
if ($this->httpProxy !== null && !$this->ignoreHttpsProxy) {
$this->needsTransitionWarning = true;
return $this->httpProxy;
}
}
return $this->httpsProxy;
}
return null;
}
/**
* Finds proxy values from the environment and sets class properties
*/
private function getProxyData(): void
{
// Handle http_proxy/HTTP_PROXY on CLI only for security reasons
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
[$env, $name] = $this->getProxyEnv('http_proxy');
if ($env !== null) {
$this->httpProxy = new ProxyItem($env, $name);
}
}
return new RequestProxy($proxyUrl, $options, $formattedProxyUrl);
}
/**
* Returns true if a proxy is being used
*
* @return bool If false any error will be in $message
*/
public function isProxying(): bool
{
return $this->hasProxy;
}
/**
* Returns proxy configuration info which can be shown to the user
*
* @return string|null Safe proxy URL or an error message if setting up proxy failed or null if no proxy was configured
*/
public function getFormattedProxy(): ?string
{
return $this->hasProxy ? $this->info : $this->error;
}
/**
* Initializes proxy values from the environment
*/
private function initProxyData(): void
{
try {
[$httpProxy, $httpsProxy, $noProxy] = ProxyHelper::getProxyData();
} catch (\RuntimeException $e) {
$this->error = $e->getMessage();
return;
}
$info = [];
if ($httpProxy) {
$info[] = $this->setData($httpProxy, 'http');
}
if ($httpsProxy) {
$info[] = $this->setData($httpsProxy, 'https');
}
if ($this->hasProxy) {
$this->info = implode(', ', $info);
if ($noProxy) {
$this->noProxyHandler = new NoProxyPattern($noProxy);
// Handle cgi_http_proxy/CGI_HTTP_PROXY if needed
if ($this->httpProxy === null) {
[$env, $name] = $this->getProxyEnv('cgi_http_proxy');
if ($env !== null) {
$this->httpProxy = new ProxyItem($env, $name);
}
}
// Handle https_proxy/HTTPS_PROXY
[$env, $name] = $this->getProxyEnv('https_proxy');
if ($env !== null) {
$this->httpsProxy = new ProxyItem($env, $name);
}
// Handle no_proxy/NO_PROXY
[$env, $name] = $this->getProxyEnv('no_proxy');
if ($env !== null) {
$this->noProxyHandler = new NoProxyPattern($env);
}
}
/**
* Sets initial data
* Searches $_SERVER for case-sensitive values
*
* @param non-empty-string $url Proxy url
* @param 'http'|'https' $scheme Environment variable scheme
*
* @return non-empty-string
* @return array{0: string|null, 1: string} value, name
*/
private function setData($url, $scheme): string
private function getProxyEnv(string $envName): array
{
$safeProxy = Url::sanitize($url);
$this->fullProxy[$scheme] = $url;
$this->safeProxy[$scheme] = $safeProxy;
$this->streams[$scheme]['options'] = ProxyHelper::getContextOptions($url);
$this->hasProxy = true;
$names = [strtolower($envName), strtoupper($envName)];
return sprintf('%s=%s', $scheme, $safeProxy);
foreach ($names as $name) {
if (is_string($_SERVER[$name] ?? null)) {
if ($_SERVER[$name] !== '') {
return [$_SERVER[$name], $name];
}
// this can be removed after the transition period
if ($this->isTransitional && strtolower($name) === 'https_proxy') {
$this->ignoreHttpsProxy = true;
break;
}
}
}
return [null, ''];
}
/**
@ -176,6 +195,10 @@ class ProxyManager
*/
private function noProxy(string $requestUrl): bool
{
return $this->noProxyHandler && $this->noProxyHandler->test($requestUrl);
if ($this->noProxyHandler === null) {
return false;
}
return $this->noProxyHandler->test($requestUrl);
}
}

View File

@ -12,78 +12,157 @@
namespace Composer\Util\Http;
use Composer\Util\Url;
use Composer\Downloader\TransportException;
/**
* @internal
* @author John Stevenson <john-stevenson@blueyonder.co.uk>
*
* @phpstan-type contextOptions array{http: array{proxy: string, header?: string, request_fulluri?: bool}}
*/
class RequestProxy
{
/** @var mixed[] */
/** @var ?contextOptions */
private $contextOptions;
/** @var bool */
private $isSecure;
/** @var string */
private $formattedUrl;
/** @var string */
/** @var ?non-empty-string */
private $status;
/** @var ?non-empty-string */
private $url;
/** @var ?non-empty-string */
private $auth;
/**
* @param mixed[] $contextOptions
* @param ?non-empty-string $url The proxy url, without authorization
* @param ?non-empty-string $auth Authorization for curl
* @param ?contextOptions $contextOptions
* @param ?non-empty-string $status
*/
public function __construct(string $url, array $contextOptions, string $formattedUrl)
public function __construct(?string $url, ?string $auth, ?array $contextOptions, ?string $status)
{
$this->url = $url;
$this->auth = $auth;
$this->contextOptions = $contextOptions;
$this->formattedUrl = $formattedUrl;
$this->isSecure = 0 === strpos($url, 'https://');
$this->status = $status;
}
public static function none(): RequestProxy
{
return new self(null, null, null, null);
}
public static function noProxy(): RequestProxy
{
return new self(null, null, null, 'excluded by no_proxy');
}
/**
* Returns an array of context options
* Returns the context options to use for this request, otherwise null
*
* @return mixed[]
* @return ?contextOptions
*/
public function getContextOptions(): array
public function getContextOptions(): ?array
{
return $this->contextOptions;
}
/**
* Returns the safe proxy url from the last request
* Returns an array of curl proxy options
*
* @param string|null $format Output format specifier
* @return string Safe proxy, no proxy or empty
* @param array<string, string|int> $sslOptions
* @return array<int, string|int>
*/
public function getFormattedUrl(?string $format = ''): string
public function getCurlOptions(array $sslOptions): array
{
$result = '';
if ($this->formattedUrl) {
$format = $format ?: '%s';
$result = sprintf($format, $this->formattedUrl);
if ($this->isSecure() && !$this->supportsSecureProxy()) {
throw new TransportException('Cannot use an HTTPS proxy. PHP >= 7.3 and cUrl >= 7.52.0 are required.');
}
return $result;
// Always set a proxy url, even an empty value, because it tells curl
// to ignore proxy environment variables
$options = [CURLOPT_PROXY => (string) $this->url];
// If using a proxy, tell curl to ignore no_proxy environment variables
if ($this->url !== null) {
$options[CURLOPT_NOPROXY] = '';
}
// Set any authorization
if ($this->auth !== null) {
$options[CURLOPT_PROXYAUTH] = CURLAUTH_BASIC;
$options[CURLOPT_PROXYUSERPWD] = $this->auth;
}
if ($this->isSecure()) {
if (isset($sslOptions['cafile'])) {
$options[CURLOPT_PROXY_CAINFO] = $sslOptions['cafile'];
}
if (isset($sslOptions['capath'])) {
$options[CURLOPT_PROXY_CAPATH] = $sslOptions['capath'];
}
}
return $options;
}
/**
* Returns the proxy url
* Returns proxy info associated with this request
*
* @return string Proxy url or empty
* An empty return value means that the user has not set a proxy.
* A non-empty value will either be the sanitized proxy url if a proxy is
* required, or a message indicating that a no_proxy value has disabled the
* proxy.
*
* @param ?string $format Output format specifier
*/
public function getUrl(): string
public function getStatus(?string $format = null): string
{
return $this->url;
if ($this->status === null) {
return '';
}
$format = $format ?? '%s';
if (strpos($format, '%s') !== false) {
return sprintf($format, $this->status);
}
throw new \InvalidArgumentException('String format specifier is missing');
}
/**
* Returns true if this is a secure-proxy
* Returns true if the request url has been excluded by a no_proxy value
*
* @return bool False if not secure or there is no proxy
* A false value can also mean that the user has not set a proxy.
*/
public function isExcludedByNoProxy(): bool
{
return $this->status !== null && $this->url === null;
}
/**
* Returns true if this is a secure (HTTPS) proxy
*
* A false value means that this is either an HTTP proxy, or that a proxy
* is not required for this request, or that the user has not set a proxy.
*/
public function isSecure(): bool
{
return $this->isSecure;
return 0 === strpos((string) $this->url, 'https://');
}
/**
* Returns true if an HTTPS proxy can be used.
*
* This depends on PHP7.3+ for CURL_VERSION_HTTPS_PROXY
* and curl including the feature (from version 7.52.0)
*/
public function supportsSecureProxy(): bool
{
if (false === ($version = curl_version()) || !defined('CURL_VERSION_HTTPS_PROXY')) {
return false;
}
$features = $version['features'];
return (bool) ($features & CURL_VERSION_HTTPS_PROXY);
}
}

View File

@ -64,8 +64,6 @@ class RemoteFilesystem
private $redirects;
/** @var int */
private $maxRedirects = 20;
/** @var ProxyManager */
private $proxyManager;
/**
* Constructor.
@ -91,7 +89,6 @@ class RemoteFilesystem
$this->options = array_replace_recursive($this->options, $options);
$this->config = $config;
$this->authHelper = $authHelper ?? new AuthHelper($io, $config);
$this->proxyManager = ProxyManager::getInstance();
}
/**
@ -276,8 +273,8 @@ class RemoteFilesystem
$ctx = StreamContextFactory::getContext($fileUrl, $options, ['notification' => [$this, 'callbackGet']]);
$proxy = $this->proxyManager->getProxyForRequest($fileUrl);
$usingProxy = $proxy->getFormattedUrl(' using proxy (%s)');
$proxy = ProxyManager::getInstance()->getProxyForRequest($fileUrl);
$usingProxy = $proxy->getStatus(' using proxy (%s)');
$this->io->writeError((strpos($origFileUrl, 'http') === 0 ? 'Downloading ' : 'Reading ') . Url::sanitize($origFileUrl) . $usingProxy, true, IOInterface::DEBUG);
unset($origFileUrl, $proxy, $usingProxy);

View File

@ -76,7 +76,8 @@ final class StreamContextFactory
// Add stream proxy options if there is a proxy
if (!$forCurl) {
$proxy = ProxyManager::getInstance()->getProxyForRequest($url);
if ($proxyOptions = $proxy->getContextOptions()) {
$proxyOptions = $proxy->getContextOptions();
if ($proxyOptions !== null) {
$isHttpsRequest = 0 === strpos($url, 'https://');
if ($proxy->isSecure()) {

View File

@ -1,201 +0,0 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Test\Util\Http;
use Composer\Util\Http\ProxyHelper;
use Composer\Test\TestCase;
class ProxyHelperTest extends TestCase
{
protected function setUp(): void
{
unset(
$_SERVER['HTTP_PROXY'],
$_SERVER['http_proxy'],
$_SERVER['HTTPS_PROXY'],
$_SERVER['https_proxy'],
$_SERVER['NO_PROXY'],
$_SERVER['no_proxy'],
$_SERVER['CGI_HTTP_PROXY']
);
}
protected function tearDown(): void
{
parent::tearDown();
unset(
$_SERVER['HTTP_PROXY'],
$_SERVER['http_proxy'],
$_SERVER['HTTPS_PROXY'],
$_SERVER['https_proxy'],
$_SERVER['NO_PROXY'],
$_SERVER['no_proxy'],
$_SERVER['CGI_HTTP_PROXY']
);
}
/**
* @dataProvider dataMalformed
*/
public function testThrowsOnMalformedUrl(string $url): void
{
$_SERVER['http_proxy'] = $url;
self::expectException('RuntimeException');
ProxyHelper::getProxyData();
}
public static function dataMalformed(): array
{
return [
'no-host' => ['localhost'],
'no-port' => ['scheme://localhost'],
];
}
/**
* @dataProvider dataFormatting
*/
public function testUrlFormatting(string $url, string $expected): void
{
$_SERVER['http_proxy'] = $url;
[$httpProxy, $httpsProxy, $noProxy] = ProxyHelper::getProxyData();
$this->assertSame($expected, $httpProxy);
}
public static function dataFormatting(): array
{
// url, expected
return [
'lowercases-scheme' => ['HTTP://proxy.com:8888', 'http://proxy.com:8888'],
'adds-http-port' => ['http://proxy.com', 'http://proxy.com:80'],
'adds-https-port' => ['https://proxy.com', 'https://proxy.com:443'],
];
}
/**
* @dataProvider dataCaseOverrides
*
* @param array<string, mixed> $server
*/
public function testLowercaseOverridesUppercase(array $server, string $expected, int $index): void
{
$_SERVER = array_merge($_SERVER, $server);
$list = ProxyHelper::getProxyData();
$this->assertSame($expected, $list[$index]);
}
public static function dataCaseOverrides(): array
{
// server, expected, list index
return [
[['HTTP_PROXY' => 'http://upper.com', 'http_proxy' => 'http://lower.com'], 'http://lower.com:80', 0],
[['HTTPS_PROXY' => 'http://upper.com', 'https_proxy' => 'http://lower.com'], 'http://lower.com:80', 1],
[['NO_PROXY' => 'upper.com', 'no_proxy' => 'lower.com'], 'lower.com', 2],
];
}
/**
* @dataProvider dataCGIOverrides
*
* @param array<string, mixed> $server
*/
public function testCGIUpperCaseOverridesHttp(array $server, string $expected, int $index): void
{
$_SERVER = array_merge($_SERVER, $server);
$list = ProxyHelper::getProxyData();
$this->assertSame($expected, $list[$index]);
}
public static function dataCGIOverrides(): array
{
// server, expected, list index
return [
[['http_proxy' => 'http://http.com', 'CGI_HTTP_PROXY' => 'http://cgi.com'], 'http://cgi.com:80', 0],
[['http_proxy' => 'http://http.com', 'cgi_http_proxy' => 'http://cgi.com'], 'http://http.com:80', 0],
];
}
public function testNoHttpsProxyUsesHttpProxy(): void
{
$_SERVER['http_proxy'] = 'http://http.com';
[$httpProxy, $httpsProxy, $noProxy] = ProxyHelper::getProxyData();
$this->assertSame('http://http.com:80', $httpsProxy);
}
public function testNoHttpProxyDoesNotUseHttpsProxy(): void
{
$_SERVER['https_proxy'] = 'http://https.com';
[$httpProxy, $httpsProxy, $noProxy] = ProxyHelper::getProxyData();
$this->assertSame(null, $httpProxy);
}
/**
* @dataProvider dataContextOptions
*
* @param array<string, string> $expected
*
* @phpstan-param array{http: array{proxy: string, header?: string}} $expected
*/
public function testGetContextOptions(string $url, array $expected): void
{
$this->assertEquals($expected, ProxyHelper::getContextOptions($url));
}
public static function dataContextOptions(): array
{
// url, expected
return [
['http://proxy.com', ['http' => [
'proxy' => 'tcp://proxy.com:80',
]]],
['https://proxy.com', ['http' => [
'proxy' => 'ssl://proxy.com:443',
]]],
['http://user:p%40ss@proxy.com', ['http' => [
'proxy' => 'tcp://proxy.com:80',
'header' => 'Proxy-Authorization: Basic dXNlcjpwQHNz',
]]],
];
}
/**
* @dataProvider dataRequestFullUri
*
* @param mixed[] $expected
*/
public function testSetRequestFullUri(string $requestUrl, array $expected): void
{
$options = [];
ProxyHelper::setRequestFullUri($requestUrl, $options);
$this->assertEquals($expected, $options);
}
public static function dataRequestFullUri(): array
{
$options = ['http' => ['request_fulluri' => true]];
// $requestUrl, expected
return [
'http' => ['http://repo.org', $options],
'https' => ['https://repo.org', []],
'no-scheme' => ['repo.org', []],
];
}
}

View File

@ -0,0 +1,72 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Test\Util\Http;
use Composer\Util\Http\ProxyItem;
use Composer\Test\TestCase;
class ProxyItemTest extends TestCase
{
/**
* @dataProvider dataMalformed
*/
public function testThrowsOnMalformedUrl(string $url): void
{
self::expectException('RuntimeException');
$proxyItem = new ProxyItem($url, 'http_proxy');
}
/**
* @return array<string, array<string>>
*/
public static function dataMalformed(): array
{
return [
'ws-r' => ["http://user\rname@localhost:80"],
'ws-n' => ["http://user\nname@localhost:80"],
'ws-t' => ["http://user\tname@localhost:80"],
'no-host' => ['localhost'],
'no-port' => ['scheme://localhost'],
'port-0' => ['http://localhost:0'],
'port-big' => ['http://localhost:65536'],
];
}
/**
* @dataProvider dataFormatting
*/
public function testUrlFormatting(string $url, string $expected): void
{
$proxyItem = new ProxyItem($url, 'http_proxy');
$proxy = $proxyItem->toRequestProxy('http');
self::assertSame($expected, $proxy->getStatus());
}
/**
* @return array<string, array<string>>
*/
public static function dataFormatting(): array
{
// url, expected
return [
'none' => ['http://proxy.com:8888', 'http://proxy.com:8888'],
'lowercases-scheme' => ['HTTP://proxy.com:8888', 'http://proxy.com:8888'],
'adds-http-scheme' => ['proxy.com:80', 'http://proxy.com:80'],
'adds-http-port' => ['http://proxy.com', 'http://proxy.com:80'],
'adds-https-port' => ['https://proxy.com', 'https://proxy.com:443'],
'removes-user' => ['http://user@proxy.com:6180', 'http://***@proxy.com:6180'],
'removes-user-pass' => ['http://user:p%40ss@proxy.com:6180', 'http://***:***@proxy.com:6180'],
];
}
}

View File

@ -15,8 +15,16 @@ namespace Composer\Test\Util\Http;
use Composer\Util\Http\ProxyManager;
use Composer\Test\TestCase;
/**
* @phpstan-import-type contextOptions from \Composer\Util\Http\RequestProxy
*/
class ProxyManagerTest extends TestCase
{
// isTransitional can be removed after the transition period
/** @var bool */
private $isTransitional = true;
protected function setUp(): void
{
unset(
@ -26,7 +34,8 @@ class ProxyManagerTest extends TestCase
$_SERVER['https_proxy'],
$_SERVER['NO_PROXY'],
$_SERVER['no_proxy'],
$_SERVER['CGI_HTTP_PROXY']
$_SERVER['CGI_HTTP_PROXY'],
$_SERVER['cgi_http_proxy']
);
ProxyManager::reset();
}
@ -41,7 +50,8 @@ class ProxyManagerTest extends TestCase
$_SERVER['https_proxy'],
$_SERVER['NO_PROXY'],
$_SERVER['no_proxy'],
$_SERVER['CGI_HTTP_PROXY']
$_SERVER['CGI_HTTP_PROXY'],
$_SERVER['cgi_http_proxy']
);
ProxyManager::reset();
}
@ -49,54 +59,137 @@ class ProxyManagerTest extends TestCase
public function testInstantiation(): void
{
$originalInstance = ProxyManager::getInstance();
$this->assertInstanceOf('Composer\Util\Http\ProxyManager', $originalInstance);
$sameInstance = ProxyManager::getInstance();
$this->assertTrue($originalInstance === $sameInstance);
self::assertTrue($originalInstance === $sameInstance);
ProxyManager::reset();
$newInstance = ProxyManager::getInstance();
$this->assertFalse($sameInstance === $newInstance);
self::assertFalse($sameInstance === $newInstance);
}
public function testGetProxyForRequestThrowsOnBadProxyUrl(): void
{
$_SERVER['http_proxy'] = 'localhost';
$proxyManager = ProxyManager::getInstance();
self::expectException('Composer\Downloader\TransportException');
$proxyManager->getProxyForRequest('http://example.com');
}
/**
* @dataProvider dataRequest
* @dataProvider dataCaseOverrides
*
* @param array<string, mixed> $server
* @param mixed[] $expectedOptions
* @param non-empty-string $url
* @param array<string, string> $server
* @param non-empty-string $url
*/
public function testGetProxyForRequest(array $server, string $url, string $expectedUrl, array $expectedOptions, bool $expectedSecure, string $expectedMessage): void
public function testLowercaseOverridesUppercase(array $server, string $url, string $expectedUrl): void
{
$_SERVER = array_merge($_SERVER, $server);
$proxyManager = ProxyManager::getInstance();
$proxy = $proxyManager->getProxyForRequest($url);
$this->assertInstanceOf('Composer\Util\Http\RequestProxy', $proxy);
$this->assertSame($expectedUrl, $proxy->getUrl());
$this->assertSame($expectedOptions, $proxy->getContextOptions());
$this->assertSame($expectedSecure, $proxy->isSecure());
$message = $proxy->getFormattedUrl();
if ($expectedMessage) {
$condition = stripos($message, $expectedMessage) !== false;
} else {
$condition = $expectedMessage === $message;
}
$this->assertTrue($condition, 'lastProxy check');
self::assertSame($expectedUrl, $proxy->getStatus());
}
/**
* @return list<array{0: array<string, string>, 1: string, 2: string}>
*/
public static function dataCaseOverrides(): array
{
// server, url, expectedUrl
return [
[['HTTP_PROXY' => 'http://upper.com', 'http_proxy' => 'http://lower.com'], 'http://repo.org', 'http://lower.com:80'],
[['CGI_HTTP_PROXY' => 'http://upper.com', 'cgi_http_proxy' => 'http://lower.com'], 'http://repo.org', 'http://lower.com:80'],
[['HTTPS_PROXY' => 'http://upper.com', 'https_proxy' => 'http://lower.com'], 'https://repo.org', 'http://lower.com:80'],
];
}
/**
* @dataProvider dataCGIProxy
*
* @param array<string, string> $server
*/
public function testCGIProxyIsOnlyUsedWhenNoHttpProxy(array $server, string $expectedUrl): void
{
$_SERVER = array_merge($_SERVER, $server);
$proxyManager = ProxyManager::getInstance();
$proxy = $proxyManager->getProxyForRequest('http://repo.org');
self::assertSame($expectedUrl, $proxy->getStatus());
}
/**
* @return list<array{0: array<string, string>, 1: string}>
*/
public static function dataCGIProxy(): array
{
// server, expectedUrl
return [
[['CGI_HTTP_PROXY' => 'http://cgi.com:80'], 'http://cgi.com:80'],
[['http_proxy' => 'http://http.com:80', 'CGI_HTTP_PROXY' => 'http://cgi.com:80'], 'http://http.com:80'],
];
}
public function testNoHttpProxyDoesNotUseHttpsProxy(): void
{
$_SERVER['https_proxy'] = 'https://proxy.com:443';
$proxyManager = ProxyManager::getInstance();
$proxy = $proxyManager->getProxyForRequest('http://repo.org');
self::assertSame('', $proxy->getStatus());
}
public function testNoHttpsProxyDoesNotUseHttpProxy(): void
{
$_SERVER['http_proxy'] = 'http://proxy.com:80';
// This can be removed after the transition period.
// An empty https_proxy value prevents using any http_proxy
if ($this->isTransitional) {
$_SERVER['https_proxy'] = '';
}
$proxyManager = ProxyManager::getInstance();
$proxy = $proxyManager->getProxyForRequest('https://repo.org');
self::assertSame('', $proxy->getStatus());
}
/**
* This test can be removed after the transition period
*/
public function testTransitional(): void
{
$_SERVER['http_proxy'] = 'http://proxy.com:80';
$proxyManager = ProxyManager::getInstance();
$proxy = $proxyManager->getProxyForRequest('https://repo.org');
self::assertSame('http://proxy.com:80', $proxy->getStatus());
self::assertTrue($proxyManager->needsTransitionWarning());
}
/**
* @dataProvider dataRequest
*
* @param array<string, string> $server
* @param non-empty-string $url
* @param ?contextOptions $options
*/
public function testGetProxyForRequest(array $server, string $url, ?array $options, string $status, bool $excluded): void
{
$_SERVER = array_merge($_SERVER, $server);
$proxyManager = ProxyManager::getInstance();
$proxy = $proxyManager->getProxyForRequest($url);
self::assertSame($options, $proxy->getContextOptions());
self::assertSame($status, $proxy->getStatus());
self::assertSame($excluded, $proxy->isExcludedByNoProxy());
}
/**
* Tests context options. curl options are tested in RequestProxyTest.php
*
* @return list<array{0: array<string, string>, 1: string, 2: ?contextOptions, 3: string, 4: bool}>
*/
public static function dataRequest(): array
{
$server = [
@ -105,68 +198,26 @@ class ProxyManagerTest extends TestCase
'no_proxy' => 'other.repo.org',
];
// server, url, expectedUrl, expectedOptions, expectedSecure, expectedMessage
// server, url, options, status, excluded
return [
[[], 'http://repo.org', '', [], false, ''],
[$server, 'http://repo.org', 'http://user:p%40ss@proxy.com:80',
[[], 'http://repo.org', null, '', false],
[$server, 'http://repo.org',
['http' => [
'proxy' => 'tcp://proxy.com:80',
'header' => 'Proxy-Authorization: Basic dXNlcjpwQHNz',
'request_fulluri' => true,
]],
'http://***:***@proxy.com:80',
false,
'http://user:***@proxy.com:80',
],
[
$server, 'https://repo.org', 'https://proxy.com:443',
[$server, 'https://repo.org',
['http' => [
'proxy' => 'ssl://proxy.com:443',
]],
true,
'https://proxy.com:443',
false,
],
[$server, 'https://other.repo.org', '', [], false, 'no_proxy'],
];
}
/**
* @dataProvider dataStatus
*
* @param array<string, mixed> $server
*/
public function testGetStatus(array $server, bool $expectedStatus, ?string $expectedMessage): void
{
$_SERVER = array_merge($_SERVER, $server);
$proxyManager = ProxyManager::getInstance();
$status = $proxyManager->isProxying();
$message = $proxyManager->getFormattedProxy();
$this->assertSame($expectedStatus, $status);
if ($expectedMessage !== null) {
$condition = stripos($message, $expectedMessage) !== false;
} else {
$condition = $expectedMessage === $message;
}
$this->assertTrue($condition, 'message check');
}
public static function dataStatus(): array
{
// server, expectedStatus, expectedMessage
return [
[[], false, null],
[['http_proxy' => 'localhost'], false, 'malformed'],
[
['http_proxy' => 'http://user:p%40ss@proxy.com:80'],
true,
'http=http://user:***@proxy.com:80',
],
[
['http_proxy' => 'proxy.com:80', 'https_proxy' => 'proxy.com:80'],
true,
'http=proxy.com:80, https=proxy.com:80',
],
[$server, 'https://other.repo.org', null, 'excluded by no_proxy', true],
];
}
}

View File

@ -17,45 +17,174 @@ use Composer\Test\TestCase;
class RequestProxyTest extends TestCase
{
/**
* @dataProvider dataSecure
*/
public function testIsSecure(string $url, bool $expectedSecure): void
public function testFactoryNone(): void
{
$proxy = new RequestProxy($url, [], '');
$proxy = RequestProxy::none();
$this->assertSame($expectedSecure, $proxy->isSecure());
$options = extension_loaded('curl') ? [CURLOPT_PROXY => ''] : [];
self::assertSame($options, $proxy->getCurlOptions([]));
self::assertNull($proxy->getContextOptions());
self::assertSame('', $proxy->getStatus());
}
public function testFactoryNoProxy(): void
{
$proxy = RequestProxy::noProxy();
$options = extension_loaded('curl') ? [CURLOPT_PROXY => ''] : [];
self::assertSame($options, $proxy->getCurlOptions([]));
self::assertNull($proxy->getContextOptions());
self::assertSame('excluded by no_proxy', $proxy->getStatus());
}
/**
* @dataProvider dataSecure
*
* @param ?non-empty-string $url
*/
public function testIsSecure(?string $url, bool $expected): void
{
$proxy = new RequestProxy($url, null, null, null);
self::assertSame($expected, $proxy->isSecure());
}
/**
* @return array<string, array{0: ?non-empty-string, 1: bool}>
*/
public static function dataSecure(): array
{
// url, secure
// url, expected
return [
'basic' => ['http://proxy.com:80', false],
'secure' => ['https://proxy.com:443', true],
'none' => ['', false],
'none' => [null, false],
];
}
/**
* @dataProvider dataProxyUrl
*/
public function testGetFormattedUrlFormat(string $url, string $format, string $expected): void
public function testGetStatusThrowsOnBadFormatSpecifier(): void
{
$proxy = new RequestProxy($url, [], $url);
$message = $proxy->getFormattedUrl($format);
$this->assertSame($expected, $message);
$proxy = new RequestProxy('http://proxy.com:80', null, null, 'http://proxy.com:80');
self::expectException('InvalidArgumentException');
$proxy->getStatus('using proxy');
}
public static function dataProxyUrl(): array
/**
* @dataProvider dataStatus
*
* @param ?non-empty-string $url
*/
public function testGetStatus(?string $url, ?string $format, string $expected): void
{
$proxy = new RequestProxy($url, null, null, $url);
if ($format === null) {
// try with and without optional param
self::assertSame($expected, $proxy->getStatus());
self::assertSame($expected, $proxy->getStatus($format));
} else {
self::assertSame($expected, $proxy->getStatus($format));
}
}
/**
* @return array<string, array{0: ?non-empty-string, 1: ?string, 2: string}>
*/
public static function dataStatus(): array
{
$format = 'proxy (%s)';
// url, format, expected
return [
['', $format, ''],
['http://proxy.com:80', $format, 'proxy (http://proxy.com:80)'],
'no-proxy' => [null, $format, ''],
'null-format' => ['http://proxy.com:80', null, 'http://proxy.com:80'],
'with-format' => ['http://proxy.com:80', $format, 'proxy (http://proxy.com:80)'],
];
}
/**
* This test avoids HTTPS proxies so that it can be run on PHP < 7.3
*
* @requires extension curl
* @dataProvider dataCurlOptions
*
* @param ?non-empty-string $url
* @param ?non-empty-string $auth
* @param array<int, string|int> $expected
*/
public function testGetCurlOptions(?string $url, ?string $auth, array $expected): void
{
$proxy = new RequestProxy($url, $auth, null, null);
self::assertSame($expected, $proxy->getCurlOptions([]));
}
/**
* @return list<array{0: ?string, 1: ?string, 2: array<int, string|int>}>
*/
public static function dataCurlOptions(): array
{
// url, auth, expected
return [
[null, null, [CURLOPT_PROXY => '']],
['http://proxy.com:80', null,
[
CURLOPT_PROXY => 'http://proxy.com:80',
CURLOPT_NOPROXY => '',
],
],
['http://proxy.com:80', 'user:p%40ss',
[
CURLOPT_PROXY => 'http://proxy.com:80',
CURLOPT_NOPROXY => '',
CURLOPT_PROXYAUTH => CURLAUTH_BASIC,
CURLOPT_PROXYUSERPWD => 'user:p%40ss',
],
],
];
}
/**
* @requires PHP >= 7.3.0
* @requires extension curl >= 7.52.0
* @dataProvider dataCurlSSLOptions
*
* @param non-empty-string $url
* @param ?non-empty-string $auth
* @param array<string, string> $sslOptions
* @param array<int, string|int> $expected
*/
public function testGetCurlOptionsWithSSL(string $url, ?string $auth, array $sslOptions, array $expected): void
{
$proxy = new RequestProxy($url, $auth, null, null);
self::assertSame($expected, $proxy->getCurlOptions($sslOptions));
}
/**
* @return list<array{0: string, 1: ?string, 2: array<string, string>, 3: array<int, string|int>}>
*/
public static function dataCurlSSLOptions(): array
{
// for PHPStan on PHP < 7.3
$caInfo = 10246; // CURLOPT_PROXY_CAINFO
$caPath = 10247; // CURLOPT_PROXY_CAPATH
// url, auth, sslOptions, expected
return [
['https://proxy.com:443', null, ['cafile' => '/certs/bundle.pem'],
[
CURLOPT_PROXY => 'https://proxy.com:443',
CURLOPT_NOPROXY => '',
$caInfo => '/certs/bundle.pem',
],
],
['https://proxy.com:443', 'user:p%40ss', ['capath' => '/certs'],
[
CURLOPT_PROXY => 'https://proxy.com:443',
CURLOPT_NOPROXY => '',
CURLOPT_PROXYAUTH => CURLAUTH_BASIC,
CURLOPT_PROXYUSERPWD => 'user:p%40ss',
$caPath => '/certs',
],
],
];
}
}