Forráskód Böngészése

Merge remote-tracking branch 'upstream/master' into issue-4203

Niels Keurentjes 9 éve
szülő
commit
76c1645a0e

+ 6 - 0
doc/03-cli.md

@@ -466,6 +466,12 @@ changes to the repositories section by using it the following way:
 php composer.phar config repositories.foo vcs https://github.com/foo/bar
 ```
 
+If your repository requires more configuration options, you can instead pass its JSON representation :
+
+```sh
+php composer.phar config repositories.foo '{"type": "vcs", "url": "http://svn.example.org/my-project/", "trunk-path": "master"}'
+```
+
 ## create-project
 
 You can use Composer to create new projects from an existing package. This is

+ 1 - 1
doc/articles/custom-installers.md

@@ -84,7 +84,7 @@ Example:
         "class": "phpDocumentor\\Composer\\TemplateInstallerPlugin"
     },
     "require": {
-        "composer-plugin-api": "1.0.0"
+        "composer-plugin-api": "^1.0"
     }
 }
 ```

+ 4 - 4
doc/articles/troubleshooting.md

@@ -76,16 +76,16 @@ This is a list of common pitfalls on using Composer, and how to avoid them.
 
 ## I have a dependency which contains a "repositories" definition in its composer.json, but it seems to be ignored.
 
