source: moodle/trunk/fuentes/admin/tool/installaddon/classes/validator.php @ 136

Last change on this file since 136 was 136, checked in by mabarracus, 4 years ago

Ported code to xenial

File size: 19.7 KB
Line 
1<?php
2
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/>.
17
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 */
29
30defined('MOODLE_INTERNAL') || die();
31
32if (!defined('T_ML_COMMENT')) {
33   define('T_ML_COMMENT', T_COMMENT);
34} else {
35   define('T_DOC_COMMENT', T_ML_COMMENT);
36}
37
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 */
44class tool_installaddon_validator {
45
46    /** Critical error message level, causes the validation fail. */
47    const ERROR     = 'error';
48
49    /** Warning message level, validation does not fail but the admin should be always informed. */
50    const WARNING   = 'warning';
51
52    /** Information message level that the admin should be aware of. */
53    const INFO      = 'info';
54
55    /** Debugging message level, should be displayed in debugging mode only. */
56    const DEBUG     = 'debug';
57
58    /** @var string full path to the extracted ZIP contents */
59    protected $extractdir = null;
60
61    /** @var array as returned by {@link zip_packer::extract_to_pathname()} */
62    protected $extractfiles = null;
63
64    /** @var bool overall result of validation */
65    protected $result = null;
66
67    /** @var string the name of the plugin root directory */
68    protected $rootdir = null;
69
70    /** @var array explicit list of expected/required characteristics of the ZIP */
71    protected $assertions = null;
72
73    /** @var array of validation log messages */
74    protected $messages = array();
75
76    /** @var array|null array of relevant data obtained from version.php */
77    protected $versionphp = null;
78
79    /** @var string|null the name of found English language file without the .php extension */
80    protected $langfilename = null;
81
82    /** @var moodle_url|null URL to continue with the installation of validated add-on */
83    protected $continueurl = null;
84
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    }
95
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    }
104
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    }
113
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() {
125
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        );
132
133        return $this->result;
134    }
135
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    }
148
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    }
160
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    }
174
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    }
187
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    }
196
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    }
205
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    }
216
217    // End of external API /////////////////////////////////////////////////////
218
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    }
227
228    // Validation methods //////////////////////////////////////////////////////
229
230    /**
231     * @return bool false if files in the ZIP do not have required layout
232     */
233    protected function validate_files_layout() {
234
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        }
240
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        }
247
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        }
254
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        }
263
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        }
270
271        return is_dir($this->extractdir.'/'.$this->rootdir);
272    }
273
274    /**
275     * @return bool false if the version.php file does not declare required information
276     */
277    protected function validate_version_php() {
278
279        if (!isset($this->assertions['plugintype'])) {
280            throw new coding_exception('Required plugin type must be set before calling this');
281        }
282
283        if (!isset($this->assertions['moodleversion'])) {
284            throw new coding_exception('Required Moodle version must be set before calling this');
285        }
286
287        $fullpath = $this->extractdir.'/'.$this->rootdir.'/version.php';
288
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        }
299
300        $this->versionphp = array();
301        $info = $this->parse_version_php($fullpath);
302
303        if ($this->assertions['plugintype'] === 'mod') {
304            $type = 'module';
305        } else {
306            $type = 'plugin';
307        }
308
309        if (!isset($info[$type.'->version'])) {
310            if ($type === 'module' and isset($info['plugin->version'])) {
311                // Expect the activity module using $plugin in version.php instead of $module.
312                $type = 'plugin';
313                $this->versionphp['version'] = $info[$type.'->version'];
314                $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
315            } else {
316                $this->add_message(self::ERROR, 'missingversion');
317                return false;
318            }
319        } else {
320            $this->versionphp['version'] = $info[$type.'->version'];
321            $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
322        }
323
324        if (isset($info[$type.'->requires'])) {
325            $this->versionphp['requires'] = $info[$type.'->requires'];
326            if ($this->versionphp['requires'] > $this->assertions['moodleversion']) {
327                $this->add_message(self::ERROR, 'requiresmoodle', $this->versionphp['requires']);
328                return false;
329            }
330            $this->add_message(self::INFO, 'requiresmoodle', $this->versionphp['requires']);
331        }
332
333        if (isset($info[$type.'->component'])) {
334            $this->versionphp['component'] = $info[$type.'->component'];
335            list($reqtype, $reqname) = core_component::normalize_component($this->versionphp['component']);
336            if ($reqtype !== $this->assertions['plugintype']) {
337                $this->add_message(self::ERROR, 'componentmismatchtype', array(
338                    'expected' => $this->assertions['plugintype'],
339                    'found' => $reqtype));
340                return false;
341            }
342            if ($reqname !== $this->rootdir) {
343                $this->add_message(self::ERROR, 'componentmismatchname', $reqname);
344                return false;
345            }
346            $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']);
347        }
348
349        if (isset($info[$type.'->maturity'])) {
350            $this->versionphp['maturity'] = $info[$type.'->maturity'];
351            if ($this->versionphp['maturity'] === 'MATURITY_STABLE') {
352                $this->add_message(self::INFO, 'maturity', $this->versionphp['maturity']);
353            } else {
354                $this->add_message(self::WARNING, 'maturity', $this->versionphp['maturity']);
355            }
356        }
357
358        if (isset($info[$type.'->release'])) {
359            $this->versionphp['release'] = $info[$type.'->release'];
360            $this->add_message(self::INFO, 'release', $this->versionphp['release']);
361        }
362
363        return true;
364    }
365
366    /**
367     * @return bool false if the English language pack is not provided correctly
368     */
369    protected function validate_language_pack() {
370
371        if (!isset($this->assertions['plugintype'])) {
372            throw new coding_exception('Required plugin type must be set before calling this');
373        }
374
375        if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'])
376                or $this->extractfiles[$this->rootdir.'/lang/en/'] !== true
377                or !is_dir($this->extractdir.'/'.$this->rootdir.'/lang/en')) {
378            $this->add_message(self::ERROR, 'missinglangenfolder');
379            return false;
380        }
381
382        $langfiles = array();
383        foreach (array_keys($this->extractfiles) as $extractfile) {
384            $matches = array();
385            if (preg_match('#^'.preg_quote($this->rootdir).'/lang/en/([^/]+).php?$#i', $extractfile, $matches)) {
386                $langfiles[] = $matches[1];
387            }
388        }
389
390        if (empty($langfiles)) {
391            $this->add_message(self::ERROR, 'missinglangenfile');
392            return false;
393        } else if (count($langfiles) > 1) {
394            $this->add_message(self::WARNING, 'multiplelangenfiles');
395        } else {
396            $this->langfilename = $langfiles[0];
397            $this->add_message(self::DEBUG, 'foundlangfile', $this->langfilename);
398        }
399
400        if ($this->assertions['plugintype'] === 'mod') {
401            $expected = $this->rootdir.'.php';
402        } else {
403            $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php';
404        }
405
406        if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'.$expected])
407                or $this->extractfiles[$this->rootdir.'/lang/en/'.$expected] !== true
408                or !is_file($this->extractdir.'/'.$this->rootdir.'/lang/en/'.$expected)) {
409            $this->add_message(self::ERROR, 'missingexpectedlangenfile', $expected);
410            return false;
411        }
412
413        return true;
414    }
415
416
417    /**
418     * @return bool false of the given add-on can't be installed into its location
419     */
420    public function validate_target_location() {
421
422        if (!isset($this->assertions['plugintype'])) {
423            throw new coding_exception('Required plugin type must be set before calling this');
424        }
425
426        $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']);
427
428        if (is_null($plugintypepath)) {
429            $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']);
430            return false;
431        }
432
433        if (!is_dir($plugintypepath)) {
434            throw new coding_exception('Plugin type location does not exist!');
435        }
436
437        $target = $plugintypepath.'/'.$this->rootdir;
438
439        if (file_exists($target)) {
440            $this->add_message(self::ERROR, 'targetexists', $target);
441            return false;
442        }
443
444        if (is_writable($plugintypepath)) {
445            $this->add_message(self::INFO, 'pathwritable', $plugintypepath);
446        } else {
447            $this->add_message(self::ERROR, 'pathwritable', $plugintypepath);
448            return false;
449        }
450
451        return true;
452    }
453
454    // Helper methods //////////////////////////////////////////////////////////
455
456    /**
457     * Get as much information from existing version.php as possible
458     *
459     * @param string full path to the version.php file
460     * @return array of found meta-info declarations
461     */
462    protected function parse_version_php($fullpath) {
463
464        $content = $this->get_stripped_file_contents($fullpath);
465
466        preg_match_all('#\$((plugin|module)\->(version|maturity|release|requires))=()(\d+(\.\d+)?);#m', $content, $matches1);
467        preg_match_all('#\$((plugin|module)\->(maturity))=()(MATURITY_\w+);#m', $content, $matches2);
468        preg_match_all('#\$((plugin|module)\->(release))=([\'"])(.*?)\4;#m', $content, $matches3);
469        preg_match_all('#\$((plugin|module)\->(component))=([\'"])(.+?_.+?)\4;#m', $content, $matches4);
470
471        if (count($matches1[1]) + count($matches2[1]) + count($matches3[1]) + count($matches4[1])) {
472            $info = array_combine(
473                array_merge($matches1[1], $matches2[1], $matches3[1], $matches4[1]),
474                array_merge($matches1[5], $matches2[5], $matches3[5], $matches4[5])
475            );
476
477        } else {
478            $info = array();
479        }
480
481        return $info;
482    }
483
484    /**
485     * Append the given message to the messages log
486     *
487     * @param string $level e.g. self::ERROR
488     * @param string $msgcode may form a string
489     * @param string|array|object $a optional additional info suitable for {@link get_string()}
490     */
491    protected function add_message($level, $msgcode, $a = null) {
492        $msg = (object)array(
493            'level'     => $level,
494            'msgcode'   => $msgcode,
495            'addinfo'   => $a,
496        );
497        $this->messages[] = $msg;
498    }
499
500    /**
501     * Returns bare PHP code from the given file
502     *
503     * Returns contents without PHP opening and closing tags, text outside php code,
504     * comments and extra whitespaces.
505     *
506     * @param string $fullpath full path to the file
507     * @return string
508     */
509    protected function get_stripped_file_contents($fullpath) {
510
511        $source = file_get_contents($fullpath);
512        $tokens = token_get_all($source);
513        $output = '';
514        $doprocess = false;
515        foreach ($tokens as $token) {
516            if (is_string($token)) {
517                // Simple one character token.
518                $id = -1;
519                $text = $token;
520            } else {
521                // Token array.
522                list($id, $text) = $token;
523            }
524            switch ($id) {
525            case T_WHITESPACE:
526            case T_COMMENT:
527            case T_ML_COMMENT:
528            case T_DOC_COMMENT:
529                // Ignore whitespaces, inline comments, multiline comments and docblocks.
530                break;
531            case T_OPEN_TAG:
532                // Start processing.
533                $doprocess = true;
534                break;
535            case T_CLOSE_TAG:
536                // Stop processing.
537                $doprocess = false;
538                break;
539            default:
540                // Anything else is within PHP tags, return it as is.
541                if ($doprocess) {
542                    $output .= $text;
543                    if ($text === 'function') {
544                        // Explicitly keep the whitespace that would be ignored.
545                        $output .= ' ';
546                    }
547                }
548                break;
549            }
550        }
551
552        return $output;
553    }
554
555
556    /**
557     * Returns the full path to the root directory of the given plugin type
558     *
559     * @param string $plugintype
560     * @return string|null
561     */
562    public function get_plugintype_location($plugintype) {
563
564        $plugintypepath = null;
565
566        foreach (core_component::get_plugin_types() as $type => $fullpath) {
567            if ($type === $plugintype) {
568                $plugintypepath = $fullpath;
569                break;
570            }
571        }
572
573        return $plugintypepath;
574    }
575}
Note: See TracBrowser for help on using the repository browser.