0bb3a413e1844c9f204de49c51a4fe6aff465b3d
[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->versionphp['component'] = $info['plugin->component'];
327             list($reqtype, $reqname) = core_component::normalize_component($this->versionphp['component']);
328             if ($reqtype !== $this->assertions['plugintype']) {
329                 $this->add_message(self::ERROR, 'componentmismatchtype', array(
330                     'expected' => $this->assertions['plugintype'],
331                     'found' => $reqtype));
332                 return false;
333             }
334             if ($reqname !== $this->rootdir) {
335                 $this->add_message(self::ERROR, 'componentmismatchname', $reqname);
336                 return false;
337             }
338             $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']);
339         }
341         if (isset($info['plugin->maturity'])) {
342             $this->versionphp['maturity'] = $info['plugin->maturity'];
343             if ($this->versionphp['maturity'] === 'MATURITY_STABLE') {
344                 $this->add_message(self::INFO, 'maturity', $this->versionphp['maturity']);
345             } else {
346                 $this->add_message(self::WARNING, 'maturity', $this->versionphp['maturity']);
347             }
348         }
350         if (isset($info['plugin->release'])) {
351             $this->versionphp['release'] = $info['plugin->release'];
352             $this->add_message(self::INFO, 'release', $this->versionphp['release']);
353         }
355         return true;
356     }
358     /**
359      * @return bool false if the English language pack is not provided correctly
360      */
361     protected function validate_language_pack() {
363         if (!isset($this->assertions['plugintype'])) {
364             throw new coding_exception('Required plugin type must be set before calling this');
365         }
367         if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'])
368                 or $this->extractfiles[$this->rootdir.'/lang/en/'] !== true
369                 or !is_dir($this->extractdir.'/'.$this->rootdir.'/lang/en')) {
370             $this->add_message(self::ERROR, 'missinglangenfolder');
371             return false;
372         }
374         $langfiles = array();
375         foreach (array_keys($this->extractfiles) as $extractfile) {
376             $matches = array();
377             if (preg_match('#^'.preg_quote($this->rootdir).'/lang/en/([^/]+).php?$#i', $extractfile, $matches)) {
378                 $langfiles[] = $matches[1];
379             }
380         }
382         if (empty($langfiles)) {
383             $this->add_message(self::ERROR, 'missinglangenfile');
384             return false;
385         } else if (count($langfiles) > 1) {
386             $this->add_message(self::WARNING, 'multiplelangenfiles');
387         } else {
388             $this->langfilename = $langfiles[0];
389             $this->add_message(self::DEBUG, 'foundlangfile', $this->langfilename);
390         }
392         if ($this->assertions['plugintype'] === 'mod') {
393             $expected = $this->rootdir.'.php';
394         } else {
395             $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php';
396         }
398         if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'.$expected])
399                 or $this->extractfiles[$this->rootdir.'/lang/en/'.$expected] !== true
400                 or !is_file($this->extractdir.'/'.$this->rootdir.'/lang/en/'.$expected)) {
401             $this->add_message(self::ERROR, 'missingexpectedlangenfile', $expected);
402             return false;
403         }
405         return true;
406     }
409     /**
410      * @return bool false of the given add-on can't be installed into its location
411      */
412     public function validate_target_location() {
414         if (!isset($this->assertions['plugintype'])) {
415             throw new coding_exception('Required plugin type must be set before calling this');
416         }
418         $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']);
420         if (is_null($plugintypepath)) {
421             $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']);
422             return false;
423         }
425         if (!is_dir($plugintypepath)) {
426             throw new coding_exception('Plugin type location does not exist!');
427         }
429         $target = $plugintypepath.'/'.$this->rootdir;
431         if (file_exists($target)) {
432             $this->add_message(self::ERROR, 'targetexists', $target);
433             return false;
434         }
436         if (is_writable($plugintypepath)) {
437             $this->add_message(self::INFO, 'pathwritable', $plugintypepath);
438         } else {
439             $this->add_message(self::ERROR, 'pathwritable', $plugintypepath);
440             return false;
441         }
443         return true;
444     }
446     // Helper methods //////////////////////////////////////////////////////////
448     /**
449      * Get as much information from existing version.php as possible
450      *
451      * @param string full path to the version.php file
452      * @return array of found meta-info declarations
453      */
454     protected function parse_version_php($fullpath) {
456         $content = $this->get_stripped_file_contents($fullpath);
458         preg_match_all('#\$((plugin|module)\->(version|maturity|release|requires))=()(\d+(\.\d+)?);#m', $content, $matches1);
459         preg_match_all('#\$((plugin|module)\->(maturity))=()(MATURITY_\w+);#m', $content, $matches2);
460         preg_match_all('#\$((plugin|module)\->(release))=([\'"])(.*?)\4;#m', $content, $matches3);
461         preg_match_all('#\$((plugin|module)\->(component))=([\'"])(.+?_.+?)\4;#m', $content, $matches4);
463         if (count($matches1[1]) + count($matches2[1]) + count($matches3[1]) + count($matches4[1])) {
464             $info = array_combine(
465                 array_merge($matches1[1], $matches2[1], $matches3[1], $matches4[1]),
466                 array_merge($matches1[5], $matches2[5], $matches3[5], $matches4[5])
467             );
469         } else {
470             $info = array();
471         }
473         return $info;
474     }
476     /**
477      * Append the given message to the messages log
478      *
479      * @param string $level e.g. self::ERROR
480      * @param string $msgcode may form a string
481      * @param string|array|object $a optional additional info suitable for {@link get_string()}
482      */
483     protected function add_message($level, $msgcode, $a = null) {
484         $msg = (object)array(
485             'level'     => $level,
486             'msgcode'   => $msgcode,
487             'addinfo'   => $a,
488         );
489         $this->messages[] = $msg;
490     }
492     /**
493      * Returns bare PHP code from the given file
494      *
495      * Returns contents without PHP opening and closing tags, text outside php code,
496      * comments and extra whitespaces.
497      *
498      * @param string $fullpath full path to the file
499      * @return string
500      */
501     protected function get_stripped_file_contents($fullpath) {
503         $source = file_get_contents($fullpath);
504         $tokens = token_get_all($source);
505         $output = '';
506         $doprocess = false;
507         foreach ($tokens as $token) {
508             if (is_string($token)) {
509                 // Simple one character token.
510                 $id = -1;
511                 $text = $token;
512             } else {
513                 // Token array.
514                 list($id, $text) = $token;
515             }
516             switch ($id) {
517             case T_WHITESPACE:
518             case T_COMMENT:
519             case T_ML_COMMENT:
520             case T_DOC_COMMENT:
521                 // Ignore whitespaces, inline comments, multiline comments and docblocks.
522                 break;
523             case T_OPEN_TAG:
524                 // Start processing.
525                 $doprocess = true;
526                 break;
527             case T_CLOSE_TAG:
528                 // Stop processing.
529                 $doprocess = false;
530                 break;
531             default:
532                 // Anything else is within PHP tags, return it as is.
533                 if ($doprocess) {
534                     $output .= $text;
535                     if ($text === 'function') {
536                         // Explicitly keep the whitespace that would be ignored.
537                         $output .= ' ';
538                     }
539                 }
540                 break;
541             }
542         }
544         return $output;
545     }
548     /**
549      * Returns the full path to the root directory of the given plugin type
550      *
551      * @param string $plugintype
552      * @return string|null
553      */
554     public function get_plugintype_location($plugintype) {
556         $plugintypepath = null;
558         foreach (core_component::get_plugin_types() as $type => $fullpath) {
559             if ($type === $plugintype) {
560                 $plugintypepath = $fullpath;
561                 break;
562             }
563         }
565         return $plugintypepath;
566     }