From a2e1e0d0f1c768546a4bda917ed81388f2706112 Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20Mudr=C3=A1k?= Date: Fri, 9 Oct 2015 13:44:57 +0200 Subject: [PATCH] MDL-49329 admin: Archive plugin code before removing it from dirroot This should allow the admin to revert the upgrade of existing plugins, such when the dependency chain leads to a dead-end. Additionally, we archive (as a last-chance copy) the to-be-installed plugins when cancelling their installation. This is mainly for developers who could otherwise loose their code. For the same reason, plugins are being archived upon uninstallation, too. --- admin/plugins.php | 14 +-- lib/classes/plugin_manager.php | 45 ++++++-- lib/classes/update/code_manager.php | 145 +++++++++++++++++++++++++ lib/tests/update_code_manager_test.php | 56 ++++++++++ 4 files changed, 237 insertions(+), 23 deletions(-) diff --git a/admin/plugins.php b/admin/plugins.php index ae255e23d35..9c9e2bc6050 100644 --- a/admin/plugins.php +++ b/admin/plugins.php @@ -148,13 +148,6 @@ if ($delete and $confirmed) { 'core_plugin_manager::get_plugin_info() returned not-null versiondb for the plugin to be deleted'); } - // Make sure the folder is removable. - if (!$pluginman->is_plugin_folder_removable($pluginfo->component)) { - throw new moodle_exception('err_removing_unremovable_folder', 'core_plugin', '', - array('plugin' => $pluginfo->component, 'rootdir' => $pluginfo->rootdir), - 'plugin root folder is not removable as expected'); - } - // Make sure the folder is within Moodle installation tree. if (strpos($pluginfo->rootdir, $CFG->dirroot) !== 0) { throw new moodle_exception('err_unexpected_plugin_rootdir', 'core_plugin', '', @@ -163,11 +156,8 @@ if ($delete and $confirmed) { } // So long, and thanks for all the bugs. - fulldelete($pluginfo->rootdir); - // Reset op code caches. - if (function_exists('opcache_reset')) { - opcache_reset(); - } + $pluginman->remove_plugin_folder($pluginfo); + // We need to execute upgrade to make sure everything including caches is up to date. redirect(new moodle_url('/admin/index.php')); } diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php index 222fbf32da6..8b094a6db9b 100644 --- a/lib/classes/plugin_manager.php +++ b/lib/classes/plugin_manager.php @@ -1341,11 +1341,7 @@ class core_plugin_manager { list($plugintype, $pluginname) = core_component::normalize_component($plugin->component); $target = $this->get_plugintype_root($plugintype); if (file_exists($target.'/'.$pluginname)) { - $current = $this->get_plugin_info($plugin->component); - if ($current->versiondb and $current->versiondb == $current->versiondisk) { - // TODO Archive existing version so that we can revert. - } - remove_dir($target.'/'.$pluginname); + $this->remove_plugin_folder($this->get_plugin_info($plugin->component)); } if (!$this->unzip_plugin_file($zipfile, $target, $pluginname)) { $silent or $this->mtrace(get_string('error')); @@ -1911,6 +1907,37 @@ class core_plugin_manager { } } + /** + * Remove the current plugin code from the dirroot. + * + * If removing the currently installed version (which happens during + * updates), we archive the code so that the upgrade can be cancelled. + * + * To prevent accidental data-loss, we also archive the existing plugin + * code if cancelling installation of it, so that the developer does not + * loose the only version of their work-in-progress. + * + * @param \core\plugininfo\base $plugin + */ + public function remove_plugin_folder(\core\plugininfo\base $plugin) { + + if (!$this->is_plugin_folder_removable($plugin->component)) { + throw new moodle_exception('err_removing_unremovable_folder', 'core_plugin', '', + array('plugin' => $pluginfo->component, 'rootdir' => $pluginfo->rootdir), + 'plugin root folder is not removable as expected'); + } + + if ($plugin->get_status() === self::PLUGIN_STATUS_UPTODATE or $plugin->get_status() === self::PLUGIN_STATUS_NEW) { + $this->archive_plugin_version($plugin); + } + + remove_dir($plugin->rootdir); + clearstatcache(); + if (function_exists('opcache_reset')) { + opcache_reset(); + } + } + /** * Can the installation of the new plugin be cancelled? * @@ -1938,16 +1965,13 @@ class core_plugin_manager { * upgrade happens. * * @param string $component - * @return bool */ public function cancel_plugin_installation($component) { $plugin = $this->get_plugin_info($component); if ($this->can_cancel_plugin_installation($plugin)) { - if ($this->archive_plugin_version($plugin)) { - return remove_dir($plugin->rootdir); - } + $this->remove_plugin_folder($plugin); } return false; @@ -1974,8 +1998,7 @@ class core_plugin_manager { * @return bool */ public function archive_plugin_version(\core\plugininfo\base $plugin) { - // TODO use code_manager to do it. - return true; + return $this->get_code_manager()->archive_plugin_version($plugin->rootdir, $plugin->component, $plugin->versiondisk); } /** diff --git a/lib/classes/update/code_manager.php b/lib/classes/update/code_manager.php index 383890bbfc7..4b6f432e015 100644 --- a/lib/classes/update/code_manager.php +++ b/lib/classes/update/code_manager.php @@ -24,7 +24,11 @@ namespace core\update; +use core_component; use coding_exception; +use SplFileInfo; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; defined('MOODLE_INTERNAL') || die(); @@ -220,6 +224,146 @@ class code_manager { return $files; } + /** + * Make an archive backup of the existing plugin folder. + * + * @param string $folderpath full path to the plugin folder + * @param string $targetzip full path to the zip file to be created + * @return bool true if file created, false if not + */ + public function zip_plugin_folder($folderpath, $targetzip) { + + if (file_exists($targetzip)) { + throw new coding_exception('Attempting to create already existing ZIP file', $targetzip); + } + + if (!is_writable(dirname($targetzip))) { + throw new coding_exception('Target ZIP location not writable', dirname($targetzip)); + } + + if (!is_dir($folderpath)) { + throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath); + } + + $files = $this->list_plugin_folder_files($folderpath); + $fp = get_file_packer('application/zip'); + return $fp->archive_to_pathname($files, $targetzip, false); + } + + /** + * Archive the current plugin on-disk version. + * + * @param string $folderpath full path to the plugin folder + * @param string $component + * @param int $version + * @param bool $overwrite overwrite existing archive if found + * @return bool + */ + public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) { + + if ($component !== clean_param($component, PARAM_SAFEDIR)) { + // This should never happen, but just in case. + throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component); + } + + if ((string)$version !== clean_param((string)$version, PARAM_FILE)) { + // Prevent some nasty injections via $plugin->version tricks. + throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version); + } + + if (empty($component) or empty($version)) { + return false; + } + + if (!is_dir($folderpath)) { + return false; + } + + $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip'; + + if (file_exists($archzip) and !$overwrite) { + return true; + } + + $tmpzip = make_request_directory().'/'.$version.'.zip'; + $zipped = $this->zip_plugin_folder($folderpath, $tmpzip); + + if (!$zipped) { + return false; + } + + // Assert that the file looks like a valid one. + list($expectedtype, $expectedname) = core_component::normalize_component($component); + $actualname = $this->get_plugin_zip_root_dir($tmpzip); + if ($actualname !== $expectedname) { + // This should not happen. + throw new moodle_exception('unexpected_archive_structure', 'core_plugin'); + } + + make_writable_directory(dirname($archzip)); + return rename($tmpzip, $archzip); + } + + /** + * Return the path to the ZIP file with the archive of the given plugin version. + * + * @param string $component + * @param int $version + * @return string|bool false if not found, full path otherwise + */ + public function get_archived_plugin_version($component, $version) { + + if (empty($component) or empty($version)) { + return false; + } + + $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip'; + + if (file_exists($archzip)) { + return $archzip; + } + + return false; + } + + /** + * Returns list of all files in the given directory. + * + * Given a path like /full/path/to/mod/workshop, it returns array like + * + * [workshop/] => /full/path/to/mod/workshop + * [workshop/lang/] => /full/path/to/mod/workshop/lang + * [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php + * ... + * + * Which mathes the format used by Moodle file packers. + * + * @param string $folderpath full path to the plugin directory + * @return array (string)relpath => (string)fullpath + */ + public function list_plugin_folder_files($folderpath) { + + $folder = new RecursiveDirectoryIterator($folderpath); + $iterator = new RecursiveIteratorIterator($folder); + $folderpathinfo = new SplFileInfo($folderpath); + $strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1; + $files = array(); + foreach ($iterator as $fileinfo) { + if ($fileinfo->getFilename() === '..') { + continue; + } + if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath() !== 0)) { + throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin'); + } + $key = substr($fileinfo->getRealPath(), $strip); + if ($fileinfo->isDir() and substr($key, -1) !== '/') { + $key .= '/'; + } + $files[$key] = $fileinfo->getRealPath(); + } + return $files; + } + /** * Detects the plugin's name from its ZIP file. * @@ -267,6 +411,7 @@ class code_manager { */ protected function init_temp_directories() { make_writable_directory($this->temproot.'/distfiles'); + make_writable_directory($this->temproot.'/archive'); } /** diff --git a/lib/tests/update_code_manager_test.php b/lib/tests/update_code_manager_test.php index ec9401f8b55..6e1091a65c6 100644 --- a/lib/tests/update_code_manager_test.php +++ b/lib/tests/update_code_manager_test.php @@ -170,4 +170,60 @@ class core_update_code_manager_testcase extends advanced_testcase { $this->assertEquals('bar', $codeman->get_plugin_zip_root_dir($zipfilepath)); } + public function test_list_plugin_folder_files() { + $fixtures = __DIR__.'/fixtures/update_validator/plugindir'; + $codeman = new \core\update\testable_code_manager(); + $files = $codeman->list_plugin_folder_files($fixtures.'/foobar'); + $this->assertInternalType('array', $files); + $this->assertEquals(6, count($files)); + $this->assertEquals($files['foobar/'], $fixtures.'/foobar'); + $this->assertEquals($files['foobar/lang/en/local_foobar.php'], $fixtures.'/foobar/lang/en/local_foobar.php'); + } + + public function test_zip_plugin_folder() { + $fixtures = __DIR__.'/fixtures/update_validator/plugindir'; + $storage = make_request_directory(); + $codeman = new \core\update\testable_code_manager(); + $codeman->zip_plugin_folder($fixtures.'/foobar', $storage.'/foobar.zip'); + $this->assertTrue(file_exists($storage.'/foobar.zip')); + + $fp = get_file_packer('application/zip'); + $zipfiles = $fp->list_files($storage.'/foobar.zip'); + $this->assertNotEmpty($zipfiles); + foreach ($zipfiles as $zipfile) { + if ($zipfile->is_directory) { + $this->assertTrue(is_dir($fixtures.'/'.$zipfile->pathname)); + } else { + $this->assertTrue(file_exists($fixtures.'/'.$zipfile->pathname)); + } + } + } + + public function test_archiving_plugin_version() { + $fixtures = __DIR__.'/fixtures/update_validator/plugindir'; + $codeman = new \core\update\testable_code_manager(); + + $this->assertFalse($codeman->archive_plugin_version($fixtures.'/foobar', 'local_foobar', 0)); + $this->assertFalse($codeman->archive_plugin_version($fixtures.'/foobar', 'local_foobar', null)); + $this->assertFalse($codeman->archive_plugin_version($fixtures.'/foobar', '', 2015100900)); + $this->assertFalse($codeman->archive_plugin_version($fixtures.'/foobar-does-not-exist', 'local_foobar', 2013031900)); + + $this->assertFalse($codeman->get_archived_plugin_version('local_foobar', 2013031900)); + $this->assertFalse($codeman->get_archived_plugin_version('mod_foobar', 2013031900)); + + $this->assertTrue($codeman->archive_plugin_version($fixtures.'/foobar', 'local_foobar', 2013031900, true)); + + $this->assertNotFalse($codeman->get_archived_plugin_version('local_foobar', 2013031900)); + $this->assertTrue(file_exists($codeman->get_archived_plugin_version('local_foobar', 2013031900))); + $this->assertTrue(file_exists($codeman->get_archived_plugin_version('local_foobar', '2013031900'))); + + $this->assertFalse($codeman->get_archived_plugin_version('mod_foobar', 2013031900)); + $this->assertFalse($codeman->get_archived_plugin_version('local_foobar', 2013031901)); + $this->assertFalse($codeman->get_archived_plugin_version('', 2013031901)); + $this->assertFalse($codeman->get_archived_plugin_version('local_foobar', '')); + + $this->assertTrue($codeman->archive_plugin_version($fixtures.'/foobar', 'local_foobar', '2013031900')); + $this->assertTrue(file_exists($codeman->get_archived_plugin_version('local_foobar', 2013031900))); + + } } -- 2.43.0