MDL-48494 admin: Fail validation of plugins with no component declared
[moodle.git] / admin / tool / installaddon / classes / validator.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * Provides validation class to check the plugin ZIP contents
20  *
21  * Uses fragments of the local_plugins_archive_validator class copyrighted by
22  * Marina Glancy that is part of the local_plugins plugin.
23  *
24  * @package     tool_installaddon
25  * @subpackage  classes
26  * @copyright   2013 David Mudrak <david@moodle.com>
27  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28  */
30 defined('MOODLE_INTERNAL') || die();
32 if (!defined('T_ML_COMMENT')) {
33    define('T_ML_COMMENT', T_COMMENT);
34 } else {
35    define('T_DOC_COMMENT', T_ML_COMMENT);
36 }
38 /**
39  * Validates the contents of extracted plugin ZIP file
40  *
41  * @copyright 2013 David Mudrak <david@moodle.com>
42  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43  */
44 class tool_installaddon_validator {
46     /** Critical error message level, causes the validation fail. */
47     const ERROR     = 'error';
49     /** Warning message level, validation does not fail but the admin should be always informed. */
50     const WARNING   = 'warning';
52     /** Information message level that the admin should be aware of. */
53     const INFO      = 'info';
55     /** Debugging message level, should be displayed in debugging mode only. */
56     const DEBUG     = 'debug';
58     /** @var string full path to the extracted ZIP contents */
59     protected $extractdir = null;
61     /** @var array as returned by {@link zip_packer::extract_to_pathname()} */
62     protected $extractfiles = null;
64     /** @var bool overall result of validation */
65     protected $result = null;
67     /** @var string the name of the plugin root directory */
68     protected $rootdir = null;
70     /** @var array explicit list of expected/required characteristics of the ZIP */
71     protected $assertions = null;
73     /** @var array of validation log messages */
74     protected $messages = array();
76     /** @var array|null array of relevant data obtained from version.php */
77     protected $versionphp = null;
79     /** @var string|null the name of found English language file without the .php extension */
80     protected $langfilename = null;
82     /** @var moodle_url|null URL to continue with the installation of validated add-on */
83     protected $continueurl = null;
85     /**
86      * Factory method returning instance of the validator
87      *
88      * @param string $zipcontentpath full path to the extracted ZIP contents
89      * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
90      * @return tool_installaddon_validator
91      */
92     public static function instance($zipcontentpath, array $zipcontentfiles) {
93         return new static($zipcontentpath, $zipcontentfiles);
94     }
96     /**
97      * Set the expected plugin type, fail the validation otherwise
98      *
99      * @param string $required plugin type
100      */
101     public function assert_plugin_type($required) {
102         $this->assertions['plugintype'] = $required;
103     }
105     /**
106      * Set the expectation that the plugin can be installed into the given Moodle version
107      *
108      * @param string $required Moodle version we are about to install to
109      */
110     public function assert_moodle_version($required) {
111         $this->assertions['moodleversion'] = $required;
112     }
114     /**
115      * Execute the validation process against all explicit and implicit requirements
116      *
117      * Returns true if the validation passes (all explicit and implicit requirements
118      * pass) and the plugin can be installed. Returns false if the validation fails
119      * (some explicit or implicit requirement fails) and the plugin must not be
120      * installed.
121      *
122      * @return bool
123      */
124     public function execute() {
126         $this->result = (
127                 $this->validate_files_layout()
128             and $this->validate_version_php()
129             and $this->validate_language_pack()
130             and $this->validate_target_location()
131         );
133         return $this->result;
134     }
136     /**
137      * Returns overall result of the validation.
138      *
139      * Null is returned if the validation has not been executed yet. Otherwise
140      * this method returns true (the installation can continue) or false (it is not
141      * safe to continue with the installation).
142      *
143      * @return bool|null
144      */
145     public function get_result() {
146         return $this->result;
147     }
149     /**
150      * Return the list of validation log messages
151      *
152      * Each validation message is a plain object with properties level, msgcode
153      * and addinfo.
154      *
155      * @return array of (int)index => (stdClass) validation message
156      */
157     public function get_messages() {
158         return $this->messages;
159     }
161     /**
162      * Return the information provided by the the plugin's version.php
163      *
164      * If version.php was not found in the plugin (which is tolerated for
165      * themes only at the moment), null is returned. Otherwise the array
166      * is returned. It may be empty if no information was parsed (which
167      * should not happen).
168      *
169      * @return null|array
170      */
171     public function get_versionphp_info() {
172         return $this->versionphp;
173     }
175     /**
176      * Returns the name of the English language file without the .php extension
177      *
178      * This can be used as a suggestion for fixing the plugin root directory in the
179      * ZIP file during the upload. If no file was found, or multiple PHP files are
180      * located in lang/en/ folder, then null is returned.
181      *
182      * @return null|string
183      */
184     public function get_language_file_name() {
185         return $this->langfilename;
186     }
188     /**
189      * Returns the rootdir of the extracted package (after eventual renaming)
190      *
191      * @return string|null
192      */
193     public function get_rootdir() {
194         return $this->rootdir;
195     }
197     /**
198      * Sets the URL to continue to after successful validation
199      *
200      * @param moodle_url $url
201      */
202     public function set_continue_url(moodle_url $url) {
203         $this->continueurl = $url;
204     }
206     /**
207      * Get the URL to continue to after successful validation
208      *
209      * Null is returned if the URL has not been explicitly set by the caller.
210      *
211      * @return moodle_url|null
212      */
213     public function get_continue_url() {
214         return $this->continueurl;
215     }
217     // End of external API /////////////////////////////////////////////////////
219     /**
220      * @param string $zipcontentpath full path to the extracted ZIP contents
221      * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
222      */
223     protected function __construct($zipcontentpath, array $zipcontentfiles) {
224         $this->extractdir = $zipcontentpath;
225         $this->extractfiles = $zipcontentfiles;
226     }
228     // Validation methods //////////////////////////////////////////////////////
230     /**
231      * @return bool false if files in the ZIP do not have required layout
232      */
233     protected function validate_files_layout() {
235         if (!is_array($this->extractfiles) or count($this->extractfiles) < 4) {
236             // We need the English language pack with the name of the plugin at least
237             $this->add_message(self::ERROR, 'filesnumber');
238             return false;
239         }
241         foreach ($this->extractfiles as $filerelname => $filestatus) {
242             if ($filestatus !== true) {
243                 $this->add_message(self::ERROR, 'filestatus', array('file' => $filerelname, 'status' => $filestatus));
244                 return false;
245             }
246         }
248         foreach (array_keys($this->extractfiles) as $filerelname) {
249             if (!file_exists($this->extractdir.'/'.$filerelname)) {
250                 $this->add_message(self::ERROR, 'filenotexists', array('file' => $filerelname));
251                 return false;
252             }
253         }
255         foreach (array_keys($this->extractfiles) as $filerelname) {
256             $matches = array();
257             if (!preg_match("#^([^/]+)/#", $filerelname, $matches) or (!is_null($this->rootdir) and $this->rootdir !== $matches[1])) {
258                 $this->add_message(self::ERROR, 'onedir');
259                 return false;
260             }
261             $this->rootdir = $matches[1];
262         }
264         if ($this->rootdir !== clean_param($this->rootdir, PARAM_PLUGIN)) {
265             $this->add_message(self::ERROR, 'rootdirinvalid', $this->rootdir);
266             return false;
267         } else {
268             $this->add_message(self::INFO, 'rootdir', $this->rootdir);
269         }
271         return is_dir($this->extractdir.'/'.$this->rootdir);
272     }
274     /**
275      * @return bool false if the version.php file does not declare required information
276      */
277     protected function validate_version_php() {
279         if (!isset($this->assertions['plugintype'])) {
280             throw new coding_exception('Required plugin type must be set before calling this');
281         }
283         if (!isset($this->assertions['moodleversion'])) {
284             throw new coding_exception('Required Moodle version must be set before calling this');
285         }
287         $fullpath = $this->extractdir.'/'.$this->rootdir.'/version.php';
289         if (!file_exists($fullpath)) {
290             // This is tolerated for themes only.
291             if ($this->assertions['plugintype'] === 'theme') {
292                 $this->add_message(self::DEBUG, 'missingversionphp');
293                 return true;
294             } else {
295                 $this->add_message(self::ERROR, 'missingversionphp');
296                 return false;
297             }
298         }
300         $this->versionphp = array();
301         $info = $this->parse_version_php($fullpath);
303         if (isset($info['module->version'])) {
304             $this->add_message(self::ERROR, 'versionphpsyntax', '$module');
305             return false;
306         }
308         if (isset($info['plugin->version'])) {
309             $this->versionphp['version'] = $info['plugin->version'];
310             $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
311         } else {
312             $this->add_message(self::ERROR, 'missingversion');
313             return false;
314         }
316         if (isset($info['plugin->requires'])) {
317             $this->versionphp['requires'] = $info['plugin->requires'];
318             if ($this->versionphp['requires'] > $this->assertions['moodleversion']) {
319                 $this->add_message(self::ERROR, 'requiresmoodle', $this->versionphp['requires']);
320                 return false;
321             }
322             $this->add_message(self::INFO, 'requiresmoodle', $this->versionphp['requires']);
323         }
325         if (!isset($info['plugin->component'])) {
326             $this->add_message(self::ERROR, 'missingcomponent');
327             return false;
328         }
330         $this->versionphp['component'] = $info['plugin->component'];
331         list($reqtype, $reqname) = core_component::normalize_component($this->versionphp['component']);
332         if ($reqtype !== $this->assertions['plugintype']) {
333             $this->add_message(self::ERROR, 'componentmismatchtype', array(
334                 'expected' => $this->assertions['plugintype'],
335                 'found' => $reqtype));
336             return false;
337         }
338         if ($reqname !== $this->rootdir) {
339             $this->add_message(self::ERROR, 'componentmismatchname', $reqname);
340             return false;
341         }
342         $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']);
344         if (isset($info['plugin->maturity'])) {
345             $this->versionphp['maturity'] = $info['plugin->maturity'];
346             if ($this->versionphp['maturity'] === 'MATURITY_STABLE') {
347                 $this->add_message(self::INFO, 'maturity', $this->versionphp['maturity']);
348             } else {
349                 $this->add_message(self::WARNING, 'maturity', $this->versionphp['maturity']);
350             }
351         }
353         if (isset($info['plugin->release'])) {
354             $this->versionphp['release'] = $info['plugin->release'];
355             $this->add_message(self::INFO, 'release', $this->versionphp['release']);
356         }
358         return true;
359     }
361     /**
362      * @return bool false if the English language pack is not provided correctly
363      */
364     protected function validate_language_pack() {
366         if (!isset($this->assertions['plugintype'])) {
367             throw new coding_exception('Required plugin type must be set before calling this');
368         }
370         if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'])
371                 or $this->extractfiles[$this->rootdir.'/lang/en/'] !== true
372                 or !is_dir($this->extractdir.'/'.$this->rootdir.'/lang/en')) {
373             $this->add_message(self::ERROR, 'missinglangenfolder');
374             return false;
375         }
377         $langfiles = array();
378         foreach (array_keys($this->extractfiles) as $extractfile) {
379             $matches = array();
380             if (preg_match('#^'.preg_quote($this->rootdir).'/lang/en/([^/]+).php?$#i', $extractfile, $matches)) {
381                 $langfiles[] = $matches[1];
382             }
383         }
385         if (empty($langfiles)) {
386             $this->add_message(self::ERROR, 'missinglangenfile');
387             return false;
388         } else if (count($langfiles) > 1) {
389             $this->add_message(self::WARNING, 'multiplelangenfiles');
390         } else {
391             $this->langfilename = $langfiles[0];
392             $this->add_message(self::DEBUG, 'foundlangfile', $this->langfilename);
393         }
395         if ($this->assertions['plugintype'] === 'mod') {
396             $expected = $this->rootdir.'.php';
397         } else {
398             $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php';
399         }
401         if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'.$expected])
402                 or $this->extractfiles[$this->rootdir.'/lang/en/'.$expected] !== true
403                 or !is_file($this->extractdir.'/'.$this->rootdir.'/lang/en/'.$expected)) {
404             $this->add_message(self::ERROR, 'missingexpectedlangenfile', $expected);
405             return false;
406         }
408         return true;
409     }
412     /**
413      * @return bool false of the given add-on can't be installed into its location
414      */
415     public function validate_target_location() {
417         if (!isset($this->assertions['plugintype'])) {
418             throw new coding_exception('Required plugin type must be set before calling this');
419         }
421         $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']);
423         if (is_null($plugintypepath)) {
424             $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']);
425             return false;
426         }
428         if (!is_dir($plugintypepath)) {
429             throw new coding_exception('Plugin type location does not exist!');
430         }
432         $target = $plugintypepath.'/'.$this->rootdir;
434         if (file_exists($target)) {
435             $this->add_message(self::ERROR, 'targetexists', $target);
436             return false;
437         }
439         if (is_writable($plugintypepath)) {
440             $this->add_message(self::INFO, 'pathwritable', $plugintypepath);
441         } else {
442             $this->add_message(self::ERROR, 'pathwritable', $plugintypepath);
443             return false;
444         }
446         return true;
447     }
449     // Helper methods //////////////////////////////////////////////////////////
451     /**
452      * Get as much information from existing version.php as possible
453      *
454      * @param string full path to the version.php file
455      * @return array of found meta-info declarations
456      */
457     protected function parse_version_php($fullpath) {
459         $content = $this->get_stripped_file_contents($fullpath);
461         preg_match_all('#\$((plugin|module)\->(version|maturity|release|requires))=()(\d+(\.\d+)?);#m', $content, $matches1);
462         preg_match_all('#\$((plugin|module)\->(maturity))=()(MATURITY_\w+);#m', $content, $matches2);
463         preg_match_all('#\$((plugin|module)\->(release))=([\'"])(.*?)\4;#m', $content, $matches3);
464         preg_match_all('#\$((plugin|module)\->(component))=([\'"])(.+?_.+?)\4;#m', $content, $matches4);
466         if (count($matches1[1]) + count($matches2[1]) + count($matches3[1]) + count($matches4[1])) {
467             $info = array_combine(
468                 array_merge($matches1[1], $matches2[1], $matches3[1], $matches4[1]),
469                 array_merge($matches1[5], $matches2[5], $matches3[5], $matches4[5])
470             );
472         } else {
473             $info = array();
474         }
476         return $info;
477     }
479     /**
480      * Append the given message to the messages log
481      *
482      * @param string $level e.g. self::ERROR
483      * @param string $msgcode may form a string
484      * @param string|array|object $a optional additional info suitable for {@link get_string()}
485      */
486     protected function add_message($level, $msgcode, $a = null) {
487         $msg = (object)array(
488             'level'     => $level,
489             'msgcode'   => $msgcode,
490             'addinfo'   => $a,
491         );
492         $this->messages[] = $msg;
493     }
495     /**
496      * Returns bare PHP code from the given file
497      *
498      * Returns contents without PHP opening and closing tags, text outside php code,
499      * comments and extra whitespaces.
500      *
501      * @param string $fullpath full path to the file
502      * @return string
503      */
504     protected function get_stripped_file_contents($fullpath) {
506         $source = file_get_contents($fullpath);
507         $tokens = token_get_all($source);
508         $output = '';
509         $doprocess = false;
510         foreach ($tokens as $token) {
511             if (is_string($token)) {
512                 // Simple one character token.
513                 $id = -1;
514                 $text = $token;
515             } else {
516                 // Token array.
517                 list($id, $text) = $token;
518             }
519             switch ($id) {
520             case T_WHITESPACE:
521             case T_COMMENT:
522             case T_ML_COMMENT:
523             case T_DOC_COMMENT:
524                 // Ignore whitespaces, inline comments, multiline comments and docblocks.
525                 break;
526             case T_OPEN_TAG:
527                 // Start processing.
528                 $doprocess = true;
529                 break;
530             case T_CLOSE_TAG:
531                 // Stop processing.
532                 $doprocess = false;
533                 break;
534             default:
535                 // Anything else is within PHP tags, return it as is.
536                 if ($doprocess) {
537                     $output .= $text;
538                     if ($text === 'function') {
539                         // Explicitly keep the whitespace that would be ignored.
540                         $output .= ' ';
541                     }
542                 }
543                 break;
544             }
545         }
547         return $output;
548     }
551     /**
552      * Returns the full path to the root directory of the given plugin type
553      *
554      * @param string $plugintype
555      * @return string|null
556      */
557     public function get_plugintype_location($plugintype) {
559         $plugintypepath = null;
561         foreach (core_component::get_plugin_types() as $type => $fullpath) {
562             if ($type === $plugintype) {
563                 $plugintypepath = $fullpath;
564                 break;
565             }
566         }
568         return $plugintypepath;
569     }