-The [`repositories`](04-schema.md#repositories) configuration property is defined as [root-only]
-(04-schema.md#root-package). It is not inherited. You can read more about the reasons behind this in the "[why can't
-composer load repositories recursively?](articles/why-can't-composer-load-repositories-recursively.md)" article.
+The [`repositories`](../04-schema.md#repositories) configuration property is defined as [root-only]
+(../04-schema.md#root-package). It is not inherited. You can read more about the reasons behind this in the "[why can't
+composer load repositories recursively?](../faqs/why-can't-composer-load-repositories-recursively.md)" article.
 The simplest work-around to this limitation, is moving or duplicating the `repositories` definition into your root
 composer.json.
 
 ## I have locked a dependency to a specific commit but get unexpected results.
 
 While Composer supports locking dependencies to a specific commit using the `#commit-ref` syntax, there are certain
-caveats that one should take into account. The most important one is [documented](04-schema.md#package-links), but
+caveats that one should take into account. The most important one is [documented](../04-schema.md#package-links), but
 frequently overlooked:
 
 > **Note:** While this is convenient at times, it should not be how you use

+ 12 - 3
src/Composer/Command/ConfigCommand.php

@@ -441,9 +441,18 @@ EOT
             }
 
             if (1 === count($values)) {
-                $bool = strtolower($values[0]);
-                if (true === $booleanValidator($bool) && false === $booleanNormalizer($bool)) {
-                    return $this->configSource->addRepository($matches[1], false);
+                $value = strtolower($values[0]);
+                if (true === $booleanValidator($value)) {
+                    if (false === $booleanNormalizer($value)) {
+                        return $this->configSource->addRepository($matches[1], false);
+                    }
+                } else {
+                    $value = json_decode($values[0], true);
+                    if (JSON_ERROR_NONE !== json_last_error()) {
+                        throw new \InvalidArgumentException(sprintf('%s is not valid JSON.', $values[0]));
+                    }
+
+                    return $this->configSource->addRepository($matches[1], $value);
                 }
             }
 

+ 33 - 0
src/Composer/Command/DiagnoseCommand.php

@@ -22,6 +22,7 @@ use Composer\Util\ConfigValidator;
 use Composer\Util\ProcessExecutor;
 use Composer\Util\RemoteFilesystem;
 use Composer\Util\StreamContextFactory;
+use Composer\Util\Keys;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 
@@ -133,6 +134,9 @@ EOT
         $io->write('Checking disk free space: ', false);
         $this->outputResult($this->checkDiskSpace($config));
 
+        $io->write('Checking pubkeys: ', false);
+        $this->outputResult($this->checkPubKeys($config));
+
         $io->write('Checking composer version: ', false);
         $this->outputResult($this->checkVersion());
 
@@ -327,6 +331,35 @@ EOT
         return true;
     }
 
+    private function checkPubKeys($config)
+    {
+        $home = $config->get('home');
+        $errors = array();
+        $io = $this->getIO();
+
+        if (file_exists($home.'/keys.tags.pub') && file_exists($home.'/keys.dev.pub')) {
+            $io->write('');
+        }
+
+        if (file_exists($home.'/keys.tags.pub')) {
+            $io->write('Tags Public Key Fingerprint: ' . Keys::fingerprint($home.'/keys.tags.pub'));
+        } else {
+            $errors[] = '<error>Missing pubkey for tags verification</error>';
+        }
+
+        if (file_exists($home.'/keys.dev.pub')) {
+            $io->write('Dev Public Key Fingerprint: ' . Keys::fingerprint($home.'/keys.dev.pub'));
+        } else {
+            $errors[] = '<error>Missing pubkey for dev verification</error>';
+        }
+
+        if ($errors) {
+            $errors[] = '<error>Run composer self-update --update-keys to set them up</error>';
+        }
+
+        return $errors ?: true;
+    }
+
     private function checkVersion()
     {
         $protocol = extension_loaded('openssl') ? 'https' : 'http';

+ 120 - 3
src/Composer/Command/SelfUpdateCommand.php

@@ -14,7 +14,10 @@ namespace Composer\Command;
 
 use Composer\Composer;
 use Composer\Factory;
+use Composer\Config;
 use Composer\Util\Filesystem;
+use Composer\Util\Keys;
+use Composer\IO\IOInterface;
 use Composer\Util\RemoteFilesystem;
 use Composer\Downloader\FilesystemException;
 use Symfony\Component\Console\Input\InputInterface;
@@ -44,6 +47,7 @@ class SelfUpdateCommand extends Command
                 new InputOption('clean-backups', null, InputOption::VALUE_NONE, 'Delete old backups during an update. This makes the current version of composer the only backup available after the update'),
                 new InputArgument('version', InputArgument::OPTIONAL, 'The version to update to'),
                 new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'),
+                new InputOption('update-keys', null, InputOption::VALUE_NONE, 'Prompt user for a key update'),
             ))
             ->setHelp(<<<EOT
 The <info>self-update</info> command checks getcomposer.org for newer
@@ -71,8 +75,13 @@ EOT
 
         $cacheDir = $config->get('cache-dir');
         $rollbackDir = $config->get('data-dir');
+        $home = $config->get('home');
         $localFilename = realpath($_SERVER['argv'][0]) ?: $_SERVER['argv'][0];
 
+        if ($input->getOption('update-keys')) {
+            return $this->fetchKeys($io, $config);
+        }
+
         // check if current dir is writable and if not try the cache dir from settings
         $tmpDir = is_writable(dirname($localFilename)) ? dirname($localFilename) : $cacheDir;
 
@@ -112,15 +121,79 @@ EOT
             self::OLD_INSTALL_EXT
         );
 
-        $io->writeError(sprintf("Updating to version <info>%s</info>.", $updateVersion));
-        $remoteFilename = $baseUrl . (preg_match('{^[0-9a-f]{40}$}', $updateVersion) ? '/composer.phar' : "/download/{$updateVersion}/composer.phar");
+        $updatingToTag = !preg_match('{^[0-9a-f]{40}$}', $updateVersion);
+
+        $io->write(sprintf("Updating to version <info>%s</info>.", $updateVersion));
+        $remoteFilename = $baseUrl . ($updatingToTag ? "/download/{$updateVersion}/composer.phar" : '/composer.phar');
+        $signature = $remoteFilesystem->getContents(self::HOMEPAGE, $remoteFilename.'.sig', false);
         $remoteFilesystem->copy(self::HOMEPAGE, $remoteFilename, $tempFilename, !$input->getOption('no-progress'));
-        if (!file_exists($tempFilename)) {
+        if (!file_exists($tempFilename) || !$signature) {
             $io->writeError('<error>The download of the new composer version failed for an unexpected reason</error>');
 
             return 1;
         }
 
+        // verify phar signature
+        if (!extension_loaded('openssl') && $config->get('disable-tls')) {
+            $io->writeError('<warning>Skipping phar signature verification as you have disabled OpenSSL via config.disable-tls</warning>');
+        } else {
+            if (!extension_loaded('openssl')) {
+                throw new \RuntimeException('The openssl extension is required for phar signatures to be verified but it is not available. '
+                . 'If you can not enable the openssl extension, you can disable this error, at your own risk, by setting the \'disable-tls\' option to true.');
+            }
+
+            $sigFile = 'file://'.$home.'/' . ($updatingToTag ? 'keys.tags.pub' : 'keys.dev.pub');
+            if (!file_exists($sigFile)) {
+                file_put_contents($home.'/keys.dev.pub', <<<DEVPUBKEY
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAnBDHjZS6e0ZMoK3xTD7f
+FNCzlXjX/Aie2dit8QXA03pSrOTbaMnxON3hUL47Lz3g1SC6YJEMVHr0zYq4elWi
+i3ecFEgzLcj+pZM5X6qWu2Ozz4vWx3JYo1/a/HYdOuW9e3lwS8VtS0AVJA+U8X0A
+hZnBmGpltHhO8hPKHgkJtkTUxCheTcbqn4wGHl8Z2SediDcPTLwqezWKUfrYzu1f
+o/j3WFwFs6GtK4wdYtiXr+yspBZHO3y1udf8eFFGcb2V3EaLOrtfur6XQVizjOuk
+8lw5zzse1Qp/klHqbDRsjSzJ6iL6F4aynBc6Euqt/8ccNAIz0rLjLhOraeyj4eNn
+8iokwMKiXpcrQLTKH+RH1JCuOVxQ436bJwbSsp1VwiqftPQieN+tzqy+EiHJJmGf
+TBAbWcncicCk9q2md+AmhNbvHO4PWbbz9TzC7HJb460jyWeuMEvw3gNIpEo2jYa9
+pMV6cVqnSa+wOc0D7pC9a6bne0bvLcm3S+w6I5iDB3lZsb3A9UtRiSP7aGSo7D72
+8tC8+cIgZcI7k9vjvOqH+d7sdOU2yPCnRY6wFh62/g8bDnUpr56nZN1G89GwM4d4
+r/TU7BQQIzsZgAiqOGXvVklIgAMiV0iucgf3rNBLjjeNEwNSTTG9F0CtQ+7JLwaE
+wSEuAuRm+pRqi8BRnQ/GKUcCAwEAAQ==
+-----END PUBLIC KEY-----
+DEVPUBKEY
+);
+                file_put_contents($home.'/keys.tags.pub', <<<TAGSPUBKEY
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0Vi/2K6apCVj76nCnCl2
+MQUPdK+A9eqkYBacXo2wQBYmyVlXm2/n/ZsX6pCLYPQTHyr5jXbkQzBw8SKqPdlh
+vA7NpbMeNCz7wP/AobvUXM8xQuXKbMDTY2uZ4O7sM+PfGbptKPBGLe8Z8d2sUnTO
+bXtX6Lrj13wkRto7st/w/Yp33RHe9SlqkiiS4MsH1jBkcIkEHsRaveZzedUaxY0M
+mba0uPhGUInpPzEHwrYqBBEtWvP97t2vtfx8I5qv28kh0Y6t+jnjL1Urid2iuQZf
+noCMFIOu4vksK5HxJxxrN0GOmGmwVQjOOtxkwikNiotZGPR4KsVj8NnBrLX7oGuM
+nQvGciiu+KoC2r3HDBrpDeBVdOWxDzT5R4iI0KoLzFh2pKqwbY+obNPS2bj+2dgJ
+rV3V5Jjry42QOCBN3c88wU1PKftOLj2ECpewY6vnE478IipiEu7EAdK8Zwj2LmTr
+RKQUSa9k7ggBkYZWAeO/2Ag0ey3g2bg7eqk+sHEq5ynIXd5lhv6tC5PBdHlWipDK
+tl2IxiEnejnOmAzGVivE1YGduYBjN+mjxDVy8KGBrjnz1JPgAvgdwJ2dYw4Rsc/e
+TzCFWGk/HM6a4f0IzBWbJ5ot0PIi4amk07IotBXDWwqDiQTwyuGCym5EqWQ2BD95
+RGv89BPD+2DLnJysngsvVaUCAwEAAQ==
+-----END PUBLIC KEY-----
+TAGSPUBKEY
+);
+            }
+
+            $pubkeyid = openssl_pkey_get_public($sigFile);
+            $algo = defined('OPENSSL_ALGO_SHA384') ? OPENSSL_ALGO_SHA384 : 'SHA384';
+            if (!in_array('SHA384', openssl_get_md_methods())) {
+                throw new \RuntimeException('SHA384 is not supported by your openssl extension, could not verify the phar file integrity');
+            }
+            $signature = json_decode($signature, true);
+            $signature = base64_decode($signature['sha384']);
+            $verified = 1 === openssl_verify(file_get_contents($tempFilename), $signature, $pubkeyid, $algo);
+            openssl_free_key($pubkeyid);
+            if (!$verified) {
+                throw new \RuntimeException('The phar signature did not match the file you downloaded, this means your public keys are outdated or that the phar file is corrupt/has been modified');
+            }
+        }
+
         // remove saved installations of composer
         if ($input->getOption('clean-backups')) {
             $finder = $this->getOldInstallationFinder($rollbackDir);
@@ -147,6 +220,50 @@ EOT
         }
     }
 
+    protected function fetchKeys(IOInterface $io, Config $config)
+    {
+        if (!$io->isInteractive()) {
+            throw new \RuntimeException('Public keys can not be fetched in non-interactive mode, please run Composer interactively');
+        }
+
+        $io->write('Open <info>https://composer.github.io/pubkeys.html</info> to find the latest keys');
+
+        $validator = function ($value) {
+            if (!preg_match('{^-----BEGIN PUBLIC KEY-----$}', trim($value))) {
+                throw new \UnexpectedValueException('Invalid input');
+            }
+            return trim($value)."\n";
+        };
+
+        $devKey = '';
+        while (!preg_match('{(-----BEGIN PUBLIC KEY-----.+?-----END PUBLIC KEY-----)}s', $devKey, $match)) {
+            $devKey = $io->askAndValidate('Enter Dev / Snapshot Public Key (including lines with -----): ', $validator);
+            while ($line = $io->ask('')) {
+                $devKey .= trim($line)."\n";
+                if (trim($line) === '-----END PUBLIC KEY-----') {
+                    break;
+                }
+            }
+        }
+        file_put_contents($keyPath = $config->get('home').'/keys.dev.pub', $match[0]);
+        $io->write('Stored key with fingerprint: ' . Keys::fingerprint($keyPath));
+
+        $tagsKey = '';
+        while (!preg_match('{(-----BEGIN PUBLIC KEY-----.+?-----END PUBLIC KEY-----)}s', $tagsKey, $match)) {
+            $tagsKey = $io->askAndValidate('Enter Tags Public Key (including lines with -----): ', $validator);
+            while ($line = $io->ask('')) {
+                $tagsKey .= trim($line)."\n";
+                if (trim($line) === '-----END PUBLIC KEY-----') {
+                    break;
+                }
+            }
+        }
+        file_put_contents($keyPath = $config->get('home').'/keys.tags.pub', $match[0]);
+        $io->write('Stored key with fingerprint: ' . Keys::fingerprint($keyPath));
+
+        $io->write('Public keys stored in '.$config->get('home'));
+    }
+
     protected function rollback(OutputInterface $output, $rollbackDir, $localFilename)
     {
         $rollbackVersion = $this->getLastBackupVersion($rollbackDir);

+ 45 - 0
src/Composer/DependencyResolver/SolverProblemsException.php

@@ -31,14 +31,23 @@ class SolverProblemsException extends \RuntimeException
     protected function createMessage()
     {
         $text = "\n";
+        $hasExtensionProblems = false;
         foreach ($this->problems as $i => $problem) {
             $text .= "  Problem ".($i + 1).$problem->getPrettyString($this->installedMap)."\n";
+
+            if (!$hasExtensionProblems && $this->hasExtensionProblems($problem->getReasons())) {
+                $hasExtensionProblems = true;
+            }
         }
 
         if (strpos($text, 'could not be found') || strpos($text, 'no matching package found')) {
             $text .= "\nPotential causes:\n - A typo in the package name\n - The package is not available in a stable-enough version according to your minimum-stability setting\n   see <https://groups.google.com/d/topic/composer-dev/_g3ASeIFlrc/discussion> for more details.\n\nRead <https://getcomposer.org/doc/articles/troubleshooting.md> for further common problems.";
         }
 
+        if ($hasExtensionProblems) {
+            $text .= $this->createExtensionHint();
+        }
+
         return $text;
     }
 
@@ -46,4 +55,40 @@ class SolverProblemsException extends \RuntimeException
     {
         return $this->problems;
     }
+
+    private function createExtensionHint()
+    {
+        $paths = array();
+
+        if (($iniPath = php_ini_loaded_file()) !== false) {
+            $paths[] = $iniPath;
+        }
+
+        if (!defined('HHVM_VERSION') && $additionalIniPaths = php_ini_scanned_files()) {
+            $paths = array_merge($paths, array_map("trim", explode(",", $additionalIniPaths)));
+        }
+
+        if (count($paths) === 0) {
+            return '';
+        }
+
+        $text = "\n  To enable extensions, verify that they are enabled in those .ini files:\n    - ";
+        $text .= implode("\n    - ", $paths);
+        $text .= "\n  You can also run `php --ini` inside terminal to see which files are used by PHP in CLI mode.";
+
+        return $text;
+    }
+
+    private function hasExtensionProblems(array $reasonSets)
+    {
+        foreach ($reasonSets as $reasonSet) {
+            foreach($reasonSet as $reason) {
+                if (isset($reason["rule"]) && 0 === strpos($reason["rule"]->getRequiredPackage(), 'ext-')) {
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
 }

+ 38 - 0
src/Composer/Util/Keys.php

@@ -0,0 +1,38 @@
+<?php
+
+/*
+ * 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;
+
+use Composer\Config;
+
+/**
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class Keys
+{
+    public static function fingerprint($path)
+    {
+        $hash = strtoupper(hash('sha256', preg_replace('{\s}', '', file_get_contents($path))));
+
+        return implode(' ', [
+            substr($hash, 0, 8),
+            substr($hash, 8, 8),
+            substr($hash, 16, 8),
+            substr($hash, 24, 8),
+            '', // Extra space
+            substr($hash, 32, 8),
+            substr($hash, 40, 8),
+            substr($hash, 48, 8),
+            substr($hash, 56, 8),
+        ]);
+    }
+}

+ 237 - 21
src/Composer/Util/RemoteFilesystem.php

@@ -33,11 +33,14 @@ class RemoteFilesystem
     private $progress;
     private $lastProgress;
     private $options = array();
+    private $peerCertificateMap = array();
     private $disableTls = false;
     private $retryAuthFailure;
     private $lastHeaders;
     private $storeAuth;
     private $degradedMode = false;
+    private $redirects;
+    private $maxRedirects = 20;
 
     /**
      * Constructor.
@@ -198,6 +201,7 @@ class RemoteFilesystem
         $this->lastProgress = null;
         $this->retryAuthFailure = true;
         $this->lastHeaders = array();
+        $this->redirects = 1; // The first request counts.
 
         // capture username/password from URL if there is one
         if (preg_match('{^https?://(.+):(.+)@([^/]+)}i', $fileUrl, $match)) {
@@ -210,7 +214,16 @@ class RemoteFilesystem
             unset($additionalOptions['retry-auth-failure']);
         }
 
+        $isRedirect = false;
+        if (isset($additionalOptions['redirects'])) {
+            $this->redirects = $additionalOptions['redirects'];
+            $isRedirect = true;
+
+            unset($additionalOptions['redirects']);
+        }
+
         $options = $this->getOptionsForUrl($originUrl, $additionalOptions);
+        $userlandFollow = isset($options['http']['follow_location']) && !$options['http']['follow_location'];
 
         if ($this->io->isDebug()) {
             $this->io->writeError((substr($fileUrl, 0, 4) === 'http' ? 'Downloading ' : 'Reading ') . $fileUrl);
@@ -237,7 +250,7 @@ class RemoteFilesystem
 
         $ctx = StreamContextFactory::getContext($fileUrl, $options, array('notification' => array($this, 'callbackGet')));
 
-        if ($this->progress) {
+        if ($this->progress && !$isRedirect) {
             $this->io->writeError("    Downloading: <comment>Connecting...</comment>", false);
         }
 
@@ -252,6 +265,18 @@ class RemoteFilesystem
         });
         try {
             $result = file_get_contents($fileUrl, false, $ctx);
+
+            if (PHP_VERSION_ID < 50600 && !empty($options['ssl']['peer_fingerprint'])) {
+                // Emulate fingerprint validation on PHP < 5.6
+                $params = stream_context_get_params($ctx);
+                $expectedPeerFingerprint = $options['ssl']['peer_fingerprint'];
+                $peerFingerprint = TlsHelper::getCertificateFingerprint($params['options']['ssl']['peer_certificate']);
+
+                // Constant time compare??!
+                if ($expectedPeerFingerprint !== $peerFingerprint) {
+                    throw new TransportException('Peer fingerprint did not match');
+                }
+            }
         } catch (\Exception $e) {
             if ($e instanceof TransportException && !empty($http_response_header[0])) {
                 $e->setHeaders($http_response_header);
@@ -285,6 +310,11 @@ class RemoteFilesystem
             $statusCode = $this->findStatusCode($http_response_header);
         }
 
+        // handle 3xx redirects for php<5.6, 304 Not Modified is excluded
+        if ($userlandFollow && $statusCode >= 300 && $statusCode <= 399 && $statusCode !== 304 && $this->redirects < $this->maxRedirects) {
+            $result = $this->handleRedirect($http_response_header, $additionalOptions, $result);
+        }
+
         // fail 4xx and 5xx responses and capture the response
         if ($statusCode && $statusCode >= 400 && $statusCode <= 599) {
             if (!$this->retry) {
@@ -297,7 +327,7 @@ class RemoteFilesystem
             $result = false;
         }
 
-        if ($this->progress && !$this->retry) {
+        if ($this->progress && !$this->retry && !$isRedirect) {
             $this->io->overwriteError("    Downloading: <comment>100%</comment>");
         }
 
@@ -334,7 +364,7 @@ class RemoteFilesystem
         }
 
         // handle copy command if download was successful
-        if (false !== $result && null !== $fileName) {
+        if (false !== $result && null !== $fileName && !$isRedirect) {
             if ('' === $result) {
                 throw new TransportException('"'.$this->fileUrl.'" appears broken, and returned an empty 200 response');
             }
@@ -353,14 +383,50 @@ class RemoteFilesystem
             }
         }
 
+        // Handle SSL cert match issues
+        if (false === $result && false !== strpos($errorMessage, 'Peer certificate') && PHP_VERSION_ID < 50600) {
+            // Certificate name error, PHP doesn't support subjectAltName on PHP < 5.6
+            // The procedure to handle sAN for older PHP's is:
+            //
+            // 1. Open socket to remote server and fetch certificate (disabling peer
+            //    validation because PHP errors without giving up the certificate.)
+            //
+            // 2. Verifying the domain in the URL against the names in the sAN field.
+            //    If there is a match record the authority [host/port], certificate
+            //    common name, and certificate fingerprint.
+            //
+            // 3. Retry the original request but changing the CN_match parameter to
+            //    the common name extracted from the certificate in step 2.
+            //
+            // 4. To prevent any attempt at being hoodwinked by switching the
+            //    certificate between steps 2 and 3 the fingerprint of the certificate
+            //    presented in step 3 is compared against the one recorded in step 2.
+            if (TlsHelper::isOpensslParseSafe()) {
+                $certDetails = $this->getCertificateCnAndFp($this->fileUrl, $options);
+
+                if ($certDetails) {
+                    $this->peerCertificateMap[$this->getUrlAuthority($this->fileUrl)] = $certDetails;
+
+                    $this->retry = true;
+                }
+            } else {
+                $this->io->writeError(sprintf(
+                    '<error>Your version of PHP, %s, is affected by CVE-2013-6420 and cannot safely perform certificate validation, we strongly suggest you upgrade.</error>',
+                    PHP_VERSION
+                ));
+            }
+        }
+
         if ($this->retry) {
             $this->retry = false;
 
             $result = $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
 
-            $authHelper = new AuthHelper($this->io, $this->config);
-            $authHelper->storeAuth($this->originUrl, $this->storeAuth);
-            $this->storeAuth = false;
+            if (false !== $this->storeAuth) {
+                $authHelper = new AuthHelper($this->io, $this->config);
+                $authHelper->storeAuth($this->originUrl, $this->storeAuth);
+                $this->storeAuth = false;
+            }
 
             return $result;
         }
@@ -514,19 +580,44 @@ class RemoteFilesystem
         $tlsOptions = array();
 
         // Setup remaining TLS options - the matching may need monitoring, esp. www vs none in CN
-        if ($this->disableTls === false && PHP_VERSION_ID < 50600) {
-            if (!preg_match('{^https?://}', $this->fileUrl)) {
-                $host = $originUrl;
+        if ($this->disableTls === false && PHP_VERSION_ID < 50600 && !stream_is_local($this->fileUrl)) {
+            $host = parse_url($this->fileUrl, PHP_URL_HOST);
+
+            if (PHP_VERSION_ID >= 50304) {
+                // Must manually follow when setting CN_match because this causes all
+                // redirects to be validated against the same CN_match value.
+                $userlandFollow = true;
             } else {
-                $host = parse_url($this->fileUrl, PHP_URL_HOST);
-            }
+                // PHP < 5.3.4 does not support follow_location, for those people
+                // do some really nasty hard coded transformations. These will
+                // still breakdown if the site redirects to a domain we don't
+                // expect.
 
-            if ($host === 'github.com' || $host === 'api.github.com') {
-                $host = '*.github.com';
+                if ($host === 'github.com' || $host === 'api.github.com') {
+                    $host = '*.github.com';
+                }
             }
 
             $tlsOptions['ssl']['CN_match'] = $host;
             $tlsOptions['ssl']['SNI_server_name'] = $host;
+
+            $urlAuthority = $this->getUrlAuthority($this->fileUrl);
+
+            if (isset($this->peerCertificateMap[$urlAuthority])) {
+                // Handle subjectAltName on lesser PHP's.
+                $certMap = $this->peerCertificateMap[$urlAuthority];
+
+                if ($this->io->isDebug()) {
+                    $this->io->writeError(sprintf(
+                        'Using <info>%s</info> as CN for subjectAltName enabled host <info>%s</info>',
+                        $certMap['cn'],
+                        $urlAuthority
+                    ));
+                }
+
+                $tlsOptions['ssl']['CN_match'] = $certMap['cn'];
+                $tlsOptions['ssl']['peer_fingerprint'] = $certMap['fp'];
+            }
         }
 
         $headers = array();
@@ -543,6 +634,10 @@ class RemoteFilesystem
             $headers[] = 'Connection: close';
         }
 
+        if (isset($userlandFollow)) {
+            $options['http']['follow_location'] = 0;
+        }
+
         if ($this->io->hasAuthentication($originUrl)) {
             $auth = $this->io->getAuthentication($originUrl);
             if ('github.com' === $originUrl && 'x-oauth-basic' === $auth['password']) {
@@ -567,6 +662,51 @@ class RemoteFilesystem
         return $options;
     }
 
+    private function handleRedirect(array $http_response_header, array $additionalOptions, $result)
+    {
+        if ($locationHeader = $this->findHeaderValue($http_response_header, 'location')) {
+            if (parse_url($locationHeader, PHP_URL_SCHEME)) {
+                // Absolute URL; e.g. https://example.com/composer
+                $targetUrl = $locationHeader;
+            } elseif (parse_url($locationHeader, PHP_URL_HOST)) {
+                // Scheme relative; e.g. //example.com/foo
+                $targetUrl = $this->scheme.':'.$locationHeader;
+            } elseif ('/' === $locationHeader[0]) {
+                // Absolute path; e.g. /foo
+                $urlHost = parse_url($this->fileUrl, PHP_URL_HOST);
+
+                // Replace path using hostname as an anchor.
+                $targetUrl = preg_replace('{^(.+(?://|@)'.preg_quote($urlHost).'(?::\d+)?)(?:[/\?].*)?$}', '\1'.$locationHeader, $this->fileUrl);
+            } else {
+                // Relative path; e.g. foo
+                // This actually differs from PHP which seems to add duplicate slashes.
+                $targetUrl = preg_replace('{^(.+/)[^/?]*(?:\?.*)?$}', '\1'.$locationHeader, $this->fileUrl);
+            }
+        }
+
+        if (!empty($targetUrl)) {
+            $this->redirects++;
+
+            if ($this->io->isDebug()) {
+                $this->io->writeError(sprintf('Following redirect (%u) %s', $this->redirects, $targetUrl));
+            }
+
+            $additionalOptions['redirects'] = $this->redirects;
+
+            return $this->get($this->originUrl, $targetUrl, $additionalOptions, $this->fileName, $this->progress);
+        }
+
+        if (!$this->retry) {
+            $e = new TransportException('The "'.$this->fileUrl.'" file could not be downloaded, got redirect without Location ('.$http_response_header[0].')');
+            $e->setHeaders($http_response_header);
+            $e->setResponse($result);
+
+            throw $e;
+        }
+
+        return false;
+    }
+
     /**
      * @param array $options
      *
@@ -597,7 +737,7 @@ class RemoteFilesystem
             'DHE-DSS-AES256-SHA',
             'DHE-RSA-AES256-SHA',
             'AES128-GCM-SHA256',
-             'AES256-GCM-SHA384',
+            'AES256-GCM-SHA384',
             'ECDHE-RSA-RC4-SHA',
             'ECDHE-ECDSA-RC4-SHA',
             'AES128',
@@ -625,6 +765,7 @@ class RemoteFilesystem
                 'verify_peer' => true,
                 'verify_depth' => 7,
                 'SNI_enabled' => true,
+                'capture_peer_cert' => true,
             )
         );
 
@@ -765,6 +906,12 @@ class RemoteFilesystem
      */
     private function validateCaFile($filename)
     {
+        static $files = array();
+
+        if (isset($files[$filename])) {
+            return $files[$filename];
+        }
+
         if ($this->io->isDebug()) {
             $this->io->writeError('Checking CA file '.realpath($filename));
         }
@@ -772,15 +919,16 @@ class RemoteFilesystem
 
         // assume the CA is valid if php is vulnerable to
         // https://www.sektioneins.de/advisories/advisory-012013-php-openssl_x509_parse-memory-corruption-vulnerability.html
-        if (
-            PHP_VERSION_ID <= 50327
-            || (PHP_VERSION_ID >= 50400 && PHP_VERSION_ID < 50422)
-            || (PHP_VERSION_ID >= 50500 && PHP_VERSION_ID < 50506)
-        ) {
-            return !empty($contents);
+        if (!TlsHelper::isOpensslParseSafe()) {
+            $this->io->writeError(sprintf(
+                '<error>Your version of PHP, %s, is affected by CVE-2013-6420 and cannot safely perform certificate validation, we strongly suggest you upgrade.</error>',
+                PHP_VERSION
+            ));
+
+            return $files[$filename] = !empty($contents);
         }
 
-        return (bool) openssl_x509_parse($contents);
+        return $files[$filename] = (bool) openssl_x509_parse($contents);
     }
 
     /**
@@ -800,4 +948,72 @@ class RemoteFilesystem
 
         unset($source, $target);
     }
+
+    /**
+     * Fetch certificate common name and fingerprint for validation of SAN.
+     *
+     * @todo Remove when PHP 5.6 is minimum supported version.
+     */
+    private function getCertificateCnAndFp($url, $options)
+    {
+        if (PHP_VERSION_ID >= 50600) {
+            throw new \BadMethodCallException(sprintf(
+                '%s must not be used on PHP >= 5.6',
+                __METHOD__
+            ));
+        }
+
+        $context = StreamContextFactory::getContext($url, $options, array('options' => array(
+            'ssl' => array(
+                'capture_peer_cert' => true,
+                'verify_peer' => false, // Yes this is fucking insane! But PHP is lame.
+            ))
+        ));
+
+        // Ideally this would just use stream_socket_client() to avoid sending a
+        // HTTP request but that does not capture the certificate.
+        if (false === $handle = @fopen($url, 'rb', false, $context)) {
+            return;
+        }
+
+        // Close non authenticated connection without reading any content.
+        fclose($handle);
+        $handle = null;
+
+        $params = stream_context_get_params($context);
+
+        if (!empty($params['options']['ssl']['peer_certificate'])) {
+            $peerCertificate = $params['options']['ssl']['peer_certificate'];
+
+            if (TlsHelper::checkCertificateHost($peerCertificate, parse_url($url, PHP_URL_HOST), $commonName)) {
+                return array(
+                    'cn' => $commonName,
+                    'fp' => TlsHelper::getCertificateFingerprint($peerCertificate),
+                );
+            }
+        }
+    }
+
+    private function getUrlAuthority($url)
+    {
+        $defaultPorts = array(
+            'ftp' => 21,
+            'http' => 80,
+            'https' => 443,
+        );
+
+        $scheme = parse_url($url, PHP_URL_SCHEME);
+
+        if (!isset($defaultPorts[$scheme])) {
+            throw new \InvalidArgumentException(sprintf(
+                'Could not get default port for unknown scheme: %s',
+                $scheme
+            ));
+        }
+
+        $defaultPort = $defaultPorts[$scheme];
+        $port = parse_url($url, PHP_URL_PORT) ?: $defaultPort;
+
+        return parse_url($url, PHP_URL_HOST).':'.$port;
+    }
 }

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 210 - 0
src/Composer/Util/TlsHelper.php


+ 15 - 0
tests/Composer/Test/Config/Fixtures/config/config-with-exampletld-repository-and-options.json

@@ -0,0 +1,15 @@
+{
+    "name": "my-vend/my-app",
+    "license": "MIT",
+    "repositories": {
+        "example_tld": {
+            "type": "composer",
+            "url": "https://example.tld",
+            "options": {
+                "ssl": {
+                    "local_cert": "/home/composer/.ssl/composer.pem"
+                }
+            }
+        }
+    }
+}

+ 18 - 0
tests/Composer/Test/Config/JsonConfigSourceTest.php

@@ -52,6 +52,24 @@ class JsonConfigSourceTest extends \PHPUnit_Framework_TestCase
         $this->assertFileEquals($this->fixturePath('config/config-with-exampletld-repository.json'), $config);
     }
 
+    public function testAddRepositoryWithOptions()
+    {
+        $config = $this->workingDir.'/composer.json';
+        copy($this->fixturePath('composer-repositories.json'), $config);
+        $jsonConfigSource = new JsonConfigSource(new JsonFile($config));
+        $jsonConfigSource->addRepository('example_tld', array(
+            'type' => 'composer',
+            'url' => 'https://example.tld',
+            'options' => array(
+                'ssl' => array(
+                    'local_cert' => '/home/composer/.ssl/composer.pem'
+                )
+            )
+        ));
+
+        $this->assertFileEquals($this->fixturePath('config/config-with-exampletld-repository-and-options.json'), $config);
+    }
+
     public function testRemoveRepository()
     {
         $config = $this->workingDir.'/composer.json';

+ 76 - 0
tests/Composer/Test/Util/TlsHelperTest.php

@@ -0,0 +1,76 @@
+<?php
+
+/*
+ * 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;
+
+use Composer\Util\TlsHelper;
+
+class TlsHelperTest extends \PHPUnit_Framework_TestCase
+{
+    /** @dataProvider dataCheckCertificateHost */
+    public function testCheckCertificateHost($expectedResult, $hostname, $certNames)
+    {
+        $certificate['subject']['commonName'] = $expectedCn = array_shift($certNames);
+        $certificate['extensions']['subjectAltName'] = $certNames ? 'DNS:'.implode(',DNS:', $certNames) : '';
+
+        $result = TlsHelper::checkCertificateHost($certificate, $hostname, $foundCn);
+
+        if (true === $expectedResult) {
+            $this->assertTrue($result);
+            $this->assertSame($expectedCn, $foundCn);
+        } else {
+            $this->assertFalse($result);
+            $this->assertNull($foundCn);
+        }
+    }
+
+    public function dataCheckCertificateHost()
+    {
+        return array(
+            array(true, 'getcomposer.org', array('getcomposer.org')),
+            array(true, 'getcomposer.org', array('getcomposer.org', 'packagist.org')),
+            array(true, 'getcomposer.org', array('packagist.org', 'getcomposer.org')),
+            array(true, 'foo.getcomposer.org', array('*.getcomposer.org')),
+            array(false, 'xyz.foo.getcomposer.org', array('*.getcomposer.org')),
+            array(true, 'foo.getcomposer.org', array('getcomposer.org', '*.getcomposer.org')),
+            array(true, 'foo.getcomposer.org', array('foo.getcomposer.org', 'foo*.getcomposer.org')),
+            array(true, 'foo1.getcomposer.org', array('foo.getcomposer.org', 'foo*.getcomposer.org')),
+            array(true, 'foo2.getcomposer.org', array('foo.getcomposer.org', 'foo*.getcomposer.org')),
+            array(false, 'foo2.another.getcomposer.org', array('foo.getcomposer.org', 'foo*.getcomposer.org')),
+            array(false, 'test.example.net', array('**.example.net', '**.example.net')),
+            array(false, 'test.example.net', array('t*t.example.net', 't*t.example.net')),
+            array(false, 'xyz.example.org', array('*z.example.org', '*z.example.org')),
+            array(false, 'foo.bar.example.com', array('foo.*.example.com', 'foo.*.example.com')),
+            array(false, 'example.com', array('example.*', 'example.*')),
+            array(true, 'localhost', array('localhost')),
+            array(false, 'localhost', array('*')),
+            array(false, 'localhost', array('local*')),
+            array(false, 'example.net', array('*.net', '*.org', 'ex*.net')),
+            array(true, 'example.net', array('*.net', '*.org', 'example.net')),
+        );
+    }
+
+    public function testGetCertificateNames()
+    {
+        $certificate['subject']['commonName'] = 'example.net';
+        $certificate['extensions']['subjectAltName'] = 'DNS: example.com, IP: 127.0.0.1, DNS: getcomposer.org, Junk: blah, DNS: composer.example.org';
+
+        $names = TlsHelper::getCertificateNames($certificate);
+
+        $this->assertSame('example.net', $names['cn']);
+        $this->assertSame(array(
+            'example.com',
+            'getcomposer.org',
+            'composer.example.org',
+        ), $names['san']);
+    }
+}

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott