MDL-48494 admin: Require plugins declare their component in version.php
[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
f5f5a60a
DM
303 if (isset($info['module->version'])) {
304 $this->add_message(self::ERROR, 'versionphpsyntax', '$module');
305 return false;
a4fcf56f
DM
306 }
307
f5f5a60a
DM
308 if (isset($info['plugin->version'])) {
309 $this->versionphp['version'] = $info['plugin->version'];
a4fcf56f 310 $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
f5f5a60a
DM
311 } else {
312 $this->add_message(self::ERROR, 'missingversion');
313 return false;
a4fcf56f
DM
314 }
315
f5f5a60a
DM
316 if (isset($info['plugin->requires'])) {
317 $this->versionphp['requires'] = $info['plugin->requires'];
a4fcf56f
DM
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 }
324
f5f5a60a
DM
325 if (isset($info['plugin->component'])) {
326 $this->versionphp['component'] = $info['plugin->component'];
56da374e 327 list($reqtype, $reqname) = core_component::normalize_component($this->versionphp['component']);
a4fcf56f
DM
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 }
340
f5f5a60a
DM
341 if (isset($info['plugin->maturity'])) {
342 $this->versionphp['maturity'] = $info['plugin->maturity'];
a4fcf56f
DM
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 }
349
f5f5a60a
DM
350 if (isset($info['plugin->release'])) {
351 $this->versionphp['release'] = $info['plugin->release'];
a4fcf56f
DM
352 $this->add_message(self::INFO, 'release', $this->versionphp['release']);
353 }
354
355 return true;
356 }
357
358 /**
359 * @return bool false if the English language pack is not provided correctly
360 */
361 protected function validate_language_pack() {
362
363 if (!isset($this->assertions['plugintype'])) {
364 throw new coding_exception('Required plugin type must be set before calling this');
365 }
366
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 }
373
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 }
381
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 }
391
392 if ($this->assertions['plugintype'] === 'mod') {
393 $expected = $this->rootdir.'.php';
394 } else {
395 $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php';
396 }
397
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 }
404
405 return true;
406 }
407
408
409 /**
410 * @return bool false of the given add-on can't be installed into its location
411 */
412 public function validate_target_location() {
413
414 if (!isset($this->assertions['plugintype'])) {
415 throw new coding_exception('Required plugin type must be set before calling this');
416 }
417
418 $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']);
419
420 if (is_null($plugintypepath)) {
421 $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']);
422 return false;
423 }
424
425 if (!is_dir($plugintypepath)) {
426 throw new coding_exception('Plugin type location does not exist!');
427 }
428
429 $target = $plugintypepath.'/'.$this->rootdir;
430
431 if (file_exists($target)) {
432 $this->add_message(self::ERROR, 'targetexists', $target);
433 return false;
434 }
435
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 }
442
443 return true;
444 }
445
446 // Helper methods //////////////////////////////////////////////////////////
447
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) {
455
456 $content = $this->get_stripped_file_contents($fullpath);
457
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);
462
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 );
468
469 } else {
470 $info = array();
471 }
472
473 return $info;
474 }
475
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 }
491
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) {
502
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 }
543
544 return $output;
545 }
546
547
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) {
555
556 $plugintypepath = null;
557
46f6f7f2 558 foreach (core_component::get_plugin_types() as $type => $fullpath) {
a4fcf56f
DM
559 if ($type === $plugintype) {
560 $plugintypepath = $fullpath;
561 break;
562 }
563 }
564
565 return $plugintypepath;
566 }
567}