PageRenderTime 39ms CodeModel.GetById 11ms RepoModel.GetById 0ms app.codeStats 0ms

/lib/classes/update/validator.php

https://bitbucket.org/moodle/moodle
PHP | 644 lines | 334 code | 96 blank | 214 comment | 57 complexity | cb4f24f2eee8565c09bfc480c49290fc MD5 | raw file
Possible License(s): Apache-2.0, LGPL-2.1, BSD-3-Clause, MIT, GPL-3.0
  1. <?php
  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. * Provides validation class to check the plugin ZIP contents
  18. *
  19. * Uses fragments of the local_plugins_archive_validator class copyrighted by
  20. * Marina Glancy that is part of the local_plugins plugin.
  21. *
  22. * @package core_plugin
  23. * @subpackage validation
  24. * @copyright 2013, 2015 David Mudrak <david@moodle.com>
  25. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  26. */
  27. namespace core\update;
  28. use core_component;
  29. use core_plugin_manager;
  30. use help_icon;
  31. use coding_exception;
  32. defined('MOODLE_INTERNAL') || die();
  33. if (!defined('T_ML_COMMENT')) {
  34. define('T_ML_COMMENT', T_COMMENT);
  35. } else {
  36. define('T_DOC_COMMENT', T_ML_COMMENT);
  37. }
  38. /**
  39. * Validates the contents of extracted plugin ZIP file
  40. *
  41. * @copyright 2013, 2015 David Mudrak <david@moodle.com>
  42. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  43. */
  44. class validator {
  45. /** Critical error message level, causes the validation fail. */
  46. const ERROR = 'error';
  47. /** Warning message level, validation does not fail but the admin should be always informed. */
  48. const WARNING = 'warning';
  49. /** Information message level that the admin should be aware of. */
  50. const INFO = 'info';
  51. /** Debugging message level, should be displayed in debugging mode only. */
  52. const DEBUG = 'debug';
  53. /** @var string full path to the extracted ZIP contents */
  54. protected $extractdir = null;
  55. /** @var array as returned by {@link zip_packer::extract_to_pathname()} */
  56. protected $extractfiles = null;
  57. /** @var bool overall result of validation */
  58. protected $result = null;
  59. /** @var string the name of the plugin root directory */
  60. protected $rootdir = null;
  61. /** @var array explicit list of expected/required characteristics of the ZIP */
  62. protected $assertions = null;
  63. /** @var array of validation log messages */
  64. protected $messages = array();
  65. /** @var array|null array of relevant data obtained from version.php */
  66. protected $versionphp = null;
  67. /** @var string|null the name of found English language file without the .php extension */
  68. protected $langfilename = null;
  69. /**
  70. * Factory method returning instance of the validator
  71. *
  72. * @param string $zipcontentpath full path to the extracted ZIP contents
  73. * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
  74. * @return \core\update\validator
  75. */
  76. public static function instance($zipcontentpath, array $zipcontentfiles) {
  77. return new static($zipcontentpath, $zipcontentfiles);
  78. }
  79. /**
  80. * Set the expected plugin type, fail the validation otherwise
  81. *
  82. * @param string $required plugin type
  83. */
  84. public function assert_plugin_type($required) {
  85. $this->assertions['plugintype'] = $required;
  86. }
  87. /**
  88. * Set the expectation that the plugin can be installed into the given Moodle version
  89. *
  90. * @param string $required Moodle version we are about to install to
  91. */
  92. public function assert_moodle_version($required) {
  93. $this->assertions['moodleversion'] = $required;
  94. }
  95. /**
  96. * Execute the validation process against all explicit and implicit requirements
  97. *
  98. * Returns true if the validation passes (all explicit and implicit requirements
  99. * pass) and the plugin can be installed. Returns false if the validation fails
  100. * (some explicit or implicit requirement fails) and the plugin must not be
  101. * installed.
  102. *
  103. * @return bool
  104. */
  105. public function execute() {
  106. $this->result = (
  107. $this->validate_files_layout()
  108. and $this->validate_version_php()
  109. and $this->validate_language_pack()
  110. and $this->validate_target_location()
  111. );
  112. return $this->result;
  113. }
  114. /**
  115. * Returns overall result of the validation.
  116. *
  117. * Null is returned if the validation has not been executed yet. Otherwise
  118. * this method returns true (the installation can continue) or false (it is not
  119. * safe to continue with the installation).
  120. *
  121. * @return bool|null
  122. */
  123. public function get_result() {
  124. return $this->result;
  125. }
  126. /**
  127. * Return the list of validation log messages
  128. *
  129. * Each validation message is a plain object with properties level, msgcode
  130. * and addinfo.
  131. *
  132. * @return array of (int)index => (stdClass) validation message
  133. */
  134. public function get_messages() {
  135. return $this->messages;
  136. }
  137. /**
  138. * Returns human readable localised name of the given log level.
  139. *
  140. * @param string $level e.g. self::INFO
  141. * @return string
  142. */
  143. public function message_level_name($level) {
  144. return get_string('validationmsglevel_'.$level, 'core_plugin');
  145. }
  146. /**
  147. * If defined, returns human readable validation code.
  148. *
  149. * Otherwise, it simply returns the code itself as a fallback.
  150. *
  151. * @param string $msgcode
  152. * @return string
  153. */
  154. public function message_code_name($msgcode) {
  155. $stringman = get_string_manager();
  156. if ($stringman->string_exists('validationmsg_'.$msgcode, 'core_plugin')) {
  157. return get_string('validationmsg_'.$msgcode, 'core_plugin');
  158. }
  159. return $msgcode;
  160. }
  161. /**
  162. * Returns help icon for the message code if defined.
  163. *
  164. * @param string $msgcode
  165. * @return \help_icon|false
  166. */
  167. public function message_help_icon($msgcode) {
  168. $stringman = get_string_manager();
  169. if ($stringman->string_exists('validationmsg_'.$msgcode.'_help', 'core_plugin')) {
  170. return new help_icon('validationmsg_'.$msgcode, 'core_plugin');
  171. }
  172. return false;
  173. }
  174. /**
  175. * Localizes the message additional info if it exists.
  176. *
  177. * @param string $msgcode
  178. * @param array|string|null $addinfo value for the $a placeholder in the string
  179. * @return string
  180. */
  181. public function message_code_info($msgcode, $addinfo) {
  182. $stringman = get_string_manager();
  183. if ($addinfo !== null and $stringman->string_exists('validationmsg_'.$msgcode.'_info', 'core_plugin')) {
  184. return get_string('validationmsg_'.$msgcode.'_info', 'core_plugin', $addinfo);
  185. }
  186. return '';
  187. }
  188. /**
  189. * Return the information provided by the the plugin's version.php
  190. *
  191. * If version.php was not found in the plugin, null is returned. Otherwise
  192. * the array is returned. It may be empty if no information was parsed
  193. * (which should not happen).
  194. *
  195. * @return null|array
  196. */
  197. public function get_versionphp_info() {
  198. return $this->versionphp;
  199. }
  200. /**
  201. * Returns the name of the English language file without the .php extension
  202. *
  203. * This can be used as a suggestion for fixing the plugin root directory in the
  204. * ZIP file during the upload. If no file was found, or multiple PHP files are
  205. * located in lang/en/ folder, then null is returned.
  206. *
  207. * @return null|string
  208. */
  209. public function get_language_file_name() {
  210. return $this->langfilename;
  211. }
  212. /**
  213. * Returns the rootdir of the extracted package (after eventual renaming)
  214. *
  215. * @return string|null
  216. */
  217. public function get_rootdir() {
  218. return $this->rootdir;
  219. }
  220. // End of external API.
  221. /**
  222. * No public constructor, use {@link self::instance()} instead.
  223. *
  224. * @param string $zipcontentpath full path to the extracted ZIP contents
  225. * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
  226. */
  227. protected function __construct($zipcontentpath, array $zipcontentfiles) {
  228. $this->extractdir = $zipcontentpath;
  229. $this->extractfiles = $zipcontentfiles;
  230. }
  231. // Validation methods.
  232. /**
  233. * Returns false if files in the ZIP do not have required layout.
  234. *
  235. * @return bool
  236. */
  237. protected function validate_files_layout() {
  238. if (!is_array($this->extractfiles) or count($this->extractfiles) < 4) {
  239. // We need the English language pack with the name of the plugin at least.
  240. $this->add_message(self::ERROR, 'filesnumber');
  241. return false;
  242. }
  243. foreach ($this->extractfiles as $filerelname => $filestatus) {
  244. if ($filestatus !== true) {
  245. $this->add_message(self::ERROR, 'filestatus', array('file' => $filerelname, 'status' => $filestatus));
  246. return false;
  247. }
  248. }
  249. foreach (array_keys($this->extractfiles) as $filerelname) {
  250. if (!file_exists($this->extractdir.'/'.$filerelname)) {
  251. $this->add_message(self::ERROR, 'filenotexists', array('file' => $filerelname));
  252. return false;
  253. }
  254. }
  255. foreach (array_keys($this->extractfiles) as $filerelname) {
  256. $matches = array();
  257. if (!preg_match("#^([^/]+)/#", $filerelname, $matches)
  258. or (!is_null($this->rootdir) and $this->rootdir !== $matches[1])) {
  259. $this->add_message(self::ERROR, 'onedir');
  260. return false;
  261. }
  262. $this->rootdir = $matches[1];
  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. return is_dir($this->extractdir.'/'.$this->rootdir);
  271. }
  272. /**
  273. * Returns false if the version.php file does not declare required information.
  274. *
  275. * @return bool
  276. */
  277. protected function validate_version_php() {
  278. if (!isset($this->assertions['plugintype'])) {
  279. throw new coding_exception('Required plugin type must be set before calling this');
  280. }
  281. if (!isset($this->assertions['moodleversion'])) {
  282. throw new coding_exception('Required Moodle version must be set before calling this');
  283. }
  284. $fullpath = $this->extractdir.'/'.$this->rootdir.'/version.php';
  285. if (!file_exists($fullpath)) {
  286. // This is tolerated for themes only.
  287. if ($this->assertions['plugintype'] === 'theme') {
  288. $this->add_message(self::DEBUG, 'missingversionphp');
  289. return true;
  290. } else {
  291. $this->add_message(self::ERROR, 'missingversionphp');
  292. return false;
  293. }
  294. }
  295. $this->versionphp = array();
  296. $info = $this->parse_version_php($fullpath);
  297. if (isset($info['module->version'])) {
  298. $this->add_message(self::ERROR, 'versionphpsyntax', '$module');
  299. return false;
  300. }
  301. if (isset($info['plugin->version'])) {
  302. $this->versionphp['version'] = $info['plugin->version'];
  303. $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
  304. } else {
  305. $this->add_message(self::ERROR, 'missingversion');
  306. return false;
  307. }
  308. if (isset($info['plugin->requires'])) {
  309. $this->versionphp['requires'] = $info['plugin->requires'];
  310. if ($this->versionphp['requires'] > $this->assertions['moodleversion']) {
  311. $this->add_message(self::ERROR, 'requiresmoodle', $this->versionphp['requires']);
  312. return false;
  313. }
  314. $this->add_message(self::INFO, 'requiresmoodle', $this->versionphp['requires']);
  315. }
  316. if (!isset($info['plugin->component'])) {
  317. $this->add_message(self::ERROR, 'missingcomponent');
  318. return false;
  319. }
  320. $this->versionphp['component'] = $info['plugin->component'];
  321. list($reqtype, $reqname) = core_component::normalize_component($this->versionphp['component']);
  322. if ($reqtype !== $this->assertions['plugintype']) {
  323. $this->add_message(self::ERROR, 'componentmismatchtype', array(
  324. 'expected' => $this->assertions['plugintype'],
  325. 'found' => $reqtype));
  326. return false;
  327. }
  328. if ($reqname !== $this->rootdir) {
  329. $this->add_message(self::ERROR, 'componentmismatchname', $reqname);
  330. return false;
  331. }
  332. $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']);
  333. // Ensure the version we are uploading is higher than the version currently installed.
  334. $plugininfo = $this->get_plugin_manager()->get_plugin_info($this->versionphp['component']);
  335. if (!is_null($plugininfo) && $this->versionphp['version'] < $plugininfo->versiondb) {
  336. $this->add_message(self::ERROR, 'pluginversiontoolow', $plugininfo->versiondb);
  337. return false;
  338. }
  339. if (isset($info['plugin->maturity'])) {
  340. $this->versionphp['maturity'] = $info['plugin->maturity'];
  341. if ($this->versionphp['maturity'] === 'MATURITY_STABLE') {
  342. $this->add_message(self::INFO, 'maturity', $this->versionphp['maturity']);
  343. } else {
  344. $this->add_message(self::WARNING, 'maturity', $this->versionphp['maturity']);
  345. }
  346. }
  347. if (isset($info['plugin->release'])) {
  348. $this->versionphp['release'] = $info['plugin->release'];
  349. $this->add_message(self::INFO, 'release', $this->versionphp['release']);
  350. }
  351. return true;
  352. }
  353. /**
  354. * Returns false if the English language pack is not provided correctly.
  355. *
  356. * @return bool
  357. */
  358. protected function validate_language_pack() {
  359. if (!isset($this->assertions['plugintype'])) {
  360. throw new coding_exception('Required plugin type must be set before calling this');
  361. }
  362. if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'])
  363. or $this->extractfiles[$this->rootdir.'/lang/en/'] !== true
  364. or !is_dir($this->extractdir.'/'.$this->rootdir.'/lang/en')) {
  365. $this->add_message(self::ERROR, 'missinglangenfolder');
  366. return false;
  367. }
  368. $langfiles = array();
  369. foreach (array_keys($this->extractfiles) as $extractfile) {
  370. $matches = array();
  371. if (preg_match('#^'.preg_quote($this->rootdir).'/lang/en/([^/]+).php?$#i', $extractfile, $matches)) {
  372. $langfiles[] = $matches[1];
  373. }
  374. }
  375. if (empty($langfiles)) {
  376. $this->add_message(self::ERROR, 'missinglangenfile');
  377. return false;
  378. } else if (count($langfiles) > 1) {
  379. $this->add_message(self::WARNING, 'multiplelangenfiles');
  380. } else {
  381. $this->langfilename = $langfiles[0];
  382. $this->add_message(self::DEBUG, 'foundlangfile', $this->langfilename);
  383. }
  384. if ($this->assertions['plugintype'] === 'mod') {
  385. $expected = $this->rootdir.'.php';
  386. } else {
  387. $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php';
  388. }
  389. if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'.$expected])
  390. or $this->extractfiles[$this->rootdir.'/lang/en/'.$expected] !== true
  391. or !is_file($this->extractdir.'/'.$this->rootdir.'/lang/en/'.$expected)) {
  392. $this->add_message(self::ERROR, 'missingexpectedlangenfile', $expected);
  393. return false;
  394. }
  395. return true;
  396. }
  397. /**
  398. * Returns false of the given add-on can't be installed into its location.
  399. *
  400. * @return bool
  401. */
  402. public function validate_target_location() {
  403. if (!isset($this->assertions['plugintype'])) {
  404. throw new coding_exception('Required plugin type must be set before calling this');
  405. }
  406. $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']);
  407. if (is_null($plugintypepath)) {
  408. $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']);
  409. return false;
  410. }
  411. if (!is_dir($plugintypepath)) {
  412. throw new coding_exception('Plugin type location does not exist!');
  413. }
  414. // Always check that the plugintype root is writable.
  415. if (!is_writable($plugintypepath)) {
  416. $this->add_message(self::ERROR, 'pathwritable', $plugintypepath);
  417. return false;
  418. } else {
  419. $this->add_message(self::INFO, 'pathwritable', $plugintypepath);
  420. }
  421. // The target location itself may or may not exist. Even if installing an
  422. // available update, the code could have been removed by accident (and
  423. // be reported as missing) etc. So we just make sure that the code
  424. // can be replaced if it already exists.
  425. $target = $plugintypepath.'/'.$this->rootdir;
  426. if (file_exists($target)) {
  427. if (!is_dir($target)) {
  428. $this->add_message(self::ERROR, 'targetnotdir', $target);
  429. return false;
  430. }
  431. $this->add_message(self::WARNING, 'targetexists', $target);
  432. if ($this->get_plugin_manager()->is_directory_removable($target)) {
  433. $this->add_message(self::INFO, 'pathwritable', $target);
  434. } else {
  435. $this->add_message(self::ERROR, 'pathwritable', $target);
  436. return false;
  437. }
  438. }
  439. return true;
  440. }
  441. // Helper methods.
  442. /**
  443. * Get as much information from existing version.php as possible
  444. *
  445. * @param string $fullpath full path to the version.php file
  446. * @return array of found meta-info declarations
  447. */
  448. protected function parse_version_php($fullpath) {
  449. $content = $this->get_stripped_file_contents($fullpath);
  450. preg_match_all('#\$((plugin|module)\->(version|maturity|release|requires))=()(\d+(\.\d+)?);#m', $content, $matches1);
  451. preg_match_all('#\$((plugin|module)\->(maturity))=()(MATURITY_\w+);#m', $content, $matches2);
  452. preg_match_all('#\$((plugin|module)\->(release))=([\'"])(.*?)\4;#m', $content, $matches3);
  453. preg_match_all('#\$((plugin|module)\->(component))=([\'"])(.+?_.+?)\4;#m', $content, $matches4);
  454. if (count($matches1[1]) + count($matches2[1]) + count($matches3[1]) + count($matches4[1])) {
  455. $info = array_combine(
  456. array_merge($matches1[1], $matches2[1], $matches3[1], $matches4[1]),
  457. array_merge($matches1[5], $matches2[5], $matches3[5], $matches4[5])
  458. );
  459. } else {
  460. $info = array();
  461. }
  462. return $info;
  463. }
  464. /**
  465. * Append the given message to the messages log
  466. *
  467. * @param string $level e.g. self::ERROR
  468. * @param string $msgcode may form a string
  469. * @param string|array|object $a optional additional info suitable for {@link get_string()}
  470. */
  471. protected function add_message($level, $msgcode, $a = null) {
  472. $msg = (object)array(
  473. 'level' => $level,
  474. 'msgcode' => $msgcode,
  475. 'addinfo' => $a,
  476. );
  477. $this->messages[] = $msg;
  478. }
  479. /**
  480. * Returns bare PHP code from the given file
  481. *
  482. * Returns contents without PHP opening and closing tags, text outside php code,
  483. * comments and extra whitespaces.
  484. *
  485. * @param string $fullpath full path to the file
  486. * @return string
  487. */
  488. protected function get_stripped_file_contents($fullpath) {
  489. $source = file_get_contents($fullpath);
  490. $tokens = token_get_all($source);
  491. $output = '';
  492. $doprocess = false;
  493. foreach ($tokens as $token) {
  494. if (is_string($token)) {
  495. // Simple one character token.
  496. $id = -1;
  497. $text = $token;
  498. } else {
  499. // Token array.
  500. list($id, $text) = $token;
  501. }
  502. switch ($id) {
  503. case T_WHITESPACE:
  504. case T_COMMENT:
  505. case T_ML_COMMENT:
  506. case T_DOC_COMMENT:
  507. // Ignore whitespaces, inline comments, multiline comments and docblocks.
  508. break;
  509. case T_OPEN_TAG:
  510. // Start processing.
  511. $doprocess = true;
  512. break;
  513. case T_CLOSE_TAG:
  514. // Stop processing.
  515. $doprocess = false;
  516. break;
  517. default:
  518. // Anything else is within PHP tags, return it as is.
  519. if ($doprocess) {
  520. $output .= $text;
  521. if ($text === 'function') {
  522. // Explicitly keep the whitespace that would be ignored.
  523. $output .= ' ';
  524. }
  525. }
  526. break;
  527. }
  528. }
  529. return $output;
  530. }
  531. /**
  532. * Returns the full path to the root directory of the given plugin type.
  533. *
  534. * @param string $plugintype
  535. * @return string|null
  536. */
  537. public function get_plugintype_location($plugintype) {
  538. return $this->get_plugin_manager()->get_plugintype_root($plugintype);
  539. }
  540. /**
  541. * Returns plugin manager to use.
  542. *
  543. * @return core_plugin_manager
  544. */
  545. protected function get_plugin_manager() {
  546. return core_plugin_manager::instance();
  547. }
  548. }