MDL-51832 tests: Fix failing code manager unit tests on Windows
[moodle.git] / lib / classes / update / code_manager.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Provides core\update\code_manager class.
19  *
20  * @package     core_plugin
21  * @copyright   2012, 2013, 2015 David Mudrak <david@moodle.com>
22  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core\update;
27 use core_component;
28 use coding_exception;
29 use SplFileInfo;
30 use RecursiveDirectoryIterator;
31 use RecursiveIteratorIterator;
33 defined('MOODLE_INTERNAL') || die();
35 require_once($CFG->libdir.'/filelib.php');
37 /**
38  * General purpose class managing the plugins source code files deployment
39  *
40  * The class is able and supposed to
41  * - fetch and cache ZIP files distributed via the Moodle Plugins directory
42  * - unpack the ZIP files in a temporary storage
43  * - archive existing version of the plugin source code
44  * - move (deploy) the plugin source code into the $CFG->dirroot
45  *
46  * @copyright 2015 David Mudrak <david@moodle.com>
47  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
48  */
49 class code_manager {
51     /** @var string full path to the Moodle app directory root */
52     protected $dirroot;
53     /** @var string full path to the temp directory root */
54     protected $temproot;
56     /**
57      * Instantiate the class instance
58      *
59      * @param string $dirroot full path to the moodle app directory root
60      * @param string $temproot full path to our temp directory
61      */
62     public function __construct($dirroot=null, $temproot=null) {
63         global $CFG;
65         if (empty($dirroot)) {
66             $dirroot = $CFG->dirroot;
67         }
69         if (empty($temproot)) {
70             // Note we are using core_plugin here as that is the valid core
71             // subsystem we are part of. The namespace of this class (core\update)
72             // does not match it for legacy reasons.  The data stored in the
73             // temp directory are expected to survive multiple requests and
74             // purging caches during the upgrade, so we make use of
75             // make_temp_directory(). The contents of it can be removed if needed,
76             // given the site is in the maintenance mode (so that cron is not
77             // executed) and the site is not being upgraded.
78             $temproot = make_temp_directory('core_plugin/code_manager');
79         }
81         $this->dirroot = $dirroot;
82         $this->temproot = $temproot;
84         $this->init_temp_directories();
85     }
87     /**
88      * Obtain the plugin ZIP file from the given URL
89      *
90      * The caller is supposed to know both downloads URL and the MD5 hash of
91      * the ZIP contents in advance, typically by using the API requests against
92      * the plugins directory.
93      *
94      * @param string $url
95      * @param string $md5
96      * @return string|bool full path to the file, false on error
97      */
98     public function get_remote_plugin_zip($url, $md5) {
100         // Sanitize and validate the URL.
101         $url = str_replace(array("\r", "\n"), '', $url);
103         if (!preg_match('|^https?://|i', $url)) {
104             $this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
105             return false;
106         }
108         // The cache location for the file.
109         $distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
111         if (is_readable($distfile) and md5_file($distfile) === $md5) {
112             return $distfile;
113         } else {
114             @unlink($distfile);
115         }
117         // Download the file into a temporary location.
118         $tempdir = make_request_directory();
119         $tempfile = $tempdir.'/plugin.zip';
120         $result = $this->download_plugin_zip_file($url, $tempfile);
122         if (!$result) {
123             return false;
124         }
126         $actualmd5 = md5_file($tempfile);
128         // Make sure the actual md5 hash matches the expected one.
129         if ($actualmd5 !== $md5) {
130             $this->debug('Error fetching plugin ZIP: md5 mismatch.');
131             return false;
132         }
134         // If the file is empty, something went wrong.
135         if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
136             return false;
137         }
139         // Store the file in our cache.
140         if (!rename($tempfile, $distfile)) {
141             return false;
142         }
144         return $distfile;
145     }
147     /**
148      * Extracts the saved plugin ZIP file.
149      *
150      * Returns the list of files found in the ZIP. The format of that list is
151      * array of (string)filerelpath => (bool|string) where the array value is
152      * either true or a string describing the problematic file.
153      *
154      * @see zip_packer::extract_to_pathname()
155      * @param string $zipfilepath full path to the saved ZIP file
156      * @param string $targetdir full path to the directory to extract the ZIP file to
157      * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
158      * @return array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
159      */
160     public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
162         $fp = get_file_packer('application/zip');
163         $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
165         if (!$files) {
166             return array();
167         }
169         if (!empty($rootdir)) {
170             $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
171         }
173         // Sometimes zip may not contain all parent directories, add them to make it consistent.
174         foreach ($files as $path => $status) {
175             if ($status !== true) {
176                 continue;
177             }
178             $parts = explode('/', trim($path, '/'));
179             while (array_pop($parts)) {
180                 if (empty($parts)) {
181                     break;
182                 }
183                 $dir = implode('/', $parts).'/';
184                 if (!isset($files[$dir])) {
185                     $files[$dir] = true;
186                 }
187             }
188         }
190         return $files;
191     }
193     /**
194      * Make an archive backup of the existing plugin folder.
195      *
196      * @param string $folderpath full path to the plugin folder
197      * @param string $targetzip full path to the zip file to be created
198      * @return bool true if file created, false if not
199      */
200     public function zip_plugin_folder($folderpath, $targetzip) {
202         if (file_exists($targetzip)) {
203             throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
204         }
206         if (!is_writable(dirname($targetzip))) {
207             throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
208         }
210         if (!is_dir($folderpath)) {
211             throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
212         }
214         $files = $this->list_plugin_folder_files($folderpath);
215         $fp = get_file_packer('application/zip');
216         return $fp->archive_to_pathname($files, $targetzip, false);
217     }
219     /**
220      * Archive the current plugin on-disk version.
221      *
222      * @param string $folderpath full path to the plugin folder
223      * @param string $component
224      * @param int $version
225      * @param bool $overwrite overwrite existing archive if found
226      * @return bool
227      */
228     public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) {
230         if ($component !== clean_param($component, PARAM_SAFEDIR)) {
231             // This should never happen, but just in case.
232             throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component);
233         }
235         if ((string)$version !== clean_param((string)$version, PARAM_FILE)) {
236             // Prevent some nasty injections via $plugin->version tricks.
237             throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version);
238         }
240         if (empty($component) or empty($version)) {
241             return false;
242         }
244         if (!is_dir($folderpath)) {
245             return false;
246         }
248         $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
250         if (file_exists($archzip) and !$overwrite) {
251             return true;
252         }
254         $tmpzip = make_request_directory().'/'.$version.'.zip';
255         $zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
257         if (!$zipped) {
258             return false;
259         }
261         // Assert that the file looks like a valid one.
262         list($expectedtype, $expectedname) = core_component::normalize_component($component);
263         $actualname = $this->get_plugin_zip_root_dir($tmpzip);
264         if ($actualname !== $expectedname) {
265             // This should not happen.
266             throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
267         }
269         make_writable_directory(dirname($archzip));
270         return rename($tmpzip, $archzip);
271     }
273     /**
274      * Return the path to the ZIP file with the archive of the given plugin version.
275      *
276      * @param string $component
277      * @param int $version
278      * @return string|bool false if not found, full path otherwise
279      */
280     public function get_archived_plugin_version($component, $version) {
282         if (empty($component) or empty($version)) {
283             return false;
284         }
286         $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
288         if (file_exists($archzip)) {
289             return $archzip;
290         }
292         return false;
293     }
295     /**
296      * Returns list of all files in the given directory.
297      *
298      * Given a path like /full/path/to/mod/workshop, it returns array like
299      *
300      *  [workshop/] => /full/path/to/mod/workshop
301      *  [workshop/lang/] => /full/path/to/mod/workshop/lang
302      *  [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php
303      *  ...
304      *
305      * Which mathes the format used by Moodle file packers.
306      *
307      * @param string $folderpath full path to the plugin directory
308      * @return array (string)relpath => (string)fullpath
309      */
310     public function list_plugin_folder_files($folderpath) {
312         $folder = new RecursiveDirectoryIterator($folderpath);
313         $iterator = new RecursiveIteratorIterator($folder);
314         $folderpathinfo = new SplFileInfo($folderpath);
315         $strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1;
316         $files = array();
317         foreach ($iterator as $fileinfo) {
318             if ($fileinfo->getFilename() === '..') {
319                 continue;
320             }
321             if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath() !== 0)) {
322                 throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin');
323             }
324             $key = substr($fileinfo->getRealPath(), $strip);
325             if ($fileinfo->isDir() and substr($key, -1) !== '/') {
326                 $key .= '/';
327             }
328             $files[str_replace(DIRECTORY_SEPARATOR, '/', $key)] = str_replace(DIRECTORY_SEPARATOR, '/', $fileinfo->getRealPath());
329         }
330         return $files;
331     }
333     /**
334      * Detects the plugin's name from its ZIP file.
335      *
336      * Plugin ZIP packages are expected to contain a single directory and the
337      * directory name would become the plugin name once extracted to the Moodle
338      * dirroot.
339      *
340      * @param string $zipfilepath full path to the ZIP files
341      * @return string|bool false on error
342      */
343     public function get_plugin_zip_root_dir($zipfilepath) {
345         $fp = get_file_packer('application/zip');
346         $files = $fp->list_files($zipfilepath);
348         if (empty($files)) {
349             return false;
350         }
352         $rootdirname = null;
353         foreach ($files as $file) {
354             $pathnameitems = explode('/', $file->pathname);
355             if (empty($pathnameitems)) {
356                 return false;
357             }
358             // Set the expected name of the root directory in the first
359             // iteration of the loop.
360             if ($rootdirname === null) {
361                 $rootdirname = $pathnameitems[0];
362             }
363             // Require the same root directory for all files in the ZIP
364             // package.
365             if ($rootdirname !== $pathnameitems[0]) {
366                 return false;
367             }
368         }
370         return $rootdirname;
371     }
373     // This is the end, my only friend, the end ... of external public API.
375     /**
376      * Makes sure all temp directories exist and are writable.
377      */
378     protected function init_temp_directories() {
379         make_writable_directory($this->temproot.'/distfiles');
380         make_writable_directory($this->temproot.'/archive');
381     }
383     /**
384      * Raise developer debugging level message.
385      *
386      * @param string $msg
387      */
388     protected function debug($msg) {
389         debugging($msg, DEBUG_DEVELOPER);
390     }
392     /**
393      * Download the ZIP file with the plugin package from the given location
394      *
395      * @param string $url URL to the file
396      * @param string $tofile full path to where to store the downloaded file
397      * @return bool false on error
398      */
399     protected function download_plugin_zip_file($url, $tofile) {
401         if (file_exists($tofile)) {
402             $this->debug('Error fetching plugin ZIP: target location exists.');
403             return false;
404         }
406         $status = $this->download_file_content($url, $tofile);
408         if (!$status) {
409             $this->debug('Error fetching plugin ZIP.');
410             @unlink($tofile);
411             return false;
412         }
414         return true;
415     }
417     /**
418      * Thin wrapper for the core's download_file_content() function.
419      *
420      * @param string $url URL to the file
421      * @param string $tofile full path to where to store the downloaded file
422      * @return bool
423      */
424     protected function download_file_content($url, $tofile) {
426         // Prepare the parameters for the download_file_content() function.
427         $headers = null;
428         $postdata = null;
429         $fullresponse = false;
430         $timeout = 300;
431         $connecttimeout = 20;
432         $skipcertverify = false;
433         $tofile = $tofile;
434         $calctimeout = false;
436         return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
437             $connecttimeout, $skipcertverify, $tofile, $calctimeout);
438     }
440     /**
441      * Renames the root directory of the extracted ZIP package.
442      *
443      * This method does not validate the presence of the single root directory
444      * (it is the validator's duty). It just searches for the first directory
445      * under the given location and renames it.
446      *
447      * The method will not rename the root if the requested location already
448      * exists.
449      *
450      * @param string $dirname fullpath location of the extracted ZIP package
451      * @param string $rootdir the requested name of the root directory
452      * @param array $files list of extracted files
453      * @return array eventually amended list of extracted files
454      */
455     protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
457         if (!is_dir($dirname)) {
458             $this->debug('Unable to rename rootdir of non-existing content');
459             return $files;
460         }
462         if (file_exists($dirname.'/'.$rootdir)) {
463             // This typically means the real root dir already has the $rootdir name.
464             return $files;
465         }
467         $found = null; // The name of the first subdirectory under the $dirname.
468         foreach (scandir($dirname) as $item) {
469             if (substr($item, 0, 1) === '.') {
470                 continue;
471             }
472             if (is_dir($dirname.'/'.$item)) {
473                 $found = $item;
474                 break;
475             }
476         }
478         if (!is_null($found)) {
479             if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
480                 $newfiles = array();
481                 foreach ($files as $filepath => $status) {
482                     $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
483                     $newfiles[$newpath] = $status;
484                 }
485                 return $newfiles;
486             }
487         }
489         return $files;
490     }