Browse Source

Merge branch 'oauth', fixes #423

Jordi Boggiano 12 years ago
parent
commit
1df17a4b51

+ 19 - 48
src/Composer/Command/ConfigCommand.php

@@ -17,6 +17,7 @@ use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Composer\Config;
+use Composer\Config\JsonConfigSource;
 use Composer\Factory;
 use Composer\Json\JsonFile;
 use Composer\Json\JsonManipulator;
@@ -32,6 +33,11 @@ class ConfigCommand extends Command
      */
     protected $configFile;
 
+    /**
+     * @var Composer\Config\JsonConfigSource
+     */
+    protected $configSource;
+
     /**
      * {@inheritDoc}
      */
@@ -94,11 +100,12 @@ EOT
 
         // Get the local composer.json, global config.json, or if the user
         // passed in a file to use
-        $this->configFile = $input->getOption('global')
+        $configFile = $input->getOption('global')
             ? (Factory::createConfig()->get('home') . '/config.json')
             : $input->getOption('file');
 
-        $this->configFile = new JsonFile($this->configFile);
+        $this->configFile = new JsonFile($configFile);
+        $this->configSource = new JsonConfigSource($this->configFile);
 
         // initialize the global file if it's not there
         if ($input->getOption('global') && !$this->configFile->exists()) {
@@ -161,25 +168,17 @@ EOT
         // handle repositories
         if (preg_match('/^repos?(?:itories)?\.(.+)/', $input->getArgument('setting-key'), $matches)) {
             if ($input->getOption('unset')) {
-                return $this->manipulateJson('removeRepository', $matches[1], function (&$config, $repo) {
-                    unset($config['repositories'][$repo]);
-                });
+                return $this->configSource->removeRepository($matches[1]);
             }
 
             if (2 !== count($values)) {
                 throw new \RuntimeException('You must pass the type and a url. Example: php composer.phar config repositories.foo vcs http://bar.com');
             }
 
-            return $this->manipulateJson(
-                'addRepository',
-                $matches[1],
-                array(
-                    'type' => $values[0],
-                    'url'  => $values[1],
-                ), function (&$config, $repo, $repoConfig) {
-                    $config['repositories'][$repo] = $repoConfig;
-                }
-            );
+            return $this->configSource->addRepository($matches[1], array(
+                'type' => $values[0],
+                'url'  => $values[1],
+            ));
         }
 
         // handle config values
@@ -217,9 +216,7 @@ EOT
         foreach ($uniqueConfigValues as $name => $callbacks) {
              if ($settingKey === $name) {
                 if ($input->getOption('unset')) {
-                    return $this->manipulateJson('removeConfigSetting', $settingKey, function (&$config, $key) {
-                        unset($config['config'][$key]);
-                    });
+                    return $this->configSource->removeConfigSetting($settingKey);
                 }
 
                 list($validator, $normalizer) = $callbacks;
@@ -234,18 +231,14 @@ EOT
                     ));
                 }
 
-                return $this->manipulateJson('addConfigSetting', $settingKey, $normalizer($values[0]), function (&$config, $key, $val) {
-                    $config['config'][$key] = $val;
-                });
+                return $this->configSource->addConfigSetting($settingKey, $normalizer($values[0]));
             }
         }
 
         foreach ($multiConfigValues as $name => $callbacks) {
             if ($settingKey === $name) {
                 if ($input->getOption('unset')) {
-                    return $this->manipulateJson('removeConfigSetting', $settingKey, function (&$config, $key) {
-                        unset($config['config'][$key]);
-                    });
+                    return $this->configSource->removeConfigSetting($settingKey);
                 }
 
                 list($validator, $normalizer) = $callbacks;
@@ -256,33 +249,11 @@ EOT
                     ));
                 }
 
-                return $this->manipulateJson('addConfigSetting', $settingKey, $normalizer($values), function (&$config, $key, $val) {
-                    $config['config'][$key] = $val;
-                });
+                return $this->configSource->addConfigSetting($settingKey, $normalizer($values));
             }
         }
-    }
 
