Index: modules/update/connection/update.connection.inc
===================================================================
RCS file: modules/update/connection/update.connection.inc
diff -N modules/update/connection/update.connection.inc
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/update/connection/update.connection.inc	18 Jun 2009 04:51:24 -0000
@@ -0,0 +1,109 @@
+<?php
+// $Id$
+
+/*
+ * Connection class.
+ *
+ * This class does file operations on directories not writeable by the
+ * webserver. It connects back to the server using some backend (for example
+ * FTP or SSH). To keep security the password should always be asked from the
+ * user and never stored.
+ */
+abstract class UpdateConnection {
+  protected $canCopyDirectories = FALSE;
+  
+  function __construct($settings) {
+    $this->username = $settings['username'];
+    $this->password = $settings['password'];
+    $this->hostname = isset($settings['hostname']) ? $settings['hostname'] : 'localhost';
+    if (isset($settings['port'])) {
+      $this->port = $settings['port'];
+    }
+  }
+  
+  function __get($name) {
+    static $connection;
+    if ($name == 'connection') {
+      $this->connection = $this->connect();
+      return $this->connection;
+    }
+  }
+
+  /**
+   * Copies an extension (theme, theme_engine, module).
+   *
+   * @param $source
+   *   The path to the files belonging to the extension.
+   * @param $type
+   *   The type of the extension (i.e. theme, theme_engine, module).
+   * @param $name
+   *   The name of the extension to be copied.
+   */
+  function addExtension($source, $type, $name) {
+    return $this->copyDirectory($source, DRUPAL_ROOT . '/' . drupal_get_path($type, $name));
+  }
+
+  /**
+   * Copies a directory.
+   *
+   * @param $source
+   *   The source path.
+   * @param $destination
+   *   The destination path.
+   */
+  function copyDirectory($source, $destination) {
+    $this->mkdir($destination . basename($source));
+    foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($source), RecursiveIteratorIterator::SELF_FIRST) as $filename => $file) {
+      $relative_path = basename($source) . substr($filename, strlen($source));
+      if ($file->isDir()) {
+        $this->mkdir($destination . $relative_path);
+      }
+      else {
+        $this->copyFile($file->getPathName(), $destination . $relative_path);
+      }
+    }
+  }
+    
+  /**
+   * Creates a directory.
+   *
+   * @param $directory
+   *   The directory to be created.
+   */
+  abstract function mkdir($directory);
+
+  /**
+   * Removes a directory.
+   *
+   * @param $directory
+   *   The directory to be removed.
+   */
+  abstract function rmdir($directory);
+
+  /**
+   * Copies a file.
+   *
+   * @param $source
+   *   The source file.
+   * @param $destination
+   *   The destination file.
+   */
+  abstract function copyFile($source, $destination);  
+
+  
+}
+
+/**
+ * ConnectionException class.
+ */
+class ConnectionException extends Exception {
+  public $params = array();
+  function __construct($message, $code, $params = array()) {
+    $this->params = $params;
+    parent::__construct($message, $code);
+  }
+  
+  public function getParams() {
+    return $this->params;
+  }
+}
Index: modules/update/connection/update.ftp.inc
===================================================================
RCS file: modules/update/connection/update.ftp.inc
diff -N modules/update/connection/update.ftp.inc
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/update/connection/update.ftp.inc	18 Jun 2009 04:51:24 -0000
@@ -0,0 +1,108 @@
+<?php
+// $Id$
+
+/**
+ * Common code for the FTP connections.
+ */
+abstract class UpdateFTPConnection extends UpdateConnection {
+  function __construct($settings) {
+    // This is the default, if $settings contains a port, this will be overridden.
+    $this->port = 21;
+    parent::__construct($settings);
+  } 
+}
+/*
+ * Connection class using the FTP URL wrapper.
+ */
+class UpdateFTPWrapper extends UpdateFTPConnection {
+  function connect() {
+    $this->connection = 'ftp://' . urlencode($this->username) . ':' . urlencode($this->password) . '@' . $this->hostname . ':' . $this->hostname . '/';
+    if (!is_dir($this->connection)) {
+      throw new ConnectionException("FTP Connection failed");
+    }
+  }
+  
+  function copyFile($source, $destination) {
+    if (!@copy($this->connection . '/' . $source, $this->connection . '/' . $destination)) {
+      throw new ConnectionException("Cannot copy @source_file to @destination_file", null, array("@source" => $source, "@destination" => $destination));
+    }
+  }
+  
+  function rmDir($directory) {
+    if (is_dir($directory)) {
+      $dh = opendir($directory);
+      while (($resource = readdir($dh)) !== FALSE) {
+        if ($resource == '.' || $resource == '..') {
+          continue;
+        }
+        $full_path = $directory . DIRECTORY_SEPARATOR . $resource;
+        if (is_file($full_path)) {
+          unlink($full_path);
+        } elseif (is_dir($full_path)) {
+          $this->_rmDir($full_path . '/');
+        }
+      }
+      closedir($dh);
+      if (!rmdir($directory)) {
+        $exception = new ConnectionException("Cannot remove @directory", null, array("@directory" => $directory));
+        throw $exception;
+      }
+    }
+  }
+  
+  function mkdir($directory) {
+    if (!@mkdir($directory)) {
+      $exception = new ConnectionException("Cannot create directory @directory", null, array("@directory" => $directory));
+      throw $exception;
+    }
+  }
+}
+
+class UpdateFTPExtension extends UpdateFTPConnection { 
+  function connect() {
+    $this->connection = ftp_connect($this->hostname, $this->port);
+    
+    if (!$this->connection) {
+      throw new ConnectionException("Cannot connect to FTP Server, please check settings");
+    }
+    if (!ftp_login($this->connection, $this->username, $this->password)) {
+      throw new ConnectionException("Cannot login to FTP server, please check username and password");
+    }
+  }
+  
+  function copyFile($source, $destination) {
+    if (!@ftp_put($this->connection,  $destination, $source, FTP_BINARY)) {
+      throw new ConnectionException("Cannot move @source to @destination", null, array("@source" => $source, "@destination" => $destination));
+    }
+  }
+
+  function mkdir($directory) {
+    if (!@ftp_mkdir($this->connection, $directory)) {
+      throw new ConnectionException("Cannot create directory @directory", null, array("@directory" => $directory));
+    }
+  }
+
+  function rmdir($directory) {
+    $pwd = ftp_pwd($this->connection);
+    if (!@ftp_chdir($this->connection, $directory)) {
+      throw new ConnectionException("Unable to change to directory @directory", null, array('@directory' => $directory));
+    }
+    $list = @ftp_nlist($this->connection, '.');
+    foreach ($list as $item){
+      if ($item == '.' || $item == '..') {
+        continue;
+      }
+      if (@ftp_chdir($this->connection, $item)){
+        ftp_chdir($this->connection, '..');
+        $this->rmdir($item);
+      }
+      elseif (!ftp_delete($this->connection, $item)) {
+        throw new ConnectionException("Unable to remove to file @file", null, array('@file' => $item));
+      }
+    }
+    ftp_chdir($this->connection, $pwd);
+    if (!ftp_rmdir($this->connection, $directory)) {
+      throw new ConnectionException("Unable to remove to directory @directory", null, array('@directory' => $directory));
+    }
+  }
+}
Index: modules/update/update.info
===================================================================
RCS file: /cvs/drupal/drupal/modules/update/update.info,v
retrieving revision 1.5
diff -u -p -r1.5 update.info
--- modules/update/update.info	11 Oct 2008 02:33:12 -0000	1.5
+++ modules/update/update.info	18 Jun 2009 04:51:24 -0000
@@ -10,3 +10,7 @@ files[] = update.fetch.inc
 files[] = update.report.inc
 files[] = update.settings.inc
 files[] = update.install
