diff --git a/.gitignore b/.gitignore index b3424784d..0e883e5c1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ /vendor /nbproject phpunit.xml +.vagrant +Vagrantfile +.idea diff --git a/src/Composer/Downloader/SvnDownloader.php b/src/Composer/Downloader/SvnDownloader.php index 28d3e2101..6ded2e93f 100644 --- a/src/Composer/Downloader/SvnDownloader.php +++ b/src/Composer/Downloader/SvnDownloader.php @@ -14,22 +14,38 @@ namespace Composer\Downloader; use Composer\Package\PackageInterface; use Composer\Util\ProcessExecutor; +use Composer\Util\Svn as SvnUtil; /** * @author Ben Bieker + * @author Till Klampaeckel */ class SvnDownloader extends VcsDownloader { + /** + * @var bool + */ + protected $useAuth = false; + + /** + * @var \Composer\Util\Svn + */ + protected $util; + /** * {@inheritDoc} */ public function doDownload(PackageInterface $package, $path) { - $url = escapeshellarg($package->getSourceUrl()); - $ref = escapeshellarg($package->getSourceReference()); - $path = escapeshellarg($path); + $url = $package->getSourceUrl(); + $ref = $package->getSourceReference(); + + $util = $this->getUtil($url); + + $command = $util->getCommand("svn co", sprintf("%s/%s", $url, $ref), $path); + $this->io->write(" Checking out ".$package->getSourceReference()); - $this->process->execute(sprintf('svn co %s/%s %s', $url, $ref, $path)); + $this->execute($command, $util); } /** @@ -37,11 +53,14 @@ class SvnDownloader extends VcsDownloader */ public function doUpdate(PackageInterface $initial, PackageInterface $target, $path) { - $ref = escapeshellarg($target->getSourceReference()); - $path = escapeshellarg($path); - $url = escapeshellarg($target->getSourceUrl()); - $this->io->write(" Checking out ".$target->getSourceReference()); - $this->process->execute(sprintf('cd %s && svn switch %s/%s', $path, $url, $ref)); + $url = $target->getSourceUrl(); + $ref = $target->getSourceReference(); + + $util = $this->getUtil($url); + $command = $util->getCommand("svn switch", sprintf("%s/%s", $url, $ref)); + + $this->io->write(" Checking out " . $ref); + $this->execute(sprintf('cd %s && %s', $path, $command), $util); } /** @@ -54,4 +73,60 @@ class SvnDownloader extends VcsDownloader throw new \RuntimeException('Source directory ' . $path . ' has uncommitted changes'); } } -} \ No newline at end of file + + /** + * Wrap {@link \Composer\Util\ProcessExecutor::execute(). + * + * @param string $cmd + * @param SvnUtil $util + * + * @return string + */ + protected function execute($command, SvnUtil $util) + { + $status = $this->process->execute($command, $output); + if (0 === $status) { + return $output; + } + + // this could be any failure, since SVN exits with 1 always + + if (empty($output)) { + $output = $this->process->getErrorOutput(); + } + + if (!$this->io->isInteractive()) { + return $output; + } + + // the error is not auth-related + if (false === strpos($output, 'authorization failed:')) { + return $output; + } + + // no authorization has been detected so far + if (!$this->useAuth) { + $this->useAuth = $util->doAuthDance()->hasAuth(); + $credentials = $util->getCredentialString(); + + // restart the process + $output = $this->execute($command . ' ' . $credentials, $util); + } else { + $this->io->write("Authorization failed: {$command}"); + } + return $output; + } + + /** + * This is potentially heavy - recreating Util often. + * + * @param string $url + * + * @return \Composer\Util\Svn + */ + protected function getUtil($url) + { + $util = new SvnUtil($url, $this->io); + return $util; + } +} diff --git a/src/Composer/Repository/Vcs/SvnDriver.php b/src/Composer/Repository/Vcs/SvnDriver.php index 1e100bf96..74c188da7 100644 --- a/src/Composer/Repository/Vcs/SvnDriver.php +++ b/src/Composer/Repository/Vcs/SvnDriver.php @@ -4,10 +4,12 @@ namespace Composer\Repository\Vcs; use Composer\Json\JsonFile; use Composer\Util\ProcessExecutor; +use Composer\Util\Svn as SvnUtil; use Composer\IO\IOInterface; /** * @author Jordi Boggiano + * @author Till Klampaeckel */ class SvnDriver extends VcsDriver { @@ -17,29 +19,93 @@ class SvnDriver extends VcsDriver protected $infoCache = array(); /** - * @var boolean $useAuth Contains credentials, or not? + * Contains credentials, or not? + * @var boolean */ protected $useAuth = false; /** - * @var string $svnUsername + * To determine if we should cache the credentials supplied by the user. By default: no cache. + * @var boolean + */ + protected $useCache = false; + + /** + * @var string */ protected $svnUsername = ''; /** - * @var string $svnPassword + * @var string */ protected $svnPassword = ''; + /** + * @var \Composer\Util\Svn + */ + protected $util; + + /** + * @param string $url + * @param IOInterface $io + * @param ProcessExecutor $process + * + * @return $this + */ public function __construct($url, IOInterface $io, ProcessExecutor $process = null) { + $url = self::fixSvnUrl($url); parent::__construct($this->baseUrl = rtrim($url, '/'), $io, $process); if (false !== ($pos = strrpos($url, '/trunk'))) { $this->baseUrl = substr($url, 0, $pos); } + $this->util = new SvnUtil($this->baseUrl, $io); + $this->useAuth = $this->util->hasAuth(); + } - $this->detectSvnAuth(); + /** + * Execute an SVN command and try to fix up the process with credentials + * if necessary. The command is 'fixed up' with {@link self::getSvnCommand()}. + * + * @param string $command The svn command to run. + * @param string $url The SVN URL. + * + * @return string + */ + public function execute($command, $url) + { + $svnCommand = $this->util->getCommand($command, $url); + + $status = $this->process->execute( + $svnCommand, + $output + ); + + if (0 === $status) { + return $output; + } + + // this could be any failure, since SVN exits with 1 always + if (!$this->io->isInteractive()) { + return $output; + } + + // the error is not auth-related + if (strpos($output, 'authorization failed:') === false) { + return $output; + } + + // no authorization has been detected so far + if (!$this->useAuth) { + $this->useAuth = $this->util->doAuthDance()->hasAuth(); + + // restart the process + $output = $this->execute($command, $url); + } else { + $this->io->write("Authorization failed: {$svnCommand}"); + } + return $output; } /** @@ -98,30 +164,15 @@ class SvnDriver extends VcsDriver $rev = ''; } - $this->process->execute( - sprintf( - 'svn cat --non-interactive %s %s', - $this->getSvnCredentialString(), - escapeshellarg($this->baseUrl.$identifier.'composer.json'.$rev) - ), - $composer - ); - - if (!trim($composer)) { + $output = $this->execute('svn cat', $this->baseUrl . $identifier . 'composer.json' . $rev); + if (!trim($output)) { return; } - $composer = JsonFile::parseJson($composer); + $composer = JsonFile::parseJson($output); if (!isset($composer['time'])) { - $this->process->execute( - sprintf( - 'svn info %s %s', - $this->getSvnCredentialString(), - escapeshellarg($this->baseUrl.$identifier.$rev) - ), - $output - ); + $output = $this->execute('svn info', $this->baseUrl . $identifier . $rev); foreach ($this->process->splitLines($output) as $line) { if ($line && preg_match('{^Last Changed Date: ([^(]+)}', $line, $match)) { $date = new \DateTime($match[1]); @@ -142,18 +193,14 @@ class SvnDriver extends VcsDriver public function getTags() { if (null === $this->tags) { - $this->process->execute( - sprintf( - 'svn ls --non-interactive %s %s', - $this->getSvnCredentialString(), - escapeshellarg($this->baseUrl.'/tags') - ), - $output - ); $this->tags = array(); - foreach ($this->process->splitLines($output) as $tag) { - if ($tag) { - $this->tags[rtrim($tag, '/')] = '/tags/'.$tag; + + $output = $this->execute('svn ls', $this->baseUrl . '/tags'); + if ($output) { + foreach ($this->process->splitLines($output) as $tag) { + if ($tag) { + $this->tags[rtrim($tag, '/')] = '/tags/'.$tag; + } } } } @@ -167,71 +214,45 @@ class SvnDriver extends VcsDriver public function getBranches() { if (null === $this->branches) { - $this->process->execute( - sprintf( - 'svn ls --verbose --non-interactive %s %s', - $this->getSvnCredentialString(), - escapeshellarg($this->baseUrl.'/') - ), - $output - ); - $this->branches = array(); - foreach ($this->process->splitLines($output) as $line) { - preg_match('{^\s*(\S+).*?(\S+)\s*$}', $line, $match); - if ($match[2] === 'trunk/') { - $this->branches['trunk'] = '/trunk/@'.$match[1]; - break; + + $output = $this->execute('svn ls --verbose', $this->baseUrl . '/'); + if ($output) { + foreach ($this->process->splitLines($output) as $line) { + $line = trim($line); + if ($line && preg_match('{^\s*(\S+).*?(\S+)\s*$}', $line, $match)) { + if (isset($match[1]) && isset($match[2]) && $match[2] === 'trunk/') { + $this->branches['trunk'] = '/trunk/@'.$match[1]; + break; + } + } } } unset($output); - $this->process->execute( - sprintf( - 'svn ls --verbose --non-interactive %s', - $this->getSvnCredentialString(), - escapeshellarg($this->baseUrl.'/branches') - ), - $output - ); - foreach ($this->process->splitLines(trim($output)) as $line) { - preg_match('{^\s*(\S+).*?(\S+)\s*$}', $line, $match); - if ($match[2] === './') { - continue; + $output = $this->execute('svn ls --verbose', $this->baseUrl . '/branches'); + if ($output) { + foreach ($this->process->splitLines(trim($output)) as $line) { + $line = trim($line); + if ($line && preg_match('{^\s*(\S+).*?(\S+)\s*$}', $line, $match)) { + if (isset($match[1]) && isset($match[2]) && $match[2] !== './') { + $this->branches[rtrim($match[2], '/')] = '/branches/'.$match[2].'@'.$match[1]; + } + } } - $this->branches[rtrim($match[2], '/')] = '/branches/'.$match[2].'@'.$match[1]; } } return $this->branches; } - /** - * Return the credential string for the svn command. - * - * --no-auth-cache when credentials are present - * - * @return string - */ - public function getSvnCredentialString() - { - if ($this->useAuth !== true) { - return ''; - } - $str = ' --no-auth-cache --username %s --password %s '; - return sprintf( - $str, - escapeshellarg($this->svnUsername), - escapeshellarg($this->svnPassword) - ); - } - /** * {@inheritDoc} */ public static function supports($url, $deep = false) { - if (preg_match('#(^svn://|//svn\.)#i', $url)) { + $url = self::fixSvnUrl($url); + if (preg_match('#((^svn://)|(^svn\+ssh://)|(^file:///)|(^http)|(svn\.))#i', $url)) { return true; } @@ -242,37 +263,34 @@ class SvnDriver extends VcsDriver $processExecutor = new ProcessExecutor(); $exit = $processExecutor->execute( - sprintf( - 'svn info --non-interactive %s %s 2>/dev/null', - $this->getSvnCredentialString(), - escapeshellarg($url) - ), - $ignored + "svn info --non-interactive {$url}", + $ignoredOutput ); - return $exit === 0; + + if ($exit === 0) { + // This is definitely a Subversion repository. + return true; + } + if (preg_match('/authorization failed/i', $processExecutor->getErrorOutput())) { + // This is likely a remote Subversion repository that requires + // authentication. We will handle actual authentication later. + return true; + } + return false; } /** - * This is quick and dirty - thoughts? + * An absolute path (leading '/') is converted to a file:// url. * - * @return void - * @uses parent::$baseUrl - * @uses self::$useAuth, self::$svnUsername, self::$svnPassword - * @see self::__construct() + * @param string $url + * + * @return string */ - protected function detectSvnAuth() + protected static function fixSvnUrl($url) { - $uri = parse_url($this->baseUrl); - if (empty($uri['user'])) { - return; + if (strpos($url, '/', 0) === 0) { + $url = 'file://' . $url; } - - $this->svnUsername = $uri['user']; - - if (!empty($uri['pass'])) { - $this->svnPassword = $uri['pass']; - } - - $this->useAuth = true; + return $url; } } diff --git a/src/Composer/Util/Svn.php b/src/Composer/Util/Svn.php new file mode 100644 index 000000000..ca1e7e1d5 --- /dev/null +++ b/src/Composer/Util/Svn.php @@ -0,0 +1,205 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Util; + +use Composer\IO\IOInterface; + +/** + * @author Till Klampaeckel + */ +class Svn +{ + /** + * @var mixed + */ + protected $credentials; + + /** + * @var bool + */ + protected $hasAuth; + + /** + * @var \Composer\IO\IOInterface + */ + protected $io; + + /** + * @var string + */ + protected $url; + + /** + * Cache credentials. + * @var bool + */ + protected $useCache = false; + + /** + * @param string $url + * @param \Composer\IO\IOInterface $io + * + * @return \Composer\Util\Svn + */ + public function __construct($url, IOInterface $io) + { + $this->url = $url; + $this->io = $io; + } + + /** + * Repositories requests credentials, let's put them in. + * + * @return \Composer\Util\Svn + */ + public function doAuthDance() + { + $this->io->write("The Subversion server ({$this->url}) requested credentials:"); + + $this->hasAuth = true; + $this->credentials = new \stdClass(); + + $this->credentials->username = $this->io->ask("Username: "); + $this->credentials->password = $this->io->askAndHideAnswer("Password: "); + + $pleaseCache = $this->io->askConfirmation("Should Subversion cache these credentials? (yes/no) ", false); + if ($pleaseCache) { + $this->useCache = true; + } + return $this; + } + /** + * Return the no-auth-cache switch. + * + * @return string + */ + public function getAuthCache() + { + if (!$this->useCache) { + return '--no-auth-cache '; + } + return ''; + } + + /** + * A method to create the svn commands run. + * + * @param string $cmd Usually 'svn ls' or something like that. + * @param string $url Repo URL. + * @param string $path The path to run this against (e.g. a 'co' into) + * @param mixed $pipe Optional pipe for the output. + * + * @return string + */ + public function getCommand($cmd, $url, $path = '', $pipe = null) + { + $cmd = sprintf('%s %s%s %s', + $cmd, + '--non-interactive ', + $this->getCredentialString(), + escapeshellarg($url) + ); + if (!empty($path)) { + $cmd .= ' ' . escapeshellarg($path); + } + if ($pipe !== null) { + $cmd .= ' ' . $pipe; + } + return $cmd; + } + + /** + * Return the credential string for the svn command. + * + * Adds --no-auth-cache when credentials are present. + * + * @return string + * @uses self::$useAuth + */ + public function getCredentialString() + { + if ($this->hasAuth === null) { + $this->hasAuth(); + } + if (!$this->hasAuth) { + return ''; + } + return sprintf( + ' %s--username %s --password %s ', + $this->getAuthCache(), + escapeshellarg($this->getUsername()), + escapeshellarg($this->getPassword()) + ); + } + + /** + * Get the password for the svn command. Can be empty. + * + * @return string + * @throws \LogicException + */ + public function getPassword() + { + if ($this->credentials === null) { + throw new \LogicException("No auth detected."); + } + if (isset($this->credentials->password)) { + return $this->credentials->password; + } + return ''; // could be empty + } + + /** + * Get the username for the svn command. + * + * @return string + * @throws \LogicException + */ + public function getUsername() + { + if ($this->credentials === null) { + throw new \LogicException("No auth detected."); + } + return $this->credentials->username; + } + + /** + * Detect Svn Auth. + * + * @param string $url + * + * @return \stdClass + */ + public function hasAuth() + { + if ($this->hasAuth !== null) { + return $this->hasAuth; + } + + $uri = parse_url($this->url); + if (empty($uri['user'])) { + $this->hasAuth = false; + return $this->hasAuth; + } + + $this->hasAuth = true; + $this->credentials = new \stdClass(); + + $this->credentials->username = $uri['user']; + + if (!empty($uri['pass'])) { + $this->credentials->password = $uri['pass']; + } + + return $this->hasAuth; + } +} \ No newline at end of file diff --git a/tests/Composer/Test/Repository/Vcs/SvnDriverTest.php b/tests/Composer/Test/Repository/Vcs/SvnDriverTest.php index e9734eab6..9e85c5129 100644 --- a/tests/Composer/Test/Repository/Vcs/SvnDriverTest.php +++ b/tests/Composer/Test/Repository/Vcs/SvnDriverTest.php @@ -21,28 +21,28 @@ use Composer\IO\NullIO; class SvnDriverTest extends \PHPUnit_Framework_TestCase { /** - * Provide some examples for {@self::testCredentials()}. - * - * @return array + * Test the execute method. */ - public function urlProvider() + public function testExecute() { - return array( - array('http://till:test@svn.example.org/', $this->getCmd(" --no-auth-cache --username 'till' --password 'test' ")), - array('http://svn.apache.org/', ''), - array('svn://johndoe@example.org', $this->getCmd(" --no-auth-cache --username 'johndoe' --password '' ")), - ); - } + $this->markTestIncomplete("Currently no way to mock the output value which is passed by reference."); - /** - * @dataProvider urlProvider - */ - public function testCredentials($url, $expect) - { - $io = new \Composer\IO\NullIO; - $svn = new SvnDriver($url, $io); + $console = $this->getMock('Composer\IO\IOInterface'); + $console->expects($this->once()) + ->method('isInteractive') + ->will($this->returnValue(true)); - $this->assertEquals($expect, $svn->getSvnCredentialString()); + $output = "svn: OPTIONS of 'http://corp.svn.local/repo':"; + $output .= " authorization failed: Could not authenticate to server:"; + $output .= " rejected Basic challenge (http://corp.svn.local/)"; + + $process = $this->getMock('Composer\Util\ProcessExecutor'); + $process->expects($this->once()) + ->method('execute') + ->will($this->returnValue(1)); + + $svn = new SvnDriver('http://till:secret@corp.svn.local/repo', $console, $process); + $svn->execute('svn ls', 'http://corp.svn.local/repo'); } private function getCmd($cmd) @@ -53,4 +53,31 @@ class SvnDriverTest extends \PHPUnit_Framework_TestCase return $cmd; } + + public static function supportProvider() + { + return array( + array('http://svn.apache.org', true), + array('https://svn.sf.net', true), + array('svn://example.org', true), + array('svn+ssh://example.org', true), + array('file:///d:/repository_name/project', true), + array('file:///repository_name/project', true), + array('/absolute/path', true), + ); + } + + /** + * Nail a bug in {@link SvnDriver::support()}. + * + * @dataProvider supportProvider + */ + public function testSupport($url, $assertion) + { + if ($assertion === true) { + $this->assertTrue(SvnDriver::supports($url)); + } else { + $this->assertFalse(SvnDriver::supports($url)); + } + } } diff --git a/tests/Composer/Test/Util/SvnTest.php b/tests/Composer/Test/Util/SvnTest.php new file mode 100644 index 000000000..2a1839f67 --- /dev/null +++ b/tests/Composer/Test/Util/SvnTest.php @@ -0,0 +1,49 @@ +getCmd(" --no-auth-cache --username 'till' --password 'test' ")), + array('http://svn.apache.org/', ''), + array('svn://johndoe@example.org', $this->getCmd(" --no-auth-cache --username 'johndoe' --password '' ")), + ); + } + + /** + * Test the credential string. + * + * @param string $url The SVN url. + * @param string $expect The expectation for the test. + * + * @dataProvider urlProvider + */ + public function testCredentials($url, $expect) + { + $svn = new Svn($url, new NullIO); + + $this->assertEquals($expect, $svn->getCredentialString()); + } + + public function testInteractiveString() + { + $url = 'http://svn.example.org'; + + $svn = new Svn($url, new NullIO()); + + $this->assertEquals( + "svn ls --non-interactive 'http://svn.example.org'", + $svn->getCommand('svn ls', $url) + ); + } +} \ No newline at end of file