-    protected function manipulateJson($method, $args, $fallback)
-    {
-        $args = func_get_args();
-        // remove method & fallback
-        array_shift($args);
-        $fallback = array_pop($args);
-
-        $contents = file_get_contents($this->configFile->getPath());
-        $manipulator = new JsonManipulator($contents);
-
-        // try to update cleanly
-        if (call_user_func_array(array($manipulator, $method), $args)) {
-            file_put_contents($this->configFile->getPath(), $manipulator->getContents());
-        } else {
-            // on failed clean update, call the fallback and rewrite the whole file
-            $config = $this->configFile->read();
-            array_unshift($args, $config);
-            call_user_func_array($fallback, $args);
-            $this->configFile->write($config);
-        }
+        throw new \InvalidArgumentException('Setting '.$settingKey.' does not exist or is not supported by this command');
     }
 
     /**

+ 21 - 0
src/Composer/Config.php

@@ -12,6 +12,8 @@
 
 namespace Composer;
 
+use Composer\Config\ConfigSourceInterface;
+
 /**
  * @author Jordi Boggiano <j.boggiano@seld.be>
  */
@@ -34,6 +36,7 @@ class Config
 
     private $config;
     private $repositories;
+    private $configSource;
 
     public function __construct()
     {
@@ -42,6 +45,16 @@ class Config
         $this->repositories = static::$defaultRepositories;
     }
 
+    public function setConfigSource(ConfigSourceInterface $source)
+    {
+        $this->configSource = $source;
+    }
+
+    public function getConfigSource()
+    {
+        return $this->configSource;
+    }
+
     /**
      * Merges new config values with the existing ones (overriding)
      *
@@ -110,6 +123,10 @@ class Config
                 return rtrim($this->process($this->config[$key]), '/\\');
 
             default:
+                if (!isset($this->config[$key])) {
+                    return null;
+                }
+
                 return $this->process($this->config[$key]);
         }
     }
@@ -135,6 +152,10 @@ class Config
     {
         $config = $this;
 
+        if (!is_string($value)) {
+            return $value;
+        }
+
         return preg_replace_callback('#\{\$(.+)\}#', function ($match) use ($config) {
             return $config->get($match[1]);
         }, $value);

+ 29 - 0
src/Composer/Config/ConfigSourceInterface.php

@@ -0,0 +1,29 @@
+<?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\Config;
+
+use Composer\Json\JsonManipulator;
+
+/**
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+interface ConfigSourceInterface
+{
+    public function addRepository($name, $config);
+
+    public function removeRepository($name);
+
+    public function addConfigSetting($name, $value);
+
+    public function removeConfigSetting($name);
+}

+ 80 - 0
src/Composer/Config/JsonConfigSource.php

@@ -0,0 +1,80 @@
+<?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\Config;
+
+use Composer\Json\JsonManipulator;
+use Composer\Json\JsonFile;
+
+/**
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class JsonConfigSource implements ConfigSourceInterface
+{
+    private $file;
+    private $manipulator;
+
+    public function __construct(JsonFile $file)
+    {
+        $this->file = $file;
+    }
+
+    public function addRepository($name, $config)
+    {
+        return $this->manipulateJson('addRepository', $name, $config, function (&$config, $repo, $repoConfig) {
+            $config['repositories'][$repo] = $repoConfig;
+        });
+    }
+
+    public function removeRepository($name)
+    {
+        return $this->manipulateJson('removeRepository', $name, function (&$config, $repo) {
+            unset($config['repositories'][$repo]);
+        });
+    }
+
+    public function addConfigSetting($name, $value)
+    {
+        $this->manipulateJson('addConfigSetting', $name, $value, function (&$config, $key, $val) {
+            $config['config'][$key] = $val;
+        });
+    }
+
+    public function removeConfigSetting($name)
+    {
+        return $this->manipulateJson('removeConfigSetting', $name, function (&$config, $key) {
+            unset($config['config'][$key]);
+        });
+    }
+
+    protected function manipulateJson($method, $args, $fallback)
+    {
+        $args = func_get_args();
+        // remove method & fallback
+        array_shift($args);
+        $fallback = array_pop($args);
+
+        $contents = file_get_contents($this->file->getPath());
+        $manipulator = new JsonManipulator($contents);
+
+        // try to update cleanly
+        if (call_user_func_array(array($manipulator, $method), $args)) {
+            file_put_contents($this->file->getPath(), $manipulator->getContents());
+        } else {
+            // on failed clean update, call the fallback and rewrite the whole file
+            $config = $this->file->read();
+            array_unshift($args, $config);
+            call_user_func_array($fallback, $args);
+            $this->file->write($config);
+        }
+    }
+}

+ 7 - 5
src/Composer/Downloader/GitDownloader.php

@@ -51,10 +51,12 @@ class GitDownloader extends VcsDownloader
         $this->io->write("    Checking out ".$ref);
         $command = 'cd %s && git remote set-url composer %s && git fetch composer && git fetch --tags composer';
 
-        // capture username/password from github URL if there is one
-        $this->process->execute(sprintf('cd %s && git remote -v', escapeshellarg($path)), $output);
-        if (preg_match('{^composer\s+https://(.+):(.+)@github.com/}im', $output, $match)) {
-            $this->io->setAuthorization('github.com', $match[1], $match[2]);
+        if (!$this->io->hasAuthorization('github.com')) {
+            // capture username/password from github URL if there is one
+            $this->process->execute(sprintf('cd %s && git remote -v', escapeshellarg($path)), $output);
+            if (preg_match('{^composer\s+https://(.+):(.+)@github.com/}im', $output, $match)) {
+                $this->io->setAuthorization('github.com', $match[1], $match[2]);
+            }
         }
 
         $commandCallable = function($url) use ($ref, $path, $command) {
@@ -327,7 +329,7 @@ class GitDownloader extends VcsDownloader
 
     protected function sanitizeUrl($message)
     {
-        return preg_match('{://(.+?):.+?@}', '://$1:***@', $message);
+        return preg_replace('{://(.+?):.+?@}', '://$1:***@', $message);
     }
 
     protected function setPushUrl(PackageInterface $package, $path)

+ 11 - 0
src/Composer/Downloader/TransportException.php

@@ -17,4 +17,15 @@ namespace Composer\Downloader;
  */
 class TransportException extends \Exception
 {
+    protected $headers;
+
+    public function setHeaders($headers)
+    {
+        $this->headers = $headers;
+    }
+
+    public function getHeaders()
+    {
+        return $this->headers;
+    }
 }

