diff --git a/phpstan/baseline.neon b/phpstan/baseline.neon index 64828b622..801dd2336 100644 --- a/phpstan/baseline.neon +++ b/phpstan/baseline.neon @@ -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\\ but returns array\\.$#" 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 diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index fa7855331..d045bdf88 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -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('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('Composer now requires separate proxy environment variables for HTTP and HTTPS requests.'); + $io->writeError('Please set `https_proxy` in addition to your existing proxy environment variables.'); + $io->writeError('This fallback (and warning) will be removed in Composer 2.8.0.'); + } + return $result; } catch (ScriptExecutionException $e) { if ($this->getDisablePluginsByDefault() && $this->isRunningAsRoot() && !$this->io->isInteractive()) { diff --git a/src/Composer/Util/Http/CurlDownloader.php b/src/Composer/Util/Http/CurlDownloader.php index e512f0824..4dd554065 100644 --- a/src/Composer/Util/Http/CurlDownloader.php +++ b/src/Composer/Util/Http/CurlDownloader.php @@ -52,10 +52,6 @@ class CurlDownloader private $maxRedirects = 20; /** @var int */ private $maxRetries = 3; - /** @var ProxyManager */ - private $proxyManager; - /** @var bool */ - private $supportsSecureProxy; /** @var array */ 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); diff --git a/src/Composer/Util/Http/ProxyHelper.php b/src/Composer/Util/Http/ProxyHelper.php deleted file mode 100644 index f2d864f29..000000000 --- a/src/Composer/Util/Http/ProxyHelper.php +++ /dev/null @@ -1,183 +0,0 @@ - - * Jordi Boggiano - * - * 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 - */ -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 $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; - } -} diff --git a/src/Composer/Util/Http/ProxyItem.php b/src/Composer/Util/Http/ProxyItem.php new file mode 100644 index 000000000..2839be923 --- /dev/null +++ b/src/Composer/Util/Http/ProxyItem.php @@ -0,0 +1,119 @@ + + * Jordi Boggiano + * + * 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 + */ +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); + } +} diff --git a/src/Composer/Util/Http/ProxyManager.php b/src/Composer/Util/Http/ProxyManager.php index 731638ab8..0571780fe 100644 --- a/src/Composer/Util/Http/ProxyManager.php +++ b/src/Composer/Util/Http/ProxyManager.php @@ -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); } } diff --git a/src/Composer/Util/Http/RequestProxy.php b/src/Composer/Util/Http/RequestProxy.php index c80c8799e..d9df68861 100644 --- a/src/Composer/Util/Http/RequestProxy.php +++ b/src/Composer/Util/Http/RequestProxy.php @@ -12,78 +12,157 @@ namespace Composer\Util\Http; -use Composer\Util\Url; +use Composer\Downloader\TransportException; /** * @internal * @author John Stevenson + * + * @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 $sslOptions + * @return array */ - 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); } } diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 6fa3437a3..560ef35db 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -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); diff --git a/src/Composer/Util/StreamContextFactory.php b/src/Composer/Util/StreamContextFactory.php index 57fbe0f0e..be4c976a9 100644 --- a/src/Composer/Util/StreamContextFactory.php +++ b/src/Composer/Util/StreamContextFactory.php @@ -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()) { diff --git a/tests/Composer/Test/Util/Http/ProxyHelperTest.php b/tests/Composer/Test/Util/Http/ProxyHelperTest.php deleted file mode 100644 index 8b9dfb1b1..000000000 --- a/tests/Composer/Test/Util/Http/ProxyHelperTest.php +++ /dev/null @@ -1,201 +0,0 @@ - - * Jordi Boggiano - * - * 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 $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 $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 $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', []], - ]; - } -} diff --git a/tests/Composer/Test/Util/Http/ProxyItemTest.php b/tests/Composer/Test/Util/Http/ProxyItemTest.php new file mode 100644 index 000000000..ccca76ec6 --- /dev/null +++ b/tests/Composer/Test/Util/Http/ProxyItemTest.php @@ -0,0 +1,72 @@ + + * Jordi Boggiano + * + * 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> + */ + 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> + */ + 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'], + ]; + } +} diff --git a/tests/Composer/Test/Util/Http/ProxyManagerTest.php b/tests/Composer/Test/Util/Http/ProxyManagerTest.php index 6321df6be..80c88a844 100644 --- a/tests/Composer/Test/Util/Http/ProxyManagerTest.php +++ b/tests/Composer/Test/Util/Http/ProxyManagerTest.php @@ -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 $server - * @param mixed[] $expectedOptions - * @param non-empty-string $url + * @param array $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, 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 $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, 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 $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, 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 $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], ]; } } diff --git a/tests/Composer/Test/Util/Http/RequestProxyTest.php b/tests/Composer/Test/Util/Http/RequestProxyTest.php index 66a03fccb..c01948598 100644 --- a/tests/Composer/Test/Util/Http/RequestProxyTest.php +++ b/tests/Composer/Test/Util/Http/RequestProxyTest.php @@ -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 + */ 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 + */ + 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 $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}> + */ + 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 $sslOptions + * @param array $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, 3: array}> + */ + 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', + ], + ], ]; } }