Advisor.php 20 KB

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