+ 9 - 0
src/Composer/Factory.php

@@ -12,6 +12,7 @@
 
 namespace Composer;
 
+use Composer\Config\JsonConfigSource;
 use Composer\Json\JsonFile;
 use Composer\IO\IOInterface;
 use Composer\Repository\ComposerRepository;
@@ -59,6 +60,7 @@ class Factory
         if ($file->exists()) {
             $config->merge($file->read());
         }
+        $config->setConfigSource(new JsonConfigSource($file));
 
         return $config;
     }
@@ -138,6 +140,13 @@ class Factory
         $config = static::createConfig();
         $config->merge($localConfig);
 
+        // reload oauth token from config if available
+        if ($tokens = $config->get('github-oauth')) {
+            foreach ($tokens as $domain => $token) {
+                $io->setAuthorization($domain, $token, 'x-oauth-basic');
+            }
+        }
+
         $vendorDir = $config->get('vendor-dir');
         $binDir = $config->get('bin-dir');
 

+ 142 - 48
src/Composer/Repository/Vcs/GitHubDriver.php

@@ -235,6 +235,62 @@ class GitHubDriver extends VcsDriver
         return 'git@github.com:'.$this->owner.'/'.$this->repository.'.git';
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    protected function getContents($url, $tryClone = false)
+    {
+        try {
+            return parent::getContents($url);
+        } catch (TransportException $e) {
+            switch ($e->getCode()) {
+                case 401:
+                case 404:
+                    if (!$this->io->isInteractive() && $tryClone) {
+                        return $this->attemptCloneFallback($e);
+                    }
+
+                    $this->io->write('Your GitHub credentials are required to fetch private repository metadata (<info>'.$this->url.'</info>):');
+                    $this->authorizeOauth();
+
+                    return parent::getContents($url);
+
+                case 403:
+                    if (!$this->io->isInteractive() && $tryClone) {
+                        return $this->attemptCloneFallback($e);
+                    }
+
+                    $rateLimited = false;
+                    foreach ($e->getHeaders() as $header) {
+                        if (preg_match('{^X-RateLimit-Remaining: *0$}i', trim($header))) {
+                            $rateLimited = true;
+                        }
+                    }
+
+                    if (!$this->io->hasAuthorization($this->originUrl)) {
+                        if (!$this->io->isInteractive()) {
+                            $this->io->write('<error>GitHub API limit exhausted. Failed to get metadata for the '.$this->url.' repository, try running in interactive mode so that you can enter your GitHub credentials to increase the API limit</error>');
+                            throw $e;
+                        }
+
+                        $this->io->write('API limit exhausted. Enter your GitHub credentials to get a larger API limit (<info>'.$this->url.'</info>):');
+                        $this->authorizeOauth();
+
+                        return parent::getContents($url);
+                    }
+
+                    if ($rateLimited) {
+                        $this->io->write('<error>GitHub API limit exhausted. You are already authorized so you will have to wait a while before doing more requests</error>');
+                    }
+
+                    throw $e;
+
+                default:
+                    throw $e;
+            }
+        }
+    }
+
     /**
      * Fetch root identifier from GitHub
      *
@@ -243,60 +299,98 @@ class GitHubDriver extends VcsDriver
     protected function fetchRootIdentifier()
     {
         $repoDataUrl = 'https://api.github.com/repos/'.$this->owner.'/'.$this->repository;
+
+        $repoData = JsonFile::parseJson($this->getContents($repoDataUrl, true), $repoDataUrl);
+        if (null === $repoData && null !== $this->gitDriver) {
+            return;
+        }
+
+        $this->isPrivate = !empty($repoData['private']);
+        if (isset($repoData['default_branch'])) {
+            $this->rootIdentifier = $repoData['default_branch'];
+        } elseif (isset($repoData['master_branch'])) {
+            $this->rootIdentifier = $repoData['master_branch'];
+        } else {
+            $this->rootIdentifier = 'master';
+        }
+        $this->hasIssues = !empty($repoData['has_issues']);
+    }
+
+    protected function attemptCloneFallback()
+    {
+        $this->isPrivate = true;
+
+        try {
+            // 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->config,
+                $this->process,
+                $this->remoteFilesystem
+            );
+            $this->gitDriver->initialize();
+
+            return;
+        } catch (\RuntimeException $e) {
+            $this->gitDriver = null;
+
+            $this->io->write('<error>Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your GitHub credentials</error>');
+            throw $e;
+        }
+    }
+
+    protected function authorizeOAuth()
+    {
         $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)");
-            }
+
+        $this->io->write('The credentials will be swapped for an OAuth token stored in '.$this->config->get('home').'/config.json, your password will not be stored');
+        $this->io->write('To revoke access to this token you can visit https://github.com/settings/applications');
+        while ($attemptCounter++ < 5) {
             try {
-                $repoData = JsonFile::parseJson($this->getContents($repoDataUrl), $repoDataUrl);
-                if (isset($repoData['default_branch'])) {
-                    $this->rootIdentifier = $repoData['default_branch'];
-                } elseif (isset($repoData['master_branch'])) {
-                    $this->rootIdentifier = $repoData['master_branch'];
-                } else {
-                    $this->rootIdentifier = 'master';
+                $username = $this->io->ask('Username: ');
+                $password = $this->io->askAndHideAnswer('Password: ');
+                $this->io->setAuthorization($this->originUrl, $username, $password);
+
+                // build up OAuth app name
+                $appName = 'Composer';
+                if (0 === $this->process->execute('hostname', $output)) {
+                    $appName .= ' on ' . trim($output);
                 }
-                $this->hasIssues = !empty($repoData['has_issues']);
+
+                $contents = JsonFile::parseJson($this->remoteFilesystem->getContents($this->originUrl, 'https://api.github.com/authorizations', false, array(
+                    'http' => array(
+                        'method' => 'POST',
+                        'header' => "Content-Type: application/json\r\n",
+                        'content' => json_encode(array(
+                            'scopes' => array('repo'),
+                            'note' => $appName,
+                            'note_url' => 'https://getcomposer.org/',
+                        )),
+                    )
+                )));
             } catch (TransportException $e) {
-                switch ($e->getCode()) {
-                    case 401:
-                    case 404:
-                        $this->isPrivate = true;
-
-                        try {
-                            // 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->config,
-                                $this->process,
-                                $this->remoteFilesystem
-                            );
-                            $this->gitDriver->initialize();
-
-                            return;
-                        } catch (\RuntimeException $e) {
-                            $this->gitDriver = null;
-                            if (!$this->io->isInteractive()) {
-                                $this->io->write('<error>Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your username and password</error>');
-                                throw $e;
-                            }
-                        }
-                        $this->io->write('Authentication required (<info>'.$this->url.'</info>):');
-                        $username = $this->io->ask('Username: ');
-                        $password = $this->io->askAndHideAnswer('Password: ');
-                        $this->io->setAuthorization($this->originUrl, $username, $password);
-                        break;
-
-                    default:
-                        throw $e;
-                        break;
+                if (401 === $e->getCode()) {
+                    $this->io->write('Invalid credentials.');
+                    continue;
                 }
+
+                throw $e;
             }
+
+            $this->io->setAuthorization($this->originUrl, $contents['token'], 'x-oauth-basic');
+
+            // store value in user config
+            $githubTokens = $this->config->get('github-oauth') ?: array();
+            $githubTokens[$this->originUrl] = $contents['token'];
+            $this->config->getConfigSource()->addConfigSetting('github-oauth', $githubTokens);
+
+            return;
         }
+
+        throw new \RuntimeException("Invalid GitHub credentials 5 times in a row, aborting.");
     }
 }

+ 49 - 18
src/Composer/Util/RemoteFilesystem.php

@@ -50,12 +50,13 @@ class RemoteFilesystem
      * @param string  $fileUrl   The file URL
      * @param string  $fileName  the local filename
      * @param boolean $progress  Display the progression
+     * @param array   $options   Additional context options
      *
      * @return bool true
      */
