MDL-38509 Implement the plugin ZIP package validator
[moodle.git] / admin / tool / installaddon / classes / validator.php
CommitLineData
a4fcf56f
DM
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) = 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 (get_plugin_types() as $type => $fullpath) {
567 if ($type === $plugintype) {
568 $plugintypepath = $fullpath;
569 break;
570 }
571 }
572
573 return $plugintypepath;
574 }
575}