MDL-49329 admin: Improve the plugin package validator
[moodle.git] / lib / classes / update / validator.php
CommitLineData
a4fcf56f 1<?php
a4fcf56f
DM
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/>.
16
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 *
f2d8ed45
DM
23 * @package core_plugin
24 * @subpackage validation
25 * @copyright 2013, 2015 David Mudrak <david@moodle.com>
a4fcf56f
DM
26 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27 */
28
f2d8ed45
DM
29namespace core\update;
30
31use core_component;
8acee4b5 32use core_plugin_manager;
f2d8ed45
DM
33use help_icon;
34use coding_exception;
35
a4fcf56f
DM
36defined('MOODLE_INTERNAL') || die();
37
38if (!defined('T_ML_COMMENT')) {
39 define('T_ML_COMMENT', T_COMMENT);
40} else {
41 define('T_DOC_COMMENT', T_ML_COMMENT);
42}
43
44/**
45 * Validates the contents of extracted plugin ZIP file
46 *
f2d8ed45 47 * @copyright 2013, 2015 David Mudrak <david@moodle.com>
a4fcf56f
DM
48 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
49 */
f2d8ed45 50class validator {
a4fcf56f
DM
51
52 /** Critical error message level, causes the validation fail. */
53 const ERROR = 'error';
54
55 /** Warning message level, validation does not fail but the admin should be always informed. */
56 const WARNING = 'warning';
57
58 /** Information message level that the admin should be aware of. */
59 const INFO = 'info';
60
61 /** Debugging message level, should be displayed in debugging mode only. */
62 const DEBUG = 'debug';
63
64 /** @var string full path to the extracted ZIP contents */
65 protected $extractdir = null;
66
67 /** @var array as returned by {@link zip_packer::extract_to_pathname()} */
68 protected $extractfiles = null;
69
70 /** @var bool overall result of validation */
71 protected $result = null;
72
73 /** @var string the name of the plugin root directory */
74 protected $rootdir = null;
75
76 /** @var array explicit list of expected/required characteristics of the ZIP */
77 protected $assertions = null;
78
79 /** @var array of validation log messages */
80 protected $messages = array();
81
82 /** @var array|null array of relevant data obtained from version.php */
83 protected $versionphp = null;
84
85 /** @var string|null the name of found English language file without the .php extension */
86 protected $langfilename = null;
87
a4fcf56f
DM
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
f2d8ed45 93 * @return \core\update\validator
a4fcf56f
DM
94 */
95 public static function instance($zipcontentpath, array $zipcontentfiles) {
96 return new static($zipcontentpath, $zipcontentfiles);
97 }
98
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 }
107
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 }
116
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() {
128
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 );
135
136 return $this->result;
137 }
138
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 }
151
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 }
163
f2d8ed45
DM
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 }
173
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) {
183
184 $stringman = get_string_manager();
185
186 if ($stringman->string_exists('validationmsg_'.$msgcode, 'core_plugin')) {
187 return get_string('validationmsg_'.$msgcode, 'core_plugin');
188 }
189
190 return $msgcode;
191 }
192
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) {
200
201 $stringman = get_string_manager();
202
203 if ($stringman->string_exists('validationmsg_'.$msgcode.'_help', 'core_plugin')) {
204 return new help_icon('validationmsg_'.$msgcode, 'core_plugin');
205 }
206
207 return false;
208 }
209
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) {
218
219 $stringman = get_string_manager();
220
221 if ($addinfo !== null and $stringman->string_exists('validationmsg_'.$msgcode.'_info', 'core_plugin')) {
222 return get_string('validationmsg_'.$msgcode.'_info', 'core_plugin', $addinfo);
223 }
224
225 return '';
226 }
227
a4fcf56f
DM
228 /**
229 * Return the information provided by the the plugin's version.php
230 *
f2d8ed45
DM
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).
a4fcf56f
DM
234 *
235 * @return null|array
236 */
237 public function get_versionphp_info() {
238 return $this->versionphp;
239 }
240
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 }
253
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 }
262
f2d8ed45 263 // End of external API.
a4fcf56f
DM
264
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 }
273
f2d8ed45 274 // Validation methods.
a4fcf56f
DM
275
276 /**
277 * @return bool false if files in the ZIP do not have required layout
278 */
279 protected function validate_files_layout() {
280
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 }
286
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 }
293
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 }
300
301 foreach (array_keys($this->extractfiles) as $filerelname) {
302 $matches = array();
f2d8ed45
DM
303 if (!preg_match("#^([^/]+)/#", $filerelname, $matches)
304 or (!is_null($this->rootdir) and $this->rootdir !== $matches[1])) {
a4fcf56f
DM
305 $this->add_message(self::ERROR, 'onedir');
306 return false;
307 }
308 $this->rootdir = $matches[1];
309 }
310
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 }
317
318 return is_dir($this->extractdir.'/'.$this->rootdir);
319 }
320
321 /**
322 * @return bool false if the version.php file does not declare required information
323 */
324 protected function validate_version_php() {
325
326 if (!isset($this->assertions['plugintype'])) {
327 throw new coding_exception('Required plugin type must be set before calling this');
328 }
329
330 if (!isset($this->assertions['moodleversion'])) {
331 throw new coding_exception('Required Moodle version must be set before calling this');
332 }
333
334 $fullpath = $this->extractdir.'/'.$this->rootdir.'/version.php';
335
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 }
346
347 $this->versionphp = array();
348 $info = $this->parse_version_php($fullpath);
349
f5f5a60a
DM
350 if (isset($info['module->version'])) {
351 $this->add_message(self::ERROR, 'versionphpsyntax', '$module');
352 return false;
a4fcf56f
DM
353 }
354
f5f5a60a
DM
355 if (isset($info['plugin->version'])) {
356 $this->versionphp['version'] = $info['plugin->version'];
a4fcf56f 357 $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
f5f5a60a
DM
358 } else {
359 $this->add_message(self::ERROR, 'missingversion');
360 return false;
a4fcf56f
DM
361 }
362
f5f5a60a
DM
363 if (isset($info['plugin->requires'])) {
364 $this->versionphp['requires'] = $info['plugin->requires'];
a4fcf56f
DM
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 }
371
033761fe
DM
372 if (!isset($info['plugin->component'])) {
373 $this->add_message(self::ERROR, 'missingcomponent');
374 return false;
375 }
376
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;
a4fcf56f 388 }
033761fe 389 $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']);
a4fcf56f 390
f5f5a60a
DM
391 if (isset($info['plugin->maturity'])) {
392 $this->versionphp['maturity'] = $info['plugin->maturity'];
a4fcf56f
DM
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 }
399
f5f5a60a
DM
400 if (isset($info['plugin->release'])) {
401 $this->versionphp['release'] = $info['plugin->release'];
a4fcf56f
DM
402 $this->add_message(self::INFO, 'release', $this->versionphp['release']);
403 }
404
405 return true;
406 }
407
408 /**
409 * @return bool false if the English language pack is not provided correctly
410 */
411 protected function validate_language_pack() {
412
413 if (!isset($this->assertions['plugintype'])) {
414 throw new coding_exception('Required plugin type must be set before calling this');
415 }
416
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 }
423
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 }
431
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 }
441
442 if ($this->assertions['plugintype'] === 'mod') {
443 $expected = $this->rootdir.'.php';
444 } else {
445 $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php';
446 }
447
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 }
454
455 return true;
456 }
457
a4fcf56f
DM
458 /**
459 * @return bool false of the given add-on can't be installed into its location
460 */
461 public function validate_target_location() {
462
463 if (!isset($this->assertions['plugintype'])) {
464 throw new coding_exception('Required plugin type must be set before calling this');
465 }
466
467 $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']);
468
469 if (is_null($plugintypepath)) {
470 $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']);
471 return false;
472 }
473
474 if (!is_dir($plugintypepath)) {
475 throw new coding_exception('Plugin type location does not exist!');
476 }
477
8acee4b5
DM
478 // Always check that the plugintype root is writable.
479 if (!is_writable($plugintypepath)) {
480 $this->add_message(self::ERROR, 'pathwritable', $plugintypepath);
a4fcf56f 481 return false;
8acee4b5
DM
482 } else {
483 $this->add_message(self::INFO, 'pathwritable', $plugintypepath);
a4fcf56f
DM
484 }
485
8acee4b5
DM
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 }
a4fcf56f
DM
503 }
504
505 return true;
506 }
507
f2d8ed45 508 // Helper methods.
a4fcf56f
DM
509
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) {
517
518 $content = $this->get_stripped_file_contents($fullpath);
519
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);
524
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 );
530
531 } else {
532 $info = array();
533 }
534
535 return $info;
536 }
537
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 }
553
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) {
564
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 }
605
606 return $output;
607 }
608
a4fcf56f
DM
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) {
8acee4b5
DM
616 return $this->get_plugin_manager()->get_plugintype_root($plugintype);
617 }
a4fcf56f 618
8acee4b5
DM
619 /**
620 * @return core_plugin_manager
621 */
622 protected function get_plugin_manager() {
623 return core_plugin_manager::instance();
a4fcf56f
DM
624 }
625}