-    public function copy($originUrl, $fileUrl, $fileName, $progress = true)
+    public function copy($originUrl, $fileUrl, $fileName, $progress = true, $options = array())
     {
-        $this->get($originUrl, $fileUrl, $fileName, $progress);
+        $this->get($originUrl, $fileUrl, $options, $fileName, $progress);
 
         return $this->result;
     }
@@ -66,12 +67,13 @@ class RemoteFilesystem
      * @param string  $originUrl The origin URL
      * @param string  $fileUrl   The file URL
      * @param boolean $progress  Display the progression
+     * @param array   $options   Additional context options
      *
      * @return string The content
      */
-    public function getContents($originUrl, $fileUrl, $progress = true)
+    public function getContents($originUrl, $fileUrl, $progress = true, $options = array())
     {
-        $this->get($originUrl, $fileUrl, null, $progress);
+        $this->get($originUrl, $fileUrl, $options, null, $progress);
 
         return $this->result;
     }
@@ -79,14 +81,15 @@ class RemoteFilesystem
     /**
      * Get file content or copy action.
      *
-     * @param string  $originUrl The origin URL
-     * @param string  $fileUrl   The file URL
-     * @param string  $fileName  the local filename
-     * @param boolean $progress  Display the progression
+     * @param string  $originUrl         The origin URL
+     * @param string  $fileUrl           The file URL
+     * @param array   $additionalOptions context options
+     * @param string  $fileName          the local filename
+     * @param boolean $progress          Display the progression
      *
      * @throws TransportException When the file could not be downloaded
      */
