64c13cbd092dbdfea4445f5f00480ea33d388d00
[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 help_icon;
33 use coding_exception;
35 defined('MOODLE_INTERNAL') || die();
37 if (!defined('T_ML_COMMENT')) {
38    define('T_ML_COMMENT', T_COMMENT);
39 } else {
40    define('T_DOC_COMMENT', T_ML_COMMENT);
41 }
43 /**
44  * Validates the contents of extracted plugin ZIP file
45  *
46  * @copyright 2013, 2015 David Mudrak <david@moodle.com>
47  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
48  */
49 class validator {
51     /** Critical error message level, causes the validation fail. */
52     const ERROR     = 'error';
54     /** Warning message level, validation does not fail but the admin should be always informed. */
55     const WARNING   = 'warning';
57     /** Information message level that the admin should be aware of. */
58     const INFO      = 'info';
60     /** Debugging message level, should be displayed in debugging mode only. */
61     const DEBUG     = 'debug';
63     /** @var string full path to the extracted ZIP contents */
64     protected $extractdir = null;
66     /** @var array as returned by {@link zip_packer::extract_to_pathname()} */
67     protected $extractfiles = null;
69     /** @var bool overall result of validation */
70     protected $result = null;
72     /** @var string the name of the plugin root directory */
73     protected $rootdir = null;
75     /** @var array explicit list of expected/required characteristics of the ZIP */
76     protected $assertions = null;
78     /** @var array of validation log messages */
79     protected $messages = array();
81     /** @var array|null array of relevant data obtained from version.php */
82     protected $versionphp = null;
84     /** @var string|null the name of found English language file without the .php extension */
85     protected $langfilename = null;
87     /**
88      * Factory method returning instance of the validator
89      *
90      * @param string $zipcontentpath full path to the extracted ZIP contents
91      * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
92      * @return \core\update\validator
93      */
94     public static function instance($zipcontentpath, array $zipcontentfiles) {
95         return new static($zipcontentpath, $zipcontentfiles);
96     }
98     /**
99      * Set the expected plugin type, fail the validation otherwise
100      *
101      * @param string $required plugin type
102      */
103     public function assert_plugin_type($required) {
104         $this->assertions['plugintype'] = $required;
105     }
107     /**
108      * Set the expectation that the plugin can be installed into the given Moodle version
109      *
110      * @param string $required Moodle version we are about to install to
111      */
112     public function assert_moodle_version($required) {
113         $this->assertions['moodleversion'] = $required;
114     }
116     /**
117      * Execute the validation process against all explicit and implicit requirements
118      *
119      * Returns true if the validation passes (all explicit and implicit requirements
120      * pass) and the plugin can be installed. Returns false if the validation fails
121      * (some explicit or implicit requirement fails) and the plugin must not be
122      * installed.
123      *
124      * @return bool
125      */
126     public function execute() {
128         $this->result = (
129                 $this->validate_files_layout()
130             and $this->validate_version_php()
131             and $this->validate_language_pack()
132             and $this->validate_target_location()
133         );
135         return $this->result;
136     }
138     /**
139      * Returns overall result of the validation.
140      *
141      * Null is returned if the validation has not been executed yet. Otherwise
142      * this method returns true (the installation can continue) or false (it is not
143      * safe to continue with the installation).
144      *
145      * @return bool|null
146      */
147     public function get_result() {
148         return $this->result;
149     }
151     /**
152      * Return the list of validation log messages
153      *
154      * Each validation message is a plain object with properties level, msgcode
155      * and addinfo.
156      *
157      * @return array of (int)index => (stdClass) validation message
158      */
159     public function get_messages() {
160         return $this->messages;
161     }
163     /**
164      * Returns human readable localised name of the given log level.
165      *
166      * @param string $level e.g. self::INFO
167      * @return string
168      */
169     public function message_level_name($level) {
170         return get_string('validationmsglevel_'.$level, 'core_plugin');
171     }
173     /**
174      * If defined, returns human readable validation code.
175      *
176      * Otherwise, it simply returns the code itself as a fallback.
177      *
178      * @param string $msgcode
179      * @return string
180      */
181     public function message_code_name($msgcode) {
183         $stringman = get_string_manager();
185         if ($stringman->string_exists('validationmsg_'.$msgcode, 'core_plugin')) {
186             return get_string('validationmsg_'.$msgcode, 'core_plugin');
187         }
189         return $msgcode;
190     }
192     /**
193      * Returns help icon for the message code if defined.
194      *
195      * @param string $msgcode
196      * @return \help_icon|false
197      */
198     public function message_help_icon($msgcode) {
200         $stringman = get_string_manager();
202         if ($stringman->string_exists('validationmsg_'.$msgcode.'_help', 'core_plugin')) {
203             return new help_icon('validationmsg_'.$msgcode, 'core_plugin');
204         }
206         return false;
207     }
209     /**
210      * Localizes the message additional info if it exists.
211      *
212      * @param string $msgcode
213      * @param array|string|null $addinfo value for the $a placeholder in the string
214      * @return string
215      */
216     public function message_code_info($msgcode, $addinfo) {
218         $stringman = get_string_manager();
220         if ($addinfo !== null and $stringman->string_exists('validationmsg_'.$msgcode.'_info', 'core_plugin')) {
221             return get_string('validationmsg_'.$msgcode.'_info', 'core_plugin', $addinfo);
222         }
224         return '';
225     }
227     /**
228      * Return the information provided by the the plugin's version.php
229      *
230      * If version.php was not found in the plugin, null is returned. Otherwise
231      * the array is returned. It may be empty if no information was parsed
232      * (which should not happen).
233      *
234      * @return null|array
235      */
236     public function get_versionphp_info() {
237         return $this->versionphp;
238     }
240     /**
241      * Returns the name of the English language file without the .php extension
242      *
243      * This can be used as a suggestion for fixing the plugin root directory in the
244      * ZIP file during the upload. If no file was found, or multiple PHP files are
245      * located in lang/en/ folder, then null is returned.
246      *
247      * @return null|string
248      */
249     public function get_language_file_name() {
250         return $this->langfilename;
251     }
253     /**
254      * Returns the rootdir of the extracted package (after eventual renaming)
255      *
256      * @return string|null
257      */
258     public function get_rootdir() {
259         return $this->rootdir;
260     }
262     // End of external API.
264     /**
265      * @param string $zipcontentpath full path to the extracted ZIP contents
266      * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
267      */
268     protected function __construct($zipcontentpath, array $zipcontentfiles) {
269         $this->extractdir = $zipcontentpath;
270         $this->extractfiles = $zipcontentfiles;
271     }
273     // Validation methods.
275     /**
276      * @return bool false if files in the ZIP do not have required layout
277      */
278     protected function validate_files_layout() {
280         if (!is_array($this->extractfiles) or count($this->extractfiles) < 4) {
281             // We need the English language pack with the name of the plugin at least
282             $this->add_message(self::ERROR, 'filesnumber');
283             return false;
284         }
286         foreach ($this->extractfiles as $filerelname => $filestatus) {
287             if ($filestatus !== true) {
288                 $this->add_message(self::ERROR, 'filestatus', array('file' => $filerelname, 'status' => $filestatus));
289                 return false;
290             }
291         }
293         foreach (array_keys($this->extractfiles) as $filerelname) {
294             if (!file_exists($this->extractdir.'/'.$filerelname)) {
295                 $this->add_message(self::ERROR, 'filenotexists', array('file' => $filerelname));
296                 return false;
297             }
298         }
300         foreach (array_keys($this->extractfiles) as $filerelname) {
301             $matches = array();
302             if (!preg_match("#^([^/]+)/#", $filerelname, $matches)
303                     or (!is_null($this->rootdir) and $this->rootdir !== $matches[1])) {
304                 $this->add_message(self::ERROR, 'onedir');
305                 return false;
306             }
307             $this->rootdir = $matches[1];
308         }
310         if ($this->rootdir !== clean_param($this->rootdir, PARAM_PLUGIN)) {
311             $this->add_message(self::ERROR, 'rootdirinvalid', $this->rootdir);
312             return false;
313         } else {
314             $this->add_message(self::INFO, 'rootdir', $this->rootdir);
315         }
317         return is_dir($this->extractdir.'/'.$this->rootdir);
318     }
320     /**
321      * @return bool false if the version.php file does not declare required information
322      */
323     protected function validate_version_php() {
325         if (!isset($this->assertions['plugintype'])) {
326             throw new coding_exception('Required plugin type must be set before calling this');
327         }
329         if (!isset($this->assertions['moodleversion'])) {
330             throw new coding_exception('Required Moodle version must be set before calling this');
331         }
333         $fullpath = $this->extractdir.'/'.$this->rootdir.'/version.php';
335         if (!file_exists($fullpath)) {
336             // This is tolerated for themes only.
337             if ($this->assertions['plugintype'] === 'theme') {
338                 $this->add_message(self::DEBUG, 'missingversionphp');
339                 return true;
340             } else {
341                 $this->add_message(self::ERROR, 'missingversionphp');
342                 return false;
343             }
344         }
346         $this->versionphp = array();
347         $info = $this->parse_version_php($fullpath);
349         if (isset($info['module->version'])) {
350             $this->add_message(self::ERROR, 'versionphpsyntax', '$module');
351             return false;
352         }
354         if (isset($info['plugin->version'])) {
355             $this->versionphp['version'] = $info['plugin->version'];
356             $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
357         } else {
358             $this->add_message(self::ERROR, 'missingversion');
359             return false;
360         }
362         if (isset($info['plugin->requires'])) {
363             $this->versionphp['requires'] = $info['plugin->requires'];
364             if ($this->versionphp['requires'] > $this->assertions['moodleversion']) {
365                 $this->add_message(self::ERROR, 'requiresmoodle', $this->versionphp['requires']);
366                 return false;
367             }
368             $this->add_message(self::INFO, 'requiresmoodle', $this->versionphp['requires']);
369         }
371         if (!isset($info['plugin->component'])) {
372             $this->add_message(self::ERROR, 'missingcomponent');
373             return false;
374         }
376         $this->versionphp['component'] = $info['plugin->component'];
377         list($reqtype, $reqname) = core_component::normalize_component($this->versionphp['component']);
378         if ($reqtype !== $this->assertions['plugintype']) {
379             $this->add_message(self::ERROR, 'componentmismatchtype', array(
380                 'expected' => $this->assertions['plugintype'],
381                 'found' => $reqtype));
382             return false;
383         }
384         if ($reqname !== $this->rootdir) {
385             $this->add_message(self::ERROR, 'componentmismatchname', $reqname);
386             return false;
387         }
388         $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']);
390         if (isset($info['plugin->maturity'])) {
391             $this->versionphp['maturity'] = $info['plugin->maturity'];
392             if ($this->versionphp['maturity'] === 'MATURITY_STABLE') {
393                 $this->add_message(self::INFO, 'maturity', $this->versionphp['maturity']);
394             } else {
395                 $this->add_message(self::WARNING, 'maturity', $this->versionphp['maturity']);
396             }
397         }
399         if (isset($info['plugin->release'])) {
400             $this->versionphp['release'] = $info['plugin->release'];
401             $this->add_message(self::INFO, 'release', $this->versionphp['release']);
402         }
404         return true;
405     }
407     /**
408      * @return bool false if the English language pack is not provided correctly
409      */
410     protected function validate_language_pack() {
412         if (!isset($this->assertions['plugintype'])) {
413             throw new coding_exception('Required plugin type must be set before calling this');
414         }
416         if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'])
417                 or $this->extractfiles[$this->rootdir.'/lang/en/'] !== true
418                 or !is_dir($this->extractdir.'/'.$this->rootdir.'/lang/en')) {
419             $this->add_message(self::ERROR, 'missinglangenfolder');
420             return false;
421         }
423         $langfiles = array();
424         foreach (array_keys($this->extractfiles) as $extractfile) {
425             $matches = array();
426             if (preg_match('#^'.preg_quote($this->rootdir).'/lang/en/([^/]+).php?$#i', $extractfile, $matches)) {
427                 $langfiles[] = $matches[1];
428             }
429         }
431         if (empty($langfiles)) {
432             $this->add_message(self::ERROR, 'missinglangenfile');
433             return false;
434         } else if (count($langfiles) > 1) {
435             $this->add_message(self::WARNING, 'multiplelangenfiles');
436         } else {
437             $this->langfilename = $langfiles[0];
438             $this->add_message(self::DEBUG, 'foundlangfile', $this->langfilename);
439         }
441         if ($this->assertions['plugintype'] === 'mod') {
442             $expected = $this->rootdir.'.php';
443         } else {
444             $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php';
445         }
447         if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'.$expected])
448                 or $this->extractfiles[$this->rootdir.'/lang/en/'.$expected] !== true
449                 or !is_file($this->extractdir.'/'.$this->rootdir.'/lang/en/'.$expected)) {
450             $this->add_message(self::ERROR, 'missingexpectedlangenfile', $expected);
451             return false;
452         }
454         return true;
455     }
457     /**
458      * @return bool false of the given add-on can't be installed into its location
459      */
460     public function validate_target_location() {
462         if (!isset($this->assertions['plugintype'])) {
463             throw new coding_exception('Required plugin type must be set before calling this');
464         }
466         $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']);
468         if (is_null($plugintypepath)) {
469             $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']);
470             return false;
471         }
473         if (!is_dir($plugintypepath)) {
474             throw new coding_exception('Plugin type location does not exist!');
475         }
477         $target = $plugintypepath.'/'.$this->rootdir;
479         if (file_exists($target)) {
480             $this->add_message(self::ERROR, 'targetexists', $target);
481             return false;
482         }
484         if (is_writable($plugintypepath)) {
485             $this->add_message(self::INFO, 'pathwritable', $plugintypepath);
486         } else {
487             $this->add_message(self::ERROR, 'pathwritable', $plugintypepath);
488             return false;
489         }
491         return true;
492     }
494     // Helper methods.
496     /**
497      * Get as much information from existing version.php as possible
498      *
499      * @param string full path to the version.php file
500      * @return array of found meta-info declarations
501      */
502     protected function parse_version_php($fullpath) {
504         $content = $this->get_stripped_file_contents($fullpath);
506         preg_match_all('#\$((plugin|module)\->(version|maturity|release|requires))=()(\d+(\.\d+)?);#m', $content, $matches1);
507         preg_match_all('#\$((plugin|module)\->(maturity))=()(MATURITY_\w+);#m', $content, $matches2);
508         preg_match_all('#\$((plugin|module)\->(release))=([\'"])(.*?)\4;#m', $content, $matches3);
509         preg_match_all('#\$((plugin|module)\->(component))=([\'"])(.+?_.+?)\4;#m', $content, $matches4);
511         if (count($matches1[1]) + count($matches2[1]) + count($matches3[1]) + count($matches4[1])) {
512             $info = array_combine(
513                 array_merge($matches1[1], $matches2[1], $matches3[1], $matches4[1]),
514                 array_merge($matches1[5], $matches2[5], $matches3[5], $matches4[5])
515             );
517         } else {
518             $info = array();
519         }
521         return $info;
522     }
524     /**
525      * Append the given message to the messages log
526      *
527      * @param string $level e.g. self::ERROR
528      * @param string $msgcode may form a string
529      * @param string|array|object $a optional additional info suitable for {@link get_string()}
530      */
531     protected function add_message($level, $msgcode, $a = null) {
532         $msg = (object)array(
533             'level'     => $level,
534             'msgcode'   => $msgcode,
535             'addinfo'   => $a,
536         );
537         $this->messages[] = $msg;
538     }
540     /**
541      * Returns bare PHP code from the given file
542      *
543      * Returns contents without PHP opening and closing tags, text outside php code,
544      * comments and extra whitespaces.
545      *
546      * @param string $fullpath full path to the file
547      * @return string
548      */
549     protected function get_stripped_file_contents($fullpath) {
551         $source = file_get_contents($fullpath);
552         $tokens = token_get_all($source);
553         $output = '';
554         $doprocess = false;
555         foreach ($tokens as $token) {
556             if (is_string($token)) {
557                 // Simple one character token.
558                 $id = -1;
559                 $text = $token;
560             } else {
561                 // Token array.
562                 list($id, $text) = $token;
563             }
564             switch ($id) {
565             case T_WHITESPACE:
566             case T_COMMENT:
567             case T_ML_COMMENT:
568             case T_DOC_COMMENT:
569                 // Ignore whitespaces, inline comments, multiline comments and docblocks.
570                 break;
571             case T_OPEN_TAG:
572                 // Start processing.
573                 $doprocess = true;
574                 break;
575             case T_CLOSE_TAG:
576                 // Stop processing.
577                 $doprocess = false;
578                 break;
579             default:
580                 // Anything else is within PHP tags, return it as is.
581                 if ($doprocess) {
582                     $output .= $text;
583                     if ($text === 'function') {
584                         // Explicitly keep the whitespace that would be ignored.
585                         $output .= ' ';
586                     }
587                 }
588                 break;
589             }
590         }
592         return $output;
593     }
595     /**
596      * Returns the full path to the root directory of the given plugin type
597      *
598      * @param string $plugintype
599      * @return string|null
600      */
601     public function get_plugintype_location($plugintype) {
603         $plugintypepath = null;
605         foreach (core_component::get_plugin_types() as $type => $fullpath) {
606             if ($type === $plugintype) {
607                 $plugintypepath = $fullpath;
608                 break;
609             }
610         }
612         return $plugintypepath;
613     }