diff --git a/src/Composer/Downloader/ArchiveDownloader.php b/src/Composer/Downloader/ArchiveDownloader.php index 07d418bed..8e921a3e4 100644 --- a/src/Composer/Downloader/ArchiveDownloader.php +++ b/src/Composer/Downloader/ArchiveDownloader.php @@ -15,7 +15,6 @@ namespace Composer\Downloader; use Composer\IO\IOInterface; use Composer\Package\PackageInterface; use Composer\Util\Filesystem; -use Composer\Util\RemoteFilesystem; /** * Base downloader for archives diff --git a/src/Composer/Repository/Vcs/GitHubDriver.php b/src/Composer/Repository/Vcs/GitHubDriver.php index 01371c1a2..95701ddd6 100644 --- a/src/Composer/Repository/Vcs/GitHubDriver.php +++ b/src/Composer/Repository/Vcs/GitHubDriver.php @@ -2,8 +2,11 @@ namespace Composer\Repository\Vcs; +use Composer\Downloader\TransportException; use Composer\Json\JsonFile; use Composer\IO\IOInterface; +use Composer\Util\ProcessExecutor; +use Composer\Util\RemoteFilesystem; /** * @author Jordi Boggiano @@ -16,14 +19,30 @@ class GitHubDriver extends VcsDriver protected $branches; protected $rootIdentifier; protected $infoCache = array(); + protected $isPrivate = false; - public function __construct($url, IOInterface $io) + /** + * Git Driver + * + * @var GitDriver + */ + protected $gitDriver; + + /** + * Constructor + * + * @param string $url + * @param IOInterface $io + * @param ProcessExecutor $process + * @param RemoteFilesystem $remoteFilesystem + */ + public function __construct($url, IOInterface $io, ProcessExecutor $process = null, RemoteFilesystem $remoteFilesystem = null) { preg_match('#^(?:https?|git)://github\.com/([^/]+)/(.+?)(?:\.git)?$#', $url, $match); $this->owner = $match[1]; $this->repository = $match[2]; - parent::__construct($url, $io); + parent::__construct($url, $io, $process, $remoteFilesystem); } /** @@ -31,6 +50,7 @@ class GitHubDriver extends VcsDriver */ public function initialize() { + $this->fetchRootIdentifier(); } /** @@ -38,11 +58,9 @@ class GitHubDriver extends VcsDriver */ public function getRootIdentifier() { - if (null === $this->rootIdentifier) { - $repoData = JsonFile::parseJson($this->getContents($this->getScheme() . '://api.github.com/repos/'.$this->owner.'/'.$this->repository)); - $this->rootIdentifier = $repoData['master_branch'] ?: 'master'; + if ($this->gitDriver) { + return $this->gitDriver->getRootIdentifier(); } - return $this->rootIdentifier; } @@ -51,6 +69,9 @@ class GitHubDriver extends VcsDriver */ public function getUrl() { + if ($this->gitDriver) { + return $this->gitDriver->getUrl(); + } return $this->url; } @@ -59,9 +80,19 @@ class GitHubDriver extends VcsDriver */ public function getSource($identifier) { + if ($this->gitDriver) { + return $this->gitDriver->getSource($identifier); + } $label = array_search($identifier, $this->getTags()) ?: $identifier; + if ($this->isPrivate) { + // Private GitHub repositories should be accessed using the + // SSH version of the URL. + $url = $this->generateSshUrl(); + } else { + $url = $this->getUrl(); + } - return array('type' => 'git', 'url' => $this->getUrl(), 'reference' => $label); + return array('type' => 'git', 'url' => $url, 'reference' => $label); } /** @@ -69,6 +100,9 @@ class GitHubDriver extends VcsDriver */ public function getDist($identifier) { + if ($this->gitDriver) { + return $this->gitDriver->getDist($identifier); + } $label = array_search($identifier, $this->getTags()) ?: $identifier; $url = $this->getScheme() . '://github.com/'.$this->owner.'/'.$this->repository.'/zipball/'.$label; @@ -80,6 +114,9 @@ class GitHubDriver extends VcsDriver */ public function getComposerInformation($identifier) { + if ($this->gitDriver) { + return $this->gitDriver->getComposerInformation($identifier); + } if (!isset($this->infoCache[$identifier])) { $composer = $this->getContents($this->getScheme() . '://raw.github.com/'.$this->owner.'/'.$this->repository.'/'.$identifier.'/composer.json'); if (!$composer) { @@ -103,6 +140,9 @@ class GitHubDriver extends VcsDriver */ public function getTags() { + if ($this->gitDriver) { + return $this->gitDriver->getTags(); + } if (null === $this->tags) { $tagsData = JsonFile::parseJson($this->getContents($this->getScheme() . '://api.github.com/repos/'.$this->owner.'/'.$this->repository.'/tags')); $this->tags = array(); @@ -119,6 +159,9 @@ class GitHubDriver extends VcsDriver */ public function getBranches() { + if ($this->gitDriver) { + return $this->gitDriver->getBranches(); + } if (null === $this->branches) { $branchData = JsonFile::parseJson($this->getContents($this->getScheme() . '://api.github.com/repos/'.$this->owner.'/'.$this->repository.'/branches')); $this->branches = array(); @@ -137,4 +180,63 @@ class GitHubDriver extends VcsDriver { return extension_loaded('openssl') && preg_match('#^(?:https?|git)://github\.com/([^/]+)/(.+?)(?:\.git)?$#', $url); } + + /** + * Generate an SSH URL + * + * @return string + */ + protected function generateSshUrl() + { + return 'git@github.com:'.$this->owner.'/'.$this->repository.'.git'; + } + + /** + * Fetch root identifier from GitHub + * + * @throws TransportException + */ + protected function fetchRootIdentifier() + { + $repoDataUrl = $this->getScheme() . '://api.github.com/repos/'.$this->owner.'/'.$this->repository; + $attemptCounter = 0; + while (null === $this->rootIdentifier) { + if (5 == $attemptCounter++) { + throw new \RuntimeException("Either you have entered invalid credentials or this GitHub repository does not exists (404)"); + } + try { + $repoData = JsonFile::parseJson($this->getContents($repoDataUrl)); + $this->rootIdentifier = $repoData['master_branch'] ?: 'master'; + } catch (TransportException $e) { + switch($e->getCode()) { + case 401: + case 404: + $this->isPrivate = true; + if (!$this->io->isInteractive()) { + // If this repository may be private (hard to say for sure, + // GitHub returns 404 for private repositories) and we + // cannot ask for authentication credentials (because we + // are not interactive) then we fallback to GitDriver. + $this->gitDriver = new GitDriver( + $this->generateSshUrl(), + $this->io, + $this->process, + $this->remoteFilesystem + ); + $this->gitDriver->initialize(); + return; + } + $this->io->write('Authentication required ('.$this->url.'):'); + $username = $this->io->ask('Username: '); + $password = $this->io->askAndHideAnswer('Password: '); + $this->io->setAuthorization($this->url, $username, $password); + break; + + default: + throw $e; + break; + } + } + } + } } diff --git a/src/Composer/Repository/Vcs/VcsDriver.php b/src/Composer/Repository/Vcs/VcsDriver.php index a20e959fd..99c705c50 100644 --- a/src/Composer/Repository/Vcs/VcsDriver.php +++ b/src/Composer/Repository/Vcs/VcsDriver.php @@ -27,6 +27,7 @@ abstract class VcsDriver implements VcsDriverInterface protected $url; protected $io; protected $process; + protected $remoteFilesystem; /** * Constructor. @@ -34,12 +35,14 @@ abstract class VcsDriver implements VcsDriverInterface * @param string $url The URL * @param IOInterface $io The IO instance * @param ProcessExecutor $process Process instance, injectable for mocking + * @param callable $remoteFilesystem Remote Filesystem, injectable for mocking */ - public function __construct($url, IOInterface $io, ProcessExecutor $process = null) + public function __construct($url, IOInterface $io, ProcessExecutor $process = null, $remoteFilesystem = null) { $this->url = $url; $this->io = $io; $this->process = $process ?: new ProcessExecutor; + $this->remoteFilesystem = $remoteFilesystem ?: new RemoteFilesystem($io); } /** @@ -80,8 +83,7 @@ abstract class VcsDriver implements VcsDriverInterface */ protected function getContents($url) { - $rfs = new RemoteFilesystem($this->io); - return $rfs->getContents($this->url, $url, false); + return $this->remoteFilesystem->getContents($this->url, $url, false); } protected static function isLocalUrl($url) diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 57719cbf4..e14201a13 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -80,13 +80,11 @@ class RemoteFilesystem * @param string $fileUrl The file URL * @param string $fileName the local filename * @param boolean $progress Display the progression - * @param boolean $firstCall Whether this is the first attempt at fetching this resource * * @throws TransportException When the file could not be downloaded */ - protected function get($originUrl, $fileUrl, $fileName = null, $progress = true, $firstCall = true) + protected function get($originUrl, $fileUrl, $fileName = null, $progress = true) { - $this->firstCall = $firstCall; $this->bytesMax = 0; $this->result = null; $this->originUrl = $originUrl; @@ -139,20 +137,12 @@ class RemoteFilesystem protected function callbackGet($notificationCode, $severity, $message, $messageCode, $bytesTransferred, $bytesMax) { switch ($notificationCode) { - case STREAM_NOTIFY_AUTH_REQUIRED: case STREAM_NOTIFY_FAILURE: - if (404 === $messageCode && !$this->firstCall) { - throw new TransportException("The '" . $this->fileUrl . "' URL not found", 404); - } + throw new TransportException(trim($message), $messageCode); + break; - // for private repository returning 404 error when the authorization is incorrect - $auth = $this->io->getAuthorization($this->originUrl); - $attemptAuthentication = $this->firstCall && 404 === $messageCode && null === $auth['username']; - - $this->firstCall = false; - - // get authorization informations - if (401 === $messageCode || $attemptAuthentication) { + case STREAM_NOTIFY_AUTH_REQUIRED: + if (401 === $messageCode) { if (!$this->io->isInteractive()) { $message = "The '" . $this->fileUrl . "' URL required authentication.\nYou must be using the interactive console"; @@ -164,7 +154,7 @@ class RemoteFilesystem $password = $this->io->askAndHideAnswer(' Password: '); $this->io->setAuthorization($this->originUrl, $username, $password); - $this->get($this->originUrl, $this->fileUrl, $this->fileName, $this->progress, false); + $this->get($this->originUrl, $this->fileUrl, $this->fileName, $this->progress); } break; diff --git a/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php b/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php new file mode 100644 index 000000000..c410b5ab5 --- /dev/null +++ b/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php @@ -0,0 +1,223 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Repository\Vcs; + +use Composer\Downloader\TransportException; +use Composer\Repository\Vcs\GitHubDriver; + +/** + * @author Beau Simensen + */ +class GitHubDriverTest extends \PHPUnit_Framework_TestCase +{ + public function testPrivateRepository() + { + $repoUrl = 'http://github.com/composer/packagist'; + $repoApiUrl = 'https://api.github.com/repos/composer/packagist'; + $repoSshUrl = 'git@github.com:composer/packagist.git'; + $identifier = 'v0.0.0'; + $sha = 'SOMESHA'; + + $io = $this->getMock('Composer\IO\IOInterface'); + $io->expects($this->any()) + ->method('isInteractive') + ->will($this->returnValue(true)); + + $remoteFilesystem = $this->getMockBuilder('Composer\Util\RemoteFilesystem') + ->setConstructorArgs(array($io)) + ->getMock(); + + $remoteFilesystem->expects($this->at(0)) + ->method('getContents') + ->with($this->equalTo($repoUrl), $this->equalTo($repoApiUrl), $this->equalTo(false)) + ->will($this->throwException(new TransportException('HTTP/1.1 404 Not Found', 404))); + + $io->expects($this->once()) + ->method('ask') + ->with($this->equalTo('Username: ')) + ->will($this->returnValue('someuser')); + + $io->expects($this->once()) + ->method('askAndHideAnswer') + ->with($this->equalTo('Password: ')) + ->will($this->returnValue('somepassword')); + + $io->expects($this->once()) + ->method('setAuthorization') + ->with($this->equalTo($repoUrl), 'someuser', 'somepassword'); + + $remoteFilesystem->expects($this->at(1)) + ->method('getContents') + ->with($this->equalTo($repoUrl), $this->equalTo($repoApiUrl), $this->equalTo(false)) + ->will($this->returnValue('{"master_branch": "test_master"}')); + + $gitHubDriver = new GitHubDriver($repoUrl, $io, null, $remoteFilesystem); + $gitHubDriver->initialize(); + $this->setAttribute($gitHubDriver, 'tags', array($identifier => $sha)); + + $this->assertEquals('test_master', $gitHubDriver->getRootIdentifier()); + + $dist = $gitHubDriver->getDist($identifier); + $this->assertEquals('zip', $dist['type']); + $this->assertEquals('https://github.com/composer/packagist/zipball/v0.0.0', $dist['url']); + $this->assertEquals('v0.0.0', $dist['reference']); + + $source = $gitHubDriver->getSource($identifier); + $this->assertEquals('git', $source['type']); + $this->assertEquals($repoSshUrl, $source['url']); + $this->assertEquals('v0.0.0', $source['reference']); + + $dist = $gitHubDriver->getDist($sha); + $this->assertEquals('zip', $dist['type']); + $this->assertEquals('https://github.com/composer/packagist/zipball/v0.0.0', $dist['url']); + $this->assertEquals('v0.0.0', $dist['reference']); + + $source = $gitHubDriver->getSource($sha); + $this->assertEquals('git', $source['type']); + $this->assertEquals($repoSshUrl, $source['url']); + $this->assertEquals('v0.0.0', $source['reference']); + } + + public function testPublicRepository() + { + $repoUrl = 'http://github.com/composer/packagist'; + $repoApiUrl = 'https://api.github.com/repos/composer/packagist'; + $identifier = 'v0.0.0'; + $sha = 'SOMESHA'; + + $io = $this->getMock('Composer\IO\IOInterface'); + $io->expects($this->any()) + ->method('isInteractive') + ->will($this->returnValue(true)); + + $remoteFilesystem = $this->getMockBuilder('Composer\Util\RemoteFilesystem') + ->setConstructorArgs(array($io)) + ->getMock(); + + $remoteFilesystem->expects($this->at(0)) + ->method('getContents') + ->with($this->equalTo($repoUrl), $this->equalTo($repoApiUrl), $this->equalTo(false)) + ->will($this->returnValue('{"master_branch": "test_master"}')); + + $gitHubDriver = new GitHubDriver($repoUrl, $io, null, $remoteFilesystem); + $gitHubDriver->initialize(); + $this->setAttribute($gitHubDriver, 'tags', array($identifier => $sha)); + + $this->assertEquals('test_master', $gitHubDriver->getRootIdentifier()); + + $dist = $gitHubDriver->getDist($identifier); + $this->assertEquals('zip', $dist['type']); + $this->assertEquals('https://github.com/composer/packagist/zipball/v0.0.0', $dist['url']); + $this->assertEquals($identifier, $dist['reference']); + + $source = $gitHubDriver->getSource($identifier); + $this->assertEquals('git', $source['type']); + $this->assertEquals($repoUrl, $source['url']); + $this->assertEquals($identifier, $source['reference']); + + $dist = $gitHubDriver->getDist($sha); + $this->assertEquals('zip', $dist['type']); + $this->assertEquals('https://github.com/composer/packagist/zipball/v0.0.0', $dist['url']); + $this->assertEquals($identifier, $dist['reference']); + + $source = $gitHubDriver->getSource($sha); + $this->assertEquals('git', $source['type']); + $this->assertEquals($repoUrl, $source['url']); + $this->assertEquals($identifier, $source['reference']); + } + + public function testPrivateRepositoryNoInteraction() + { + $repoUrl = 'http://github.com/composer/packagist'; + $repoApiUrl = 'https://api.github.com/repos/composer/packagist'; + $repoSshUrl = 'git@github.com:composer/packagist.git'; + $identifier = 'v0.0.0'; + $sha = 'SOMESHA'; + + $process = $this->getMockBuilder('Composer\Util\ProcessExecutor') + ->disableOriginalConstructor() + ->getMock(); + + $io = $this->getMock('Composer\IO\IOInterface'); + $io->expects($this->any()) + ->method('isInteractive') + ->will($this->returnValue(false)); + + $remoteFilesystem = $this->getMockBuilder('Composer\Util\RemoteFilesystem') + ->setConstructorArgs(array($io)) + ->getMock(); + + $remoteFilesystem->expects($this->at(0)) + ->method('getContents') + ->with($this->equalTo($repoUrl), $this->equalTo($repoApiUrl), $this->equalTo(false)) + ->will($this->throwException(new TransportException('HTTP/1.1 404 Not Found', 404))); + + $process->expects($this->at(0)) + ->method('execute') + ->with($this->stringContains('git fetch origin')); + + $process->expects($this->at(1)) + ->method('execute') + ->with($this->stringContains('git tag')); + + $process->expects($this->at(2)) + ->method('splitLines') + ->will($this->returnValue(array($identifier))); + + $process->expects($this->at(3)) + ->method('execute') + ->with($this->stringContains('git branch')); + + $process->expects($this->at(4)) + ->method('splitLines') + ->will($this->returnValue(array(' test_master edf93f1fccaebd8764383dc12016d0a1a9672d89 Fix test & behavior'))); + + $process->expects($this->at(5)) + ->method('execute') + ->with($this->stringContains('git branch')); + + $process->expects($this->at(6)) + ->method('splitLines') + ->will($this->returnValue(array(' upstream/HEAD -> upstream/test_master'))); + + $gitHubDriver = new GitHubDriver($repoUrl, $io, $process, $remoteFilesystem); + $gitHubDriver->initialize(); + + $this->assertEquals('test_master', $gitHubDriver->getRootIdentifier()); + + // Dist is not available for GitDriver + $dist = $gitHubDriver->getDist($identifier); + $this->assertNull($dist); + + $source = $gitHubDriver->getSource($identifier); + $this->assertEquals('git', $source['type']); + $this->assertEquals($repoSshUrl, $source['url']); + $this->assertEquals($identifier, $source['reference']); + + // Dist is not available for GitDriver + $dist = $gitHubDriver->getDist($sha); + $this->assertNull($dist); + + $source = $gitHubDriver->getSource($sha); + $this->assertEquals('git', $source['type']); + $this->assertEquals($repoSshUrl, $source['url']); + $this->assertEquals($sha, $source['reference']); + } + + protected function setAttribute($object, $attribute, $value) + { + $attr = new \ReflectionProperty($object, $attribute); + $attr->setAccessible(true); + $attr->setValue($object, $value); + } +} diff --git a/tests/Composer/Test/Util/RemoteFilesystemTest.php b/tests/Composer/Test/Util/RemoteFilesystemTest.php index bee389941..97e50b848 100644 --- a/tests/Composer/Test/Util/RemoteFilesystemTest.php +++ b/tests/Composer/Test/Util/RemoteFilesystemTest.php @@ -105,41 +105,14 @@ class RemoteFilesystemTest extends \PHPUnit_Framework_TestCase public function testCallbackGetNotifyFailure404() { $fs = new RemoteFilesystem($this->getMock('Composer\IO\IOInterface')); - $this->setAttribute($fs, 'firstCall', false); try { - $this->callCallbackGet($fs, STREAM_NOTIFY_FAILURE, 0, '', 404, 0, 0); + $this->callCallbackGet($fs, STREAM_NOTIFY_FAILURE, 0, 'HTTP/1.1 404 Not Found', 404, 0, 0); $this->fail(); } catch (\Exception $e) { $this->assertInstanceOf('Composer\Downloader\TransportException', $e); - $this->assertContains('URL not found', $e->getMessage()); - } - } - - public function testCallbackGetNotifyFailure404FirstCall() - { - $io = $this->getMock('Composer\IO\IOInterface'); - $io - ->expects($this->once()) - ->method('getAuthorization') - ->will($this->returnValue(array('username' => null))) - ; - $io - ->expects($this->once()) - ->method('isInteractive') - ->will($this->returnValue(false)) - ; - - $fs = new RemoteFilesystem($io); - $this->setAttribute($fs, 'firstCall', true); - - try { - $this->callCallbackGet($fs, STREAM_NOTIFY_FAILURE, 0, '', 404, 0, 0); - $this->fail(); - } catch (\Exception $e) { - $this->assertInstanceOf('Composer\Downloader\TransportException', $e); - $this->assertContains('URL required authentication', $e->getMessage()); - $this->assertAttributeEquals(false, 'firstCall', $fs); + $this->assertEquals(404, $e->getCode()); + $this->assertContains('HTTP/1.1 404 Not Found', $e->getMessage()); } }