-    protected function get($originUrl, $fileUrl, $fileName = null, $progress = true)
+    protected function get($originUrl, $fileUrl, $additionalOptions = array(), $fileName = null, $progress = true)
     {
         $this->bytesMax = 0;
         $this->result = null;
@@ -96,7 +99,7 @@ class RemoteFilesystem
         $this->progress = $progress;
         $this->lastProgress = null;
 
-        $options = $this->getOptionsForUrl($originUrl);
+        $options = $this->getOptionsForUrl($originUrl, $additionalOptions);
         $ctx = StreamContextFactory::getContext($options, array('notification' => array($this, 'callbackGet')));
 
         if ($this->progress) {
@@ -104,21 +107,32 @@ class RemoteFilesystem
         }
 
         $errorMessage = '';
+        $errorCode = 0;
         set_error_handler(function ($code, $msg) use (&$errorMessage) {
             if ($errorMessage) {
                 $errorMessage .= "\n";
             }
             $errorMessage .= preg_replace('{^file_get_contents\(.*?\): }', '', $msg);
         });
-        $result = file_get_contents($fileUrl, false, $ctx);
+        try {
+            $result = file_get_contents($fileUrl, false, $ctx);
+        } catch (\Exception $e) {
+            if ($e instanceof TransportException && !empty($http_response_header[0])) {
+                $e->setHeaders($http_response_header);
+            }
+        }
         if ($errorMessage && !ini_get('allow_url_fopen')) {
             $errorMessage = 'allow_url_fopen must be enabled in php.ini ('.$errorMessage.')';
         }
         restore_error_handler();
+        if (isset($e)) {
+            throw $e;
+        }
 
         // fix for 5.4.0 https://bugs.php.net/bug.php?id=61336
-        if (!empty($http_response_header[0]) && preg_match('{^HTTP/\S+ 404}i', $http_response_header[0])) {
+        if (!empty($http_response_header[0]) && preg_match('{^HTTP/\S+ ([45]\d\d)}i', $http_response_header[0], $match)) {
             $result = false;
+            $errorCode = $match[1];
         }
 
         // decode gzip
@@ -169,7 +183,12 @@ class RemoteFilesystem
         }
 
         if (false === $this->result) {
-            throw new TransportException('The "'.$fileUrl.'" file could not be downloaded: '.$errorMessage);
+            $e = new TransportException('The "'.$fileUrl.'" file could not be downloaded: '.$errorMessage, $errorCode);
+            if (!empty($http_response_header[0])) {
+                $e->setHeaders($http_response_header);
+            }
+
+            throw $e;
         }
     }
 