+files[] = connection/update.ftp.inc
+files[] = connection/update.ssh.inc
+files[] = connection/update.test.inc
+files[] = connection/update.connection.inc
Index: modules/update/update.module
===================================================================
RCS file: /cvs/drupal/drupal/modules/update/update.module,v
retrieving revision 1.37
diff -u -p -r1.37 update.module
--- modules/update/update.module	8 Jun 2009 05:00:11 -0000	1.37
+++ modules/update/update.module	18 Jun 2009 05:29:34 -0000
@@ -146,6 +146,12 @@ function update_menu() {
     'access arguments' => array('administer site configuration'),
     'type' => MENU_CALLBACK,
   );
+  $items['admin/update/download-and-install'] = array(
+    'title' => 'Download a module and install it  * REMOVE THIS BEFORE RELEASE! THIS IS A DEMO! * ',
+    'page callback' => 'update_download_install',
+    'access arguments' => array('administer site configuration'),
+    'type' => MENU_CALLBACK,
+  );
 
   return $items;
 }
@@ -632,3 +638,117 @@ function update_flush_caches() {
 /**
  * @} End of "defgroup update_status_cache".
  */
+
+/**
+ * Attempts to get a file using drupal_http_request and to store it locally.
+ *
+ * @param $path
+ *   The URL of the file to grab.
+ * @return
+ *   On success the address the files was saved to, FALSE on failure.
+ */
+function update_get_file($path) {
+  // Get each of the specified files.
+  $parsed_url = parse_url($path);
+  $local = file_directory_temp() . '/update-cache/' . basename($parsed_url['path']);
+  if (!file_exists(file_directory_temp() . '/update-cache/')) {
+    mkdir(file_directory_temp() . '/update-cache/');
+  }
+
+  // Check the cache and download the file if needed.
+  if (!file_exists($local)) {
+    // $result->data is the actual contents of the downloaded file. This saves
+    // it into a local file, whose path is stored in $local. $local is stored
+    // relative to the Drupal installation.
+    $result = drupal_http_request($path);
+    if ($result->code != 200 || !file_save_data($result->data, $local)) {
+      drupal_set_message(t('@remote could not be saved.', array('@remote' => $path)), 'error');
+      return FALSE;
+    }
+  }
+  return $local;
+}
+
+/**
+ * Implementation of hook_update_connections().
+ */
+function update_update_connections() {
+  $connections = array();
+
+  // SSH2 lib connection is only available if the proper PHP extension is
+  // installed.
+  if (function_exists('ssh2_connect')) {
+    $connections['ssh'] = array(
+      'title' => t('SSH'),
+      'class' => 'UpdateSSHConnection',
+    );
+  }
+  if (function_exists('ftp_connect')) {
+    $connections['ftp_extension'] = array(
+      'title' => t('FTP Extension'),
+      'class' => 'UpdateFTPExtension',
+    );
+  }
+
+  if (ini_get('allow_url_fopen')) {
+    $connections['ftp_wrapper'] = array(
+      'title' => t('FTP Wrapper'),
+      'class' => 'UpdateFTPWrapper',
+    );
+  }
+  return $connections;
+}
+
+/**
+ * Attempts to untar a file into a directory.
+ *
+ * @param $file
+ *   The file to untar.
+ * @param $directory
+ *   The directory to untar to.
+ * @return
+ *   TRUE on success, or FALSE on failure.
+ */
+function update_untar($file, $directory) {
+  if (substr($directory, 0, 1) != DIRECTORY_SEPARATOR) {
+    $directory = DIRECTORY_SEPARATOR . $directory;
+  }
+  $directory = file_directory_temp() . '/update-extraction' . $directory;
+  if (!file_exists($directory)) {
+    mkdir($directory);
+  }
+  $archive_tar = new Archive_tar($file, TRUE);
+  return $archive_tar->extract($directory);
+}
+
+/**
+ * Sample (simplified) page callback to handle an update from update status.
+ * REMOVE THIS BEFORE RELEASE! THIS IS A DEMO!
+ */
+function update_download_install($type, $name, $password) {
+  $connections = module_invoke_all('update_connections');
+  if (!$connections) {
+    return t('No available connection backend.');
+  }
+  $available = update_get_available();
+  module_load_include('inc', 'update', 'update.compare');
+  $data = update_calculate_project_data($available);
+  if (empty($data[$name])) {
+    return t('@module could not be upgraded.', array('@module' => $name));
+  }
+  $project = $data[$name];
+  $tarball = update_get_file($project['releases'][$project['latest_version']]['download_link']);
+  $directory = "$type-$name";
+  $files = update_untar($tarball, $directory);
+  $source = $directory . $files[0];
+
+  $settings = variable_get('update_connection_settings', array());
+  $settings['password'] = $password;
+  $connection_class = $connections[variable_get('update_preferred_connection', 'ftp_wrapper')]['class'];
+  $connection = new $connection_class($settings);
+  // @TODO do we want to remove the extension first? Maybe -- but usually files
+  // are overwriteable and the possible failures and race conditions are
+  // problematic.
+  $connection->addExtension($source, $type, $name);
+  drupal_goto('admin/reports/updates');
+}
Index: modules/update/connection/update.ssh.inc
===================================================================
RCS file: modules/update/connection/update.ssh.inc
diff -N modules/update/connection/update.ssh.inc
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/update/connection/update.ssh.inc	18 Jun 2009 04:51:24 -0000
@@ -0,0 +1,49 @@
+<?php
+// $Id$
+
+/*
+ * class SSHConnection
+ */
+class UpdateSSHConnection extends UpdateConnection {
+  const canCopyDirectories = TRUE;
+
+  function __construct($settings) {
+    // This is the default, if $settings contains a port, this will be overridden.
+    $this->port = 22;
+    parent::__construct($settings);
+  }
+  
+  function connect() {
+    $this->connection = @ssh2_connect($setings['hostname'], $this->port);
+    if (!$this->connection) {
+      throw new ConnectionException("SSH Connection failed");
+    }
+    if (!@ssh2_auth_password($this->connection, $this->username, $this->password)) {
+      throw new ConnectionException("The supplied username/password combination was not accepted.");
+    }
+  }
+  
+  function copyFile($source, $destination) {
+    if (!ssh2_scp_send($this->connection, $source, $destination)) {
+      throw new ConnectionException("Cannot copy @source_file to @destination_file", NULL, array("@source" => $source, "@destination" => $destination));
+    }
+  }
+  
+  function copyDirectory($source, $destination) {
+    if (!@ssh2_exec($this->connection, 'cp -Rp ' . escapeshellarg($source) . " " . escapeshellarg($destination))) {
+      throw new ConnectionException("Cannot copy directory @directory", NULL, array("@directory" => $source));
+    }
+  }
+  
+  function mkdir($directory) {
+    if (!@ssh2_exec($this->connection, 'mkdir ' . escapeshellarg("$directory"))) {
+      throw new ConnectionException("Cannot create directory @directory", null, array("@directory" => $directory));
+    }
+  }
+  
+  function rmdir($directory) {
+    if (!@ssh2_exec($this->connection, 'rm -Rf ' . escapeshellarg("$directory"))) {
+      throw new ConnectionException("Cannot remove @directory", null, array("@directory" => $directory));
+    }
+  }
+}
Index: modules/update/update.test
===================================================================
RCS file: modules/update/update.test
diff -N modules/update/update.test
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/update/update.test	18 Jun 2009 05:17:45 -0000
@@ -0,0 +1,101 @@
+<?php
+// $Id$
+
+include_once dirname(__FILE__) . '/includes/Connection/TestConnection.php';
+
+class UpdateConnectionTest extends DrupalWebTestCase {
+
+  var $host = "localhost";
+  var $username = "drupal";
+  var $password = "password";
+  var $port = "42";
+
+  public static function getInfo() {
+    return array(
+      'name' => t('Update Connection Unit Test'),
+      'description' => t('Test that the template pattern as expressed in Connection.php chooses the right workflow'),
+      'group' => t('Update')
+    );
+  }
+
+  function setUp() {
+    $this->testConnection = TestConnection::factory(DRUPAL_ROOT, array('host' => $this->host, 'username' => $this->username, 'password' => $this->password, 'port' => $this->port));
+  }
+
+  function testConnect() {
+    $this->testConnection->connect();
+    $this->assertEqual($this->testConnection->getConnection()->connectionString, 'test://' . urlencode($this->username) . ':' . urlencode($this->password) . "@{$this->host}:{$this->port}/", "Connection String correctly built");
+    //fail assertion
+    $this->assertNotEqual($this->testConnection->getConnection()->connectionString, "bull://{$this->username}:{$this->password}@{$this->host}:{$this->port}", "Connection String correctly built");
+  }
+
+  function _getFakeModuleFiles() {
+    $files = array(
+      'fake.module',
+      'fake.info',
+      'theme' => array(
+        'fake.tpl.php'
+      ),
+      'inc' => array(
+        'fake.inc'
+      )
+    );
+    return $files;
+  }
+
+  function _buildFakeModule() {
+    $location = file_directory_temp() . "/fake";
+    if (is_dir($location)) {
+      $ret = 0;
+      $output = array();
+      exec('rm -Rf ' . escapeshellarg($location), $output, $ret);
+      if ($ret != 0) {
+        throw new Exception("Error removing fake module directory");
+      }
+    }
+
+    $files = $this->_getFakeModuleFiles();
+    $this->_writeDirectory($location, $files);
+    return $location;
+  }
+
+  function _writeDirectory($base, $files = array()) {
+    mkdir($base);
+    foreach ($files as $key => $file) {
+      if (is_array($file)) {
+        $this->_writeDirectory($base . DIRECTORY_SEPARATOR . $key, $file);
+      }
+      else {
+        //just write the filename into the file
+        file_put_contents($base . DIRECTORY_SEPARATOR . $file, $file);
+      }
+    }
+  }
+
+  function testPutModule() {
+    $directory = $this->_buildFakeModule();
+    $drupal_root = DRUPAL_ROOT;
+    $this->testConnection->canCopyDirs = TRUE;
+    $this->testConnection->addModule($directory);
+
+    $expected_commands = array("copyDirectory {$directory} {$drupal_root}/sites/all/modules/fake");
+    $received_commands = $this->testConnection->getConnection()->flushCommands();
+
+    $this->assertEqual($received_commands, $expected_commands, "Expected copy directory operation made");
+
+    $this->testConnection->canCopyDirs = FALSE;
+    $this->testConnection->addModule($directory);
+    $expected_commands = array(
+      "mkdir {$drupal_root}/sites/all/modules/fake",
+      "copyFile {$directory}/fake.info {$drupal_root}/sites/all/modules/fake/fake.info",
+      "copyFile {$directory}/fake.module {$drupal_root}/sites/all/modules/fake/fake.module",
+      "mkdir {$drupal_root}/sites/all/modules/fake/inc",
+      "copyFile {$directory}/inc/fake.inc {$drupal_root}/sites/all/modules/fake/inc/fake.inc",
+      "mkdir {$drupal_root}/sites/all/modules/fake/theme",
+      "copyFile {$directory}/theme/fake.tpl.php {$drupal_root}/sites/all/modules/fake/theme/fake.tpl.php",
+    );
+
+    $received_commands = $this->testConnection->getConnection()->flushCommands();
+    $this->assertEqual($received_commands, $expected_commands, "Expected copy files operations made");
+  }
+}
Index: modules/update/connection/update.test.inc
===================================================================
RCS file: modules/update/connection/update.test.inc
diff -N modules/update/connection/update.test.inc
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/update/connection/update.test.inc	18 Jun 2009 04:51:24 -0000
@@ -0,0 +1,52 @@
+<?php 
+
+/*
+ * class TestConnction
+ */
+class UpdateTestConnection extends UpdateConnection {
+  function __construct($settings) {
+    // This is the default, if $settings contains a port, this will be overridden.
+    $this->port = 9999;
+    parent::__construct($settings);
+  }
+  
+  function connect() {
+    $this->connection = new MockTestConnection($settings);
+    $this->connection->connectionString = 'test://' . urlencode($this->username) . ':' . urlencode($this->password) . '@' . $this->hostname . ':' . $this->hostname . '/';
+  }
+  
+  function getConnection() {
+    return $this->connection;
+  }
+  
+  function copyFile($source, $destination) {
+    $this->connection->run("copyFile $source $destination");
+  }
+  
+  function rmDir($directory) {
+    $this->connection->run("rmdir $directory");
+  }
+  
+  function mkdir($directory) {
+    $this->connection->run("mkdir $directory");
+  }
+  
+  function copyDirectory($source, $destination) {
+    $this->connection->run("copyDirectory $source $destination");
+  }
+}
+
+class MockTestConnection {
+  var $commandsRun = array();
+  var $connectionString;
+  
+  function run($cmd) {
+    $this->commandsRun[] = $cmd;
+  }
+  
+  function flushCommands() {
+    $out = $this->commandsRun;
+    $this->commandsRun = array();
+    return $out;
+  }
+}
