Advisor.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. <?php
  2. /* vim: set expandtab sw=4 ts=4 sts=4: */
  3. /**
  4. * A simple rules engine, that parses and executes the rules in advisory_rules.txt.
  5. * Adjusted to phpMyAdmin.
  6. *
  7. * @package PhpMyAdmin
  8. */
  9. namespace PhpMyAdmin;
  10. use Exception;
  11. use PhpMyAdmin\Core;
  12. use PhpMyAdmin\DatabaseInterface;
  13. use PhpMyAdmin\SysInfo;
  14. use PhpMyAdmin\Url;
  15. use PhpMyAdmin\Util;
  16. use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
  17. /**
  18. * Advisor class
  19. *
  20. * @package PhpMyAdmin
  21. */
  22. class Advisor
  23. {
  24. const GENERIC_RULES_FILE = 'libraries/advisory_rules_generic.txt';
  25. const BEFORE_MYSQL80003_RULES_FILE = 'libraries/advisory_rules_mysql_before80003.txt';
  26. protected $dbi;
  27. protected $variables;
  28. protected $globals;
  29. protected $parseResult;
  30. protected $runResult;
  31. protected $expression;
  32. /**
  33. * Constructor
  34. *
  35. * @param DatabaseInterface $dbi DatabaseInterface object
  36. * @param ExpressionLanguage $expression ExpressionLanguage object
  37. */
  38. public function __construct(DatabaseInterface $dbi, ExpressionLanguage $expression)
  39. {
  40. $this->dbi = $dbi;
  41. $this->expression = $expression;
  42. /*
  43. * Register functions for ExpressionLanguage, we intentionally
  44. * do not implement support for compile as we do not use it.
  45. */
  46. $this->expression->register(
  47. 'round',
  48. function (){},
  49. function ($arguments, $num) {
  50. return round($num);
  51. }
  52. );
  53. $this->expression->register(
  54. 'substr',
  55. function (){},
  56. function ($arguments, $string, $start, $length) {
  57. return substr($string, $start, $length);
  58. }
  59. );
  60. $this->expression->register(
  61. 'preg_match',
  62. function (){},
  63. function ($arguments, $pattern , $subject) {
  64. return preg_match($pattern, $subject);
  65. }
  66. );
  67. $this->expression->register(
  68. 'ADVISOR_bytime',
  69. function (){},
  70. function ($arguments, $num, $precision) {
  71. return self::byTime($num, $precision);
  72. }
  73. );
  74. $this->expression->register(
  75. 'ADVISOR_timespanFormat',
  76. function (){},
  77. function ($arguments, $seconds) {
  78. return self::timespanFormat($seconds);
  79. }
  80. );
  81. $this->expression->register(
  82. 'ADVISOR_formatByteDown',
  83. function (){},
  84. function ($arguments, $value, $limes = 6, $comma = 0) {
  85. return self::formatByteDown($value, $limes, $comma);
  86. }
  87. );
  88. $this->expression->register(
  89. 'fired',
  90. function (){},
  91. function ($arguments, $value) {
  92. if (!isset($this->runResult['fired'])) {
  93. return 0;
  94. }
  95. // Did matching rule fire?
  96. foreach ($this->runResult['fired'] as $rule) {
  97. if ($rule['id'] == $value) {
  98. return '1';
  99. }
  100. }
  101. return '0';
  102. }
  103. );
  104. /* Some global variables for advisor */
  105. $this->globals = array(
  106. 'PMA_MYSQL_INT_VERSION' => $this->dbi->getVersion(),
  107. );
  108. }
  109. /**
  110. * Get variables
  111. *
  112. * @return mixed
  113. */
  114. public function getVariables()
  115. {
  116. return $this->variables;
  117. }
  118. /**
  119. * Set variables
  120. *
  121. * @param array $variables Variables
  122. *
  123. * @return Advisor
  124. */
  125. public function setVariables(array $variables)
  126. {
  127. $this->variables = $variables;
  128. return $this;
  129. }
  130. /**
  131. * Set a variable and its value
  132. *
  133. * @param string|int $variable Variable to set
  134. * @param mixed $value Value to set
  135. *
  136. * @return $this
  137. */
  138. public function setVariable($variable, $value)
  139. {
  140. $this->variables[$variable] = $value;
  141. return $this;
  142. }
  143. /**
  144. * Get parseResult
  145. *
  146. * @return mixed
  147. */
  148. public function getParseResult()
  149. {
  150. return $this->parseResult;
  151. }
  152. /**
  153. * Set parseResult
  154. *
  155. * @param array $parseResult Parse result
  156. *
  157. * @return Advisor
  158. */
  159. public function setParseResult(array $parseResult)
  160. {
  161. $this->parseResult = $parseResult;
  162. return $this;
  163. }
  164. /**
  165. * Get runResult
  166. *
  167. * @return mixed
  168. */
  169. public function getRunResult()
  170. {
  171. return $this->runResult;
  172. }
  173. /**
  174. * Set runResult
  175. *
  176. * @param array $runResult Run result
  177. *
  178. * @return Advisor
  179. */
  180. public function setRunResult(array $runResult)
  181. {
  182. $this->runResult = $runResult;
  183. return $this;
  184. }
  185. /**
  186. * Parses and executes advisor rules
  187. *
  188. * @return array with run and parse results
  189. */
  190. public function run()
  191. {
  192. // HowTo: A simple Advisory system in 3 easy steps.
  193. // Step 1: Get some variables to evaluate on
  194. $this->setVariables(
  195. array_merge(
  196. $this->dbi->fetchResult('SHOW GLOBAL STATUS', 0, 1),
  197. $this->dbi->fetchResult('SHOW GLOBAL VARIABLES', 0, 1)
  198. )
  199. );
  200. // Add total memory to variables as well
  201. $sysinfo = SysInfo::get();
  202. $memory = $sysinfo->memory();
  203. $this->variables['system_memory']
  204. = isset($memory['MemTotal']) ? $memory['MemTotal'] : 0;
  205. $ruleFiles = $this->defineRulesFiles();
  206. // Step 2: Read and parse the list of rules
  207. $parsedResults = [];
  208. foreach ($ruleFiles as $ruleFile) {
  209. $parsedResults[] = $this->parseRulesFile($ruleFile);
  210. }
  211. $this->setParseResult(call_user_func_array('array_merge_recursive', $parsedResults));
  212. // Step 3: Feed the variables to the rules and let them fire. Sets
  213. // $runResult
  214. $this->runRules();
  215. return array(
  216. 'parse' => array('errors' => $this->parseResult['errors']),
  217. 'run' => $this->runResult
  218. );
  219. }
  220. /**
  221. * Stores current error in run results.
  222. *
  223. * @param string $description description of an error.
  224. * @param Exception $exception exception raised
  225. *
  226. * @return void
  227. */
  228. public function storeError($description, $exception)
  229. {
  230. $this->runResult['errors'][] = $description
  231. . ' '
  232. . sprintf(
  233. __('Error when evaluating: %s'),
  234. $exception->getMessage()
  235. );
  236. }
  237. /**
  238. * Executes advisor rules
  239. *
  240. * @return boolean
  241. */
  242. public function runRules()
  243. {
  244. $this->setRunResult(
  245. array(
  246. 'fired' => array(),
  247. 'notfired' => array(),
  248. 'unchecked' => array(),
  249. 'errors' => array(),
  250. )
  251. );
  252. foreach ($this->parseResult['rules'] as $rule) {
  253. $this->variables['value'] = 0;
  254. $precond = true;
  255. if (isset($rule['precondition'])) {
  256. try {
  257. $precond = $this->ruleExprEvaluate($rule['precondition']);
  258. } catch (Exception $e) {
  259. $this->storeError(
  260. sprintf(
  261. __('Failed evaluating precondition for rule \'%s\'.'),
  262. $rule['name']
  263. ),
  264. $e
  265. );
  266. continue;
  267. }
  268. }
  269. if (! $precond) {
  270. $this->addRule('unchecked', $rule);
  271. } else {
  272. try {
  273. $value = $this->ruleExprEvaluate($rule['formula']);
  274. } catch (Exception $e) {
  275. $this->storeError(
  276. sprintf(
  277. __('Failed calculating value for rule \'%s\'.'),
  278. $rule['name']
  279. ),
  280. $e
  281. );
  282. continue;
  283. }
  284. $this->variables['value'] = $value;
  285. try {
  286. if ($this->ruleExprEvaluate($rule['test'])) {
  287. $this->addRule('fired', $rule);
  288. } else {
  289. $this->addRule('notfired', $rule);
  290. }
  291. } catch (Exception $e) {
  292. $this->storeError(
  293. sprintf(
  294. __('Failed running test for rule \'%s\'.'),
  295. $rule['name']
  296. ),
  297. $e
  298. );
  299. }
  300. }
  301. }
  302. return true;
  303. }
  304. /**
  305. * Escapes percent string to be used in format string.
  306. *
  307. * @param string $str string to escape
  308. *
  309. * @return string
  310. */
  311. public static function escapePercent($str)
  312. {
  313. return preg_replace('/%( |,|\.|$|\(|\)|<|>)/', '%%\1', $str);
  314. }
  315. /**
  316. * Wrapper function for translating.
  317. *
  318. * @param string $str the string
  319. * @param string $param the parameters
  320. *
  321. * @return string
  322. */
  323. public function translate($str, $param = null)
  324. {
  325. $string = _gettext(self::escapePercent($str));
  326. if (! is_null($param)) {
  327. $params = $this->ruleExprEvaluate('[' . $param . ']');
  328. } else {
  329. $params = array();
  330. }
  331. return vsprintf($string, $params);
  332. }
  333. /**
  334. * Splits justification to text and formula.
  335. *
  336. * @param array $rule the rule
  337. *
  338. * @return string[]
  339. */
  340. public static function splitJustification(array $rule)
  341. {
  342. $jst = preg_split('/\s*\|\s*/', $rule['justification'], 2);
  343. if (count($jst) > 1) {
  344. return array($jst[0], $jst[1]);
  345. }
  346. return array($rule['justification']);
  347. }
  348. /**
  349. * Adds a rule to the result list
  350. *
  351. * @param string $type type of rule
  352. * @param array $rule rule itself
  353. *
  354. * @return void
  355. */
  356. public function addRule($type, array $rule)
  357. {
  358. switch ($type) {
  359. case 'notfired':
  360. case 'fired':
  361. $jst = self::splitJustification($rule);
  362. if (count($jst) > 1) {
  363. try {
  364. /* Translate */
  365. $str = $this->translate($jst[0], $jst[1]);
  366. } catch (Exception $e) {
  367. $this->storeError(
  368. sprintf(
  369. __('Failed formatting string for rule \'%s\'.'),
  370. $rule['name']
  371. ),
  372. $e
  373. );
  374. return;
  375. }
  376. $rule['justification'] = $str;
  377. } else {
  378. $rule['justification'] = $this->translate($rule['justification']);
  379. }
  380. $rule['id'] = $rule['name'];
  381. $rule['name'] = $this->translate($rule['name']);
  382. $rule['issue'] = $this->translate($rule['issue']);
  383. // Replaces {server_variable} with 'server_variable'
  384. // linking to server_variables.php
  385. $rule['recommendation'] = preg_replace_callback(
  386. '/\{([a-z_0-9]+)\}/Ui',
  387. array($this, 'replaceVariable'),
  388. $this->translate($rule['recommendation'])
  389. );
  390. // Replaces external Links with Core::linkURL() generated links
  391. $rule['recommendation'] = preg_replace_callback(
  392. '#href=("|\')(https?://[^\1]+)\1#i',
  393. array($this, 'replaceLinkURL'),
  394. $rule['recommendation']
  395. );
  396. break;
  397. }
  398. $this->runResult[$type][] = $rule;
  399. }
  400. /**
  401. * Defines the rules files to use
  402. *
  403. * @return array
  404. */
  405. protected function defineRulesFiles()
  406. {
  407. $isMariaDB = false !== strpos($this->getVariables()['version'], 'MariaDB');
  408. $ruleFiles = [self::GENERIC_RULES_FILE];
  409. // If MariaDB (= not MySQL) OR MYSQL < 8.0.3, add another rules file.
  410. if ($isMariaDB || $this->globals['PMA_MYSQL_INT_VERSION'] < 80003) {
  411. $ruleFiles[] = self::BEFORE_MYSQL80003_RULES_FILE;
  412. }
  413. return $ruleFiles;
  414. }
  415. /**
  416. * Callback for wrapping links with Core::linkURL
  417. *
  418. * @param array $matches List of matched elements form preg_replace_callback
  419. *
  420. * @return string Replacement value
  421. */
  422. private function replaceLinkURL(array $matches)
  423. {
  424. return 'href="' . Core::linkURL($matches[2]) . '" target="_blank" rel="noopener noreferrer"';
  425. }
  426. /**
  427. * Callback for wrapping variable edit links
  428. *
  429. * @param array $matches List of matched elements form preg_replace_callback
  430. *
  431. * @return string Replacement value
  432. */
  433. private function replaceVariable(array $matches)
  434. {
  435. return '<a href="server_variables.php' . Url::getCommon(array('filter' => $matches[1]))
  436. . '">' . htmlspecialchars($matches[1]) . '</a>';
  437. }
  438. /**
  439. * Runs a code expression, replacing variable names with their respective
  440. * values
  441. *
  442. * @param string $expr expression to evaluate
  443. *
  444. * @return integer result of evaluated expression
  445. *
  446. * @throws Exception
  447. */
  448. public function ruleExprEvaluate($expr)
  449. {
  450. // Actually evaluate the code
  451. // This can throw exception
  452. $value = $this->expression->evaluate(
  453. $expr,
  454. array_merge($this->variables, $this->globals)
  455. );
  456. return $value;
  457. }
  458. /**
  459. * Reads the rule file into an array, throwing errors messages on syntax
  460. * errors.
  461. *
  462. * @param string $filename Name of file to parse
  463. *
  464. * @return array with parsed data
  465. */
  466. public static function parseRulesFile($filename)
  467. {
  468. $file = file($filename, FILE_IGNORE_NEW_LINES);
  469. $errors = array();
  470. $rules = array();
  471. $lines = array();
  472. if ($file === false) {
  473. $errors[] = sprintf(
  474. __('Error in reading file: The file \'%s\' does not exist or is not readable!'),
  475. $filename
  476. );
  477. return array('rules' => $rules, 'lines' => $lines, 'errors' => $errors);
  478. }
  479. $ruleSyntax = array(
  480. 'name', 'formula', 'test', 'issue', 'recommendation', 'justification'
  481. );
  482. $numRules = count($ruleSyntax);
  483. $numLines = count($file);
  484. $ruleNo = -1;
  485. $ruleLine = -1;
  486. for ($i = 0; $i < $numLines; $i++) {
  487. $line = $file[$i];
  488. if ($line == "" || $line[0] == '#') {
  489. continue;
  490. }
  491. // Reading new rule
  492. if (substr($line, 0, 4) == 'rule') {
  493. if ($ruleLine > 0) {
  494. $errors[] = sprintf(
  495. __(
  496. 'Invalid rule declaration on line %1$s, expected line '
  497. . '%2$s of previous rule.'
  498. ),
  499. $i + 1,
  500. $ruleSyntax[$ruleLine++]
  501. );
  502. continue;
  503. }
  504. if (preg_match("/rule\s'(.*)'( \[(.*)\])?$/", $line, $match)) {
  505. $ruleLine = 1;
  506. $ruleNo++;
  507. $rules[$ruleNo] = array('name' => $match[1]);
  508. $lines[$ruleNo] = array('name' => $i + 1);
  509. if (isset($match[3])) {
  510. $rules[$ruleNo]['precondition'] = $match[3];
  511. $lines[$ruleNo]['precondition'] = $i + 1;
  512. }
  513. } else {
  514. $errors[] = sprintf(
  515. __('Invalid rule declaration on line %s.'),
  516. $i + 1
  517. );
  518. }
  519. continue;
  520. } else {
  521. if ($ruleLine == -1) {
  522. $errors[] = sprintf(
  523. __('Unexpected characters on line %s.'),
  524. $i + 1
  525. );
  526. }
  527. }
  528. // Reading rule lines
  529. if ($ruleLine > 0) {
  530. if (!isset($line[0])) {
  531. continue; // Empty lines are ok
  532. }
  533. // Non tabbed lines are not
  534. if ($line[0] != "\t") {
  535. $errors[] = sprintf(
  536. __(
  537. 'Unexpected character on line %1$s. Expected tab, but '
  538. . 'found "%2$s".'
  539. ),
  540. $i + 1,
  541. $line[0]
  542. );
  543. continue;
  544. }
  545. $rules[$ruleNo][$ruleSyntax[$ruleLine]] = chop(
  546. mb_substr($line, 1)
  547. );
  548. $lines[$ruleNo][$ruleSyntax[$ruleLine]] = $i + 1;
  549. ++$ruleLine;
  550. }
  551. // Rule complete
  552. if ($ruleLine == $numRules) {
  553. $ruleLine = -1;
  554. }
  555. }
  556. return array('rules' => $rules, 'lines' => $lines, 'errors' => $errors);
  557. }
  558. /**
  559. * Formats interval like 10 per hour
  560. *
  561. * @param integer $num number to format
  562. * @param integer $precision required precision
  563. *
  564. * @return string formatted string
  565. */
  566. public static function byTime($num, $precision)
  567. {
  568. if ($num >= 1) { // per second
  569. $per = __('per second');
  570. } elseif ($num * 60 >= 1) { // per minute
  571. $num = $num * 60;
  572. $per = __('per minute');
  573. } elseif ($num * 60 * 60 >= 1 ) { // per hour
  574. $num = $num * 60 * 60;
  575. $per = __('per hour');
  576. } else {
  577. $num = $num * 60 * 60 * 24;
  578. $per = __('per day');
  579. }
  580. $num = round($num, $precision);
  581. if ($num == 0) {
  582. $num = '<' . pow(10, -$precision);
  583. }
  584. return "$num $per";
  585. }
  586. /**
  587. * Wrapper for PhpMyAdmin\Util::timespanFormat
  588. *
  589. * This function is used when evaluating advisory_rules.txt
  590. *
  591. * @param int $seconds the timespan
  592. *
  593. * @return string the formatted value
  594. */
  595. public static function timespanFormat($seconds)
  596. {
  597. return Util::timespanFormat($seconds);
  598. }
  599. /**
  600. * Wrapper around PhpMyAdmin\Util::formatByteDown
  601. *
  602. * This function is used when evaluating advisory_rules.txt
  603. *
  604. * @param double $value the value to format
  605. * @param int $limes the sensitiveness
  606. * @param int $comma the number of decimals to retain
  607. *
  608. * @return string the formatted value with unit
  609. */
  610. public static function formatByteDown($value, $limes = 6, $comma = 0)
  611. {
  612. return implode(' ', Util::formatByteDown($value, $limes, $comma));
  613. }
  614. }