@@ -207,6 +226,12 @@ class RemoteFilesystem
                 }
                 break;
 
+            case STREAM_NOTIFY_AUTH_RESULT:
+                if (403 === $messageCode) {
+                    throw new TransportException($message, 403);
+                }
+                break;
+
             case STREAM_NOTIFY_FILE_SIZE_IS:
                 if ($this->bytesMax < $bytesMax) {
                     $this->bytesMax = $bytesMax;
@@ -233,9 +258,9 @@ class RemoteFilesystem
         }
     }
 
-    protected function getOptionsForUrl($originUrl)
+    protected function getOptionsForUrl($originUrl, $additionalOptions)
     {
-        $options['http']['header'] = sprintf(
+        $header = sprintf(
             "User-Agent: Composer/%s (%s; %s; PHP %s.%s.%s)\r\n",
             Composer::VERSION,
             php_uname('s'),
@@ -245,16 +270,22 @@ class RemoteFilesystem
             PHP_RELEASE_VERSION
         );
         if (extension_loaded('zlib')) {
-            $options['http']['header'] .= 'Accept-Encoding: gzip'."\r\n";
+            $header .= 'Accept-Encoding: gzip'."\r\n";
         }
 
         if ($this->io->hasAuthorization($originUrl)) {
             $auth = $this->io->getAuthorization($originUrl);
             $authStr = base64_encode($auth['username'] . ':' . $auth['password']);
-            $options['http']['header'] .= "Authorization: Basic $authStr\r\n";
+            $header .= "Authorization: Basic $authStr\r\n";
         }
 
-        $options = array_replace_recursive($options, $this->options);
+        $options = array_replace_recursive($this->options, $additionalOptions);
+
+        if (isset($options['http']['header'])) {
+            $options['http']['header'] = rtrim($options['http']['header'], "\r\n") . "\r\n" . $header;
+        } else {
+            $options['http']['header'] = $header;
+        }
 
         return $options;
     }