e4e2a94fee39707dd25634a215d25cb45fce8871
[moodle.git] / lib / classes / update / validator.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 validation class to check the plugin ZIP contents
19  *
20  * Uses fragments of the local_plugins_archive_validator class copyrighted by
21  * Marina Glancy that is part of the local_plugins plugin.
22  *
23  * @package     core_plugin
24  * @subpackage  validation
25  * @copyright   2013, 2015 David Mudrak <david@moodle.com>
26  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27  */
29 namespace core\update;
31 use core_component;
32 use core_plugin_manager;
33 use help_icon;
34 use coding_exception;
36 defined('MOODLE_INTERNAL') || die();
38 if (!defined('T_ML_COMMENT')) {
39    define('T_ML_COMMENT', T_COMMENT);
40 } else {
41    define('T_DOC_COMMENT', T_ML_COMMENT);
42 }
44 /**
45  * Validates the contents of extracted plugin ZIP file
46  *
47  * @copyright 2013, 2015 David Mudrak <david@moodle.com>
48  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
49  */
50 class validator {
52     /** Critical error message level, causes the validation fail. */
53     const ERROR     = 'error';
55     /** Warning message level, validation does not fail but the admin should be always informed. */
56     const WARNING   = 'warning';
58     /** Information message level that the admin should be aware of. */
59     const INFO      = 'info';
61     /** Debugging message level, should be displayed in debugging mode only. */
62     const DEBUG     = 'debug';
64     /** @var string full path to the extracted ZIP contents */
65     protected $extractdir = null;
67     /** @var array as returned by {@link zip_packer::extract_to_pathname()} */
68     protected $extractfiles = null;
70     /** @var bool overall result of validation */
71     protected $result = null;
73     /** @var string the name of the plugin root directory */
74     protected $rootdir = null;
76     /** @var array explicit list of expected/required characteristics of the ZIP */
77     protected $assertions = null;
79     /** @var array of validation log messages */
80     protected $messages = array();
82     /** @var array|null array of relevant data obtained from version.php */
83     protected $versionphp = null;
85     /** @var string|null the name of found English language file without the .php extension */
86     protected $langfilename = null;
88     /**
89      * Factory method returning instance of the validator
90      *
91      * @param string $zipcontentpath full path to the extracted ZIP contents
92      * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
93      * @return \core\update\validator
94      */
95     public static function instance($zipcontentpath, array $zipcontentfiles) {
96         return new static($zipcontentpath, $zipcontentfiles);
97     }
99     /**
100      * Set the expected plugin type, fail the validation otherwise
101      *
102      * @param string $required plugin type
103      */
104     public function assert_plugin_type($required) {
105         $this->assertions['plugintype'] = $required;
106     }
108     /**
109      * Set the expectation that the plugin can be installed into the given Moodle version
110      *
111      * @param string $required Moodle version we are about to install to
112      */
113     public function assert_moodle_version($required) {
114         $this->assertions['moodleversion'] = $required;
115     }
117     /**
118      * Execute the validation process against all explicit and implicit requirements
119      *
120      * Returns true if the validation passes (all explicit and implicit requirements
121      * pass) and the plugin can be installed. Returns false if the validation fails
122      * (some explicit or implicit requirement fails) and the plugin must not be
123      * installed.
124      *
125      * @return bool
126      */
127     public function execute() {
129         $this->result = (
130                 $this->validate_files_layout()
131             and $this->validate_version_php()
132             and $this->validate_language_pack()
133             and $this->validate_target_location()
134         );
136         return $this->result;
137     }
139     /**
140      * Returns overall result of the validation.
141      *
142      * Null is returned if the validation has not been executed yet. Otherwise
143      * this method returns true (the installation can continue) or false (it is not
144      * safe to continue with the installation).
145      *
146      * @return bool|null
147      */
148     public function get_result() {
149         return $this->result;
150     }
152     /**
153      * Return the list of validation log messages
154      *
155      * Each validation message is a plain object with properties level, msgcode
156      * and addinfo.
157      *
158      * @return array of (int)index => (stdClass) validation message
159      */
160     public function get_messages() {
161         return $this->messages;
162     }
164     /**
165      * Returns human readable localised name of the given log level.
166      *
167      * @param string $level e.g. self::INFO
168      * @return string
169      */
170     public function message_level_name($level) {
171         return get_string('validationmsglevel_'.$level, 'core_plugin');
172     }
174     /**
175      * If defined, returns human readable validation code.
176      *
177      * Otherwise, it simply returns the code itself as a fallback.
178      *
179      * @param string $msgcode
180      * @return string
181      */
182     public function message_code_name($msgcode) {
184         $stringman = get_string_manager();
186         if ($stringman->string_exists('validationmsg_'.$msgcode, 'core_plugin')) {
187             return get_string('validationmsg_'.$msgcode, 'core_plugin');
188         }
190         return $msgcode;
191     }
193     /**
194      * Returns help icon for the message code if defined.
195      *
196      * @param string $msgcode
197      * @return \help_icon|false
198      */
199     public function message_help_icon($msgcode) {
201         $stringman = get_string_manager();
203         if ($stringman->string_exists('validationmsg_'.$msgcode.'_help', 'core_plugin')) {
204             return new help_icon('validationmsg_'.$msgcode, 'core_plugin');
205         }
207         return false;
208     }
210     /**
211      * Localizes the message additional info if it exists.
212      *
213      * @param string $msgcode
214      * @param array|string|null $addinfo value for the $a placeholder in the string
215      * @return string
216      */
217     public function message_code_info($msgcode, $addinfo) {
219         $stringman = get_string_manager();
221         if ($addinfo !== null and $stringman->string_exists('validationmsg_'.$msgcode.'_info', 'core_plugin')) {
222             return get_string('validationmsg_'.$msgcode.'_info', 'core_plugin', $addinfo);
223         }
225         return '';
226     }
228     /**
229      * Return the information provided by the the plugin's version.php
230      *
231      * If version.php was not found in the plugin, null is returned. Otherwise
232      * the array is returned. It may be empty if no information was parsed
233      * (which should not happen).
234      *
235      * @return null|array
236      */
237     public function get_versionphp_info() {
238         return $this->versionphp;
239     }
241     /**
242      * Returns the name of the English language file without the .php extension
243      *
244      * This can be used as a suggestion for fixing the plugin root directory in the
245      * ZIP file during the upload. If no file was found, or multiple PHP files are
246      * located in lang/en/ folder, then null is returned.
247      *
248      * @return null|string
249      */
250     public function get_language_file_name() {
251         return $this->langfilename;
252     }
254     /**
255      * Returns the rootdir of the extracted package (after eventual renaming)
256      *
257      * @return string|null
258      */
259     public function get_rootdir() {
260         return $this->rootdir;
261     }
263     // End of external API.
265     /**
266      * @param string $zipcontentpath full path to the extracted ZIP contents
267      * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
268      */
269     protected function __construct($zipcontentpath, array $zipcontentfiles) {
270         $this->extractdir = $zipcontentpath;
271         $this->extractfiles = $zipcontentfiles;
272     }
274     // Validation methods.
276     /**
277      * @return bool false if files in the ZIP do not have required layout
278      */
279     protected function validate_files_layout() {
281         if (!is_array($this->extractfiles) or count($this->extractfiles) < 4) {
282             // We need the English language pack with the name of the plugin at least
283             $this->add_message(self::ERROR, 'filesnumber');
284             return false;
285         }
287         foreach ($this->extractfiles as $filerelname => $filestatus) {
288             if ($filestatus !== true) {
289                 $this->add_message(self::ERROR, 'filestatus', array('file' => $filerelname, 'status' => $filestatus));
290                 return false;
291             }
292         }
294         foreach (array_keys($this->extractfiles) as $filerelname) {
295             if (!file_exists($this->extractdir.'/'.$filerelname)) {
296                 $this->add_message(self::ERROR, 'filenotexists', array('file' => $filerelname));
297                 return false;
298             }
299         }
301         foreach (array_keys($this->extractfiles) as $filerelname) {
302             $matches = array();
303             if (!preg_match("#^([^/]+)/#", $filerelname, $matches)
304                     or (!is_null($this->rootdir) and $this->rootdir !== $matches[1])) {
305                 $this->add_message(self::ERROR, 'onedir');
306                 return false;
307             }
308             $this->rootdir = $matches[1];
309         }
311         if ($this->rootdir !== clean_param($this->rootdir, PARAM_PLUGIN)) {
312             $this->add_message(self::ERROR, 'rootdirinvalid', $this->rootdir);
313             return false;
314         } else {
315             $this->add_message(self::INFO, 'rootdir', $this->rootdir);
316         }
318         return is_dir($this->extractdir.'/'.$this->rootdir);
319     }
321     /**
322      * @return bool false if the version.php file does not declare required information
323      */
324     protected function validate_version_php() {
326         if (!isset($this->assertions['plugintype'])) {
327             throw new coding_exception('Required plugin type must be set before calling this');
328         }
330         if (!isset($this->assertions['moodleversion'])) {
331             throw new coding_exception('Required Moodle version must be set before calling this');
332         }
334         $fullpath = $this->extractdir.'/'.$this->rootdir.'/version.php';
336         if (!file_exists($fullpath)) {
337             // This is tolerated for themes only.
338             if ($this->assertions['plugintype'] === 'theme') {
339                 $this->add_message(self::DEBUG, 'missingversionphp');
340                 return true;
341             } else {
342                 $this->add_message(self::ERROR, 'missingversionphp');
343                 return false;
344             }
345         }
347         $this->versionphp = array();
348         $info = $this->parse_version_php($fullpath);
350         if (isset($info['module->version'])) {
351             $this->add_message(self::ERROR, 'versionphpsyntax', '$module');
352             return false;
353         }
355         if (isset($info['plugin->version'])) {
356             $this->versionphp['version'] = $info['plugin->version'];
357             $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
358         } else {
359             $this->add_message(self::ERROR, 'missingversion');
360             return false;
361         }
363         if (isset($info['plugin->requires'])) {
364             $this->versionphp['requires'] = $info['plugin->requires'];
365             if ($this->versionphp['requires'] > $this->assertions['moodleversion']) {
366                 $this->add_message(self::ERROR, 'requiresmoodle', $this->versionphp['requires']);
367                 return false;
368             }
369             $this->add_message(self::INFO, 'requiresmoodle', $this->versionphp['requires']);
370         }
372         if (!isset($info['plugin->component'])) {
373             $this->add_message(self::ERROR, 'missingcomponent');
374             return false;
375         }
377         $this->versionphp['component'] = $info['plugin->component'];
378         list($reqtype, $reqname) = core_component::normalize_component($this->versionphp['component']);
379         if ($reqtype !== $this->assertions['plugintype']) {
380             $this->add_message(self::ERROR, 'componentmismatchtype', array(
381                 'expected' => $this->assertions['plugintype'],
382                 'found' => $reqtype));
383             return false;
384         }
385         if ($reqname !== $this->rootdir) {
386             $this->add_message(self::ERROR, 'componentmismatchname', $reqname);
387             return false;
388         }
389         $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']);
391         if (isset($info['plugin->maturity'])) {
392             $this->versionphp['maturity'] = $info['plugin->maturity'];
393             if ($this->versionphp['maturity'] === 'MATURITY_STABLE') {
394                 $this->add_message(self::INFO, 'maturity', $this->versionphp['maturity']);
395             } else {
396                 $this->add_message(self::WARNING, 'maturity', $this->versionphp['maturity']);
397             }
398         }
400         if (isset($info['plugin->release'])) {
401             $this->versionphp['release'] = $info['plugin->release'];
402             $this->add_message(self::INFO, 'release', $this->versionphp['release']);
403         }
405         return true;
406     }
408     /**
409      * @return bool false if the English language pack is not provided correctly
410      */
411     protected function validate_language_pack() {
413         if (!isset($this->assertions['plugintype'])) {
414             throw new coding_exception('Required plugin type must be set before calling this');
415         }
417         if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'])
418                 or $this->extractfiles[$this->rootdir.'/lang/en/'] !== true
419                 or !is_dir($this->extractdir.'/'.$this->rootdir.'/lang/en')) {
420             $this->add_message(self::ERROR, 'missinglangenfolder');
421             return false;
422         }
424         $langfiles = array();
425         foreach (array_keys($this->extractfiles) as $extractfile) {
426             $matches = array();
427             if (preg_match('#^'.preg_quote($this->rootdir).'/lang/en/([^/]+).php?$#i', $extractfile, $matches)) {
428                 $langfiles[] = $matches[1];
429             }
430         }
432         if (empty($langfiles)) {
433             $this->add_message(self::ERROR, 'missinglangenfile');
434             return false;
435         } else if (count($langfiles) > 1) {
436             $this->add_message(self::WARNING, 'multiplelangenfiles');
437         } else {
438             $this->langfilename = $langfiles[0];
439             $this->add_message(self::DEBUG, 'foundlangfile', $this->langfilename);
440         }
442         if ($this->assertions['plugintype'] === 'mod') {
443             $expected = $this->rootdir.'.php';
444         } else {
445             $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php';
446         }
448         if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'.$expected])
449                 or $this->extractfiles[$this->rootdir.'/lang/en/'.$expected] !== true
450                 or !is_file($this->extractdir.'/'.$this->rootdir.'/lang/en/'.$expected)) {
451             $this->add_message(self::ERROR, 'missingexpectedlangenfile', $expected);
452             return false;
453         }
455         return true;
456     }
458     /**
459      * @return bool false of the given add-on can't be installed into its location
460      */
461     public function validate_target_location() {
463         if (!isset($this->assertions['plugintype'])) {
464             throw new coding_exception('Required plugin type must be set before calling this');
465         }
467         $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']);
469         if (is_null($plugintypepath)) {
470             $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']);
471             return false;
472         }
474         if (!is_dir($plugintypepath)) {
475             throw new coding_exception('Plugin type location does not exist!');
476         }
478         // Always check that the plugintype root is writable.
479         if (!is_writable($plugintypepath)) {
480             $this->add_message(self::ERROR, 'pathwritable', $plugintypepath);
481             return false;
482         } else {
483             $this->add_message(self::INFO, 'pathwritable', $plugintypepath);
484         }
486         // The target location itself may or may not exist. Even if installing an
487         // available update, the code could have been removed by accident (and
488         // be reported as missing) etc. So we just make sure that the code
489         // can be replaced if it already exists.
490         $target = $plugintypepath.'/'.$this->rootdir;
491         if (file_exists($target)) {
492             if (!is_dir($target)) {
493                 $this->add_message(self::ERROR, 'targetnotdir', $target);
494                 return false;
495             }
496             $this->add_message(self::WARNING, 'targetexists', $target);
497             if ($this->get_plugin_manager()->is_directory_removable($target)) {
498                 $this->add_message(self::INFO, 'pathwritable', $target);
499             } else {
500                 $this->add_message(self::ERROR, 'pathwritable', $target);
501                 return false;
502             }
503         }
505         return true;
506     }
508     // Helper methods.
510     /**
511      * Get as much information from existing version.php as possible
512      *
513      * @param string full path to the version.php file
514      * @return array of found meta-info declarations
515      */
516     protected function parse_version_php($fullpath) {
518         $content = $this->get_stripped_file_contents($fullpath);
520         preg_match_all('#\$((plugin|module)\->(version|maturity|release|requires))=()(\d+(\.\d+)?);#m', $content, $matches1);
521         preg_match_all('#\$((plugin|module)\->(maturity))=()(MATURITY_\w+);#m', $content, $matches2);
522         preg_match_all('#\$((plugin|module)\->(release))=([\'"])(.*?)\4;#m', $content, $matches3);
523         preg_match_all('#\$((plugin|module)\->(component))=([\'"])(.+?_.+?)\4;#m', $content, $matches4);
525         if (count($matches1[1]) + count($matches2[1]) + count($matches3[1]) + count($matches4[1])) {
526             $info = array_combine(
527                 array_merge($matches1[1], $matches2[1], $matches3[1], $matches4[1]),
528                 array_merge($matches1[5], $matches2[5], $matches3[5], $matches4[5])
529             );
531         } else {
532             $info = array();
533         }
535         return $info;
536     }
538     /**
539      * Append the given message to the messages log
540      *
541      * @param string $level e.g. self::ERROR
542      * @param string $msgcode may form a string
543      * @param string|array|object $a optional additional info suitable for {@link get_string()}
544      */
545     protected function add_message($level, $msgcode, $a = null) {
546         $msg = (object)array(
547             'level'     => $level,
548             'msgcode'   => $msgcode,
549             'addinfo'   => $a,
550         );
551         $this->messages[] = $msg;
552     }
554     /**
555      * Returns bare PHP code from the given file
556      *
557      * Returns contents without PHP opening and closing tags, text outside php code,
558      * comments and extra whitespaces.
559      *
560      * @param string $fullpath full path to the file
561      * @return string
562      */
563     protected function get_stripped_file_contents($fullpath) {
565         $source = file_get_contents($fullpath);
566         $tokens = token_get_all($source);
567         $output = '';
568         $doprocess = false;
569         foreach ($tokens as $token) {
570             if (is_string($token)) {
571                 // Simple one character token.
572                 $id = -1;
573                 $text = $token;
574             } else {
575                 // Token array.
576                 list($id, $text) = $token;
577             }
578             switch ($id) {
579             case T_WHITESPACE:
580             case T_COMMENT:
581             case T_ML_COMMENT:
582             case T_DOC_COMMENT:
583                 // Ignore whitespaces, inline comments, multiline comments and docblocks.
584                 break;
585             case T_OPEN_TAG:
586                 // Start processing.
587                 $doprocess = true;
588                 break;
589             case T_CLOSE_TAG:
590                 // Stop processing.
591                 $doprocess = false;
592                 break;
593             default:
594                 // Anything else is within PHP tags, return it as is.
595                 if ($doprocess) {
596                     $output .= $text;
597                     if ($text === 'function') {
598                         // Explicitly keep the whitespace that would be ignored.
599                         $output .= ' ';
600                     }
601                 }
602                 break;
603             }
604         }
606         return $output;
607     }
609     /**
610      * Returns the full path to the root directory of the given plugin type
611      *
612      * @param string $plugintype
613      * @return string|null
614      */
615     public function get_plugintype_location($plugintype) {
616         return $this->get_plugin_manager()->get_plugintype_root($plugintype);
617     }
619     /**
620      * @return core_plugin_manager
621      */
622     protected function get_plugin_manager() {
623         return core_plugin_manager::instance();
624     }