Browse Source

Merge remote-tracking branch 'till/svn-auth-reloaded'

Jordi Boggiano 13 years ago
parent
commit
6c2b78a669

+ 3 - 0
.gitignore

@@ -5,3 +5,6 @@
 /vendor
 /nbproject
 phpunit.xml
+.vagrant
+Vagrantfile
+.idea

+ 85 - 10
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 <mail@ben-bieker.de>
+ * @author Till Klampaeckel <till@php.net>
  */
 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');
         }
     }
-}
+
+    /**
+     * 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;
+    }
+}

+ 124 - 106
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 <j.boggiano@seld.be>
+ * @author Till Klampaeckel <till@php.net>
  */
 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();
+    }
+
+    /**
+     * 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->detectSvnAuth();
+        // 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.
+     *
+     * @param string $url
      *
-     * @return void
-     * @uses   parent::$baseUrl
-     * @uses   self::$useAuth, self::$svnUsername, self::$svnPassword
-     * @see    self::__construct()
+     * @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;
     }
 }

+ 205 - 0
src/Composer/Util/Svn.php

@@ -0,0 +1,205 @@
+<?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\IO\IOInterface;
+
+/**
+ * @author Till Klampaeckel <till@php.net>
+ */
+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;
+    }
+}

+ 45 - 18
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));
+
+        $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));
 
-        $this->assertEquals($expect, $svn->getSvnCredentialString());
+        $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));
+        }
+    }
 }

+ 49 - 0
tests/Composer/Test/Util/SvnTest.php

@@ -0,0 +1,49 @@
+<?php
+namespace Composer\Test\Util;
+
+use Composer\IO\NullIO;
+use Composer\Util\Svn;
+
+class SvnTest
+{
+    /**
+     * Provide some examples for {@self::testCredentials()}.
+     *
+     * @return array
+     */
+    public function urlProvider()
+    {
+        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 '' ")),
+        );
+    }
+
+    /**
+     * 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)
+        );
+    }
+}