Passed
Push — master ( 003da0...69fb1d )
by William
07:21
created

libraries/classes/Advisor.php (1 issue)

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
11
namespace PhpMyAdmin;
12
13
use Exception;
14
use PhpMyAdmin\Core;
15
use PhpMyAdmin\DatabaseInterface;
16
use PhpMyAdmin\SysInfo;
17
use PhpMyAdmin\Url;
18
use PhpMyAdmin\Util;
19
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
20
21
/**
22
 * Advisor class
23
 *
24
 * @package PhpMyAdmin
25
 */
26
class Advisor
27
{
28
    protected $dbi;
29
    protected $variables;
30
    protected $globals;
31
    protected $parseResult;
32
    protected $runResult;
33
    protected $expression;
34
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
106
                // Did matching rule fire?
107
                foreach ($this->runResult['fired'] as $rule) {
108
                    if ($rule['id'] == $value) {
109
                        return '1';
110
                    }
111
                }
112
113
                return '0';
114
            }
115
        );
116
        /* Some global variables for advisor */
117
        $this->globals = [
118
            'PMA_MYSQL_INT_VERSION' => $this->dbi->getVersion(),
119
        ];
120
    }
121
122
    /**
123
     * Get variables
124
     *
125
     * @return mixed
126
     */
127
    public function getVariables()
128
    {
129
        return $this->variables;
130
    }
131
132
    /**
133
     * Set variables
134
     *
135
     * @param array $variables Variables
136
     *
137
     * @return Advisor
138
     */
139
    public function setVariables(array $variables): self
140
    {
141
        $this->variables = $variables;
142
143
        return $this;
144
    }
145
146
    /**
147
     * Set a variable and its value
148
     *
149
     * @param string|int $variable Variable to set
150
     * @param mixed      $value    Value to set
151
     *
152
     * @return Advisor
153
     */
154
    public function setVariable($variable, $value): self
155
    {
156
        $this->variables[$variable] = $value;
157
158
        return $this;
159
    }
160
161
    /**
162
     * Get parseResult
163
     *
164
     * @return mixed
165
     */
166
    public function getParseResult()
167
    {
168
        return $this->parseResult;
169
    }
170
171
    /**
172
     * Set parseResult
173
     *
174
     * @param array $parseResult Parse result
175
     *
176
     * @return Advisor
177
     */
178
    public function setParseResult(array $parseResult): self
179
    {
180
        $this->parseResult = $parseResult;
181
182
        return $this;
183
    }
184
185
    /**
186
     * Get runResult
187
     *
188
     * @return mixed
189
     */
190
    public function getRunResult()
191
    {
192
        return $this->runResult;
193
    }
194
195
    /**
196
     * Set runResult
197
     *
198
     * @param array $runResult Run result
199
     *
200
     * @return Advisor
201
     */
202
    public function setRunResult(array $runResult): self
203
    {
204
        $this->runResult = $runResult;
205
206
        return $this;
207
    }
208
209
    /**
210
     * Parses and executes advisor rules
211
     *
212
     * @return array with run and parse results
213
     */
214
    public function run(): array
215
    {
216
        // HowTo: A simple Advisory system in 3 easy steps.
217
218
        // Step 1: Get some variables to evaluate on
219
        $this->setVariables(
220
            array_merge(
221
                $this->dbi->fetchResult('SHOW GLOBAL STATUS', 0, 1),
222
                $this->dbi->fetchResult('SHOW GLOBAL VARIABLES', 0, 1)
223
            )
224
        );
225
226
        // Add total memory to variables as well
227
        $sysinfo = SysInfo::get();
228
        $memory  = $sysinfo->memory();
229
        $this->variables['system_memory']
230
            = isset($memory['MemTotal']) ? $memory['MemTotal'] : 0;
231
232
        // Step 2: Read and parse the list of rules
233
        $this->setParseResult(static::parseRulesFile());
234
        // Step 3: Feed the variables to the rules and let them fire. Sets
235
        // $runResult
236
        $this->runRules();
237
238
        return [
239
            'parse' => ['errors' => $this->parseResult['errors']],
240
            'run'   => $this->runResult
241
        ];
242
    }
243
244
    /**
245
     * Stores current error in run results.
246
     *
247
     * @param string     $description description of an error.
248
     * @param \Throwable $exception   exception raised
249
     *
250
     * @return void
251
     */
252
    public function storeError(string $description, \Throwable $exception): void
253
    {
254
        $this->runResult['errors'][] = $description
255
            . ' '
256
            . sprintf(
257
                __('Error when evaluating: %s'),
258
                $exception->getMessage()
259
            );
260
    }
261
262
    /**
263
     * Executes advisor rules
264
     *
265
     * @return boolean
266
     */
267
    public function runRules(): bool
268
    {
269
        $this->setRunResult(
270
            [
271
                'fired'     => [],
272
                'notfired'  => [],
273
                'unchecked' => [],
274
                'errors'    => [],
275
            ]
276
        );
277
278
        foreach ($this->parseResult['rules'] as $rule) {
279
            $this->variables['value'] = 0;
280
            $precond = true;
281
282
            if (isset($rule['precondition'])) {
283
                try {
284
                     $precond = $this->ruleExprEvaluate($rule['precondition']);
285
                } catch (Exception $e) {
286
                    $this->storeError(
287
                        sprintf(
288
                            __('Failed evaluating precondition for rule \'%s\'.'),
289
                            $rule['name']
290
                        ),
291
                        $e
292
                    );
293
                    continue;
294
                }
295
            }
296
297
            if (! $precond) {
298
                $this->addRule('unchecked', $rule);
299
            } else {
300
                try {
301
                    $value = $this->ruleExprEvaluate($rule['formula']);
302
                } catch (Exception $e) {
303
                    $this->storeError(
304
                        sprintf(
305
                            __('Failed calculating value for rule \'%s\'.'),
306
                            $rule['name']
307
                        ),
308
                        $e
309
                    );
310
                    continue;
311
                }
312
313
                $this->variables['value'] = $value;
314
315
                try {
316
                    if ($this->ruleExprEvaluate($rule['test'])) {
317
                        $this->addRule('fired', $rule);
318
                    } else {
319
                        $this->addRule('notfired', $rule);
320
                    }
321
                } catch (Exception $e) {
322
                    $this->storeError(
323
                        sprintf(
324
                            __('Failed running test for rule \'%s\'.'),
325
                            $rule['name']
326
                        ),
327
                        $e
328
                    );
329
                }
330
            }
331
        }
332
333
        return true;
334
    }
335
336
    /**
337
     * Escapes percent string to be used in format string.
338
     *
339
     * @param string $str string to escape
340
     *
341
     * @return string
342
     */
343
    public static function escapePercent(string $str): string
344
    {
345
        return preg_replace('/%( |,|\.|$|\(|\)|<|>)/', '%%\1', $str);
346
    }
347
348
    /**
349
     * Wrapper function for translating.
350
     *
351
     * @param string $str   the string
352
     * @param string $param the parameters
353
     *
354
     * @return string
355
     */
356
    public function translate(string $str, ?string $param = null): string
357
    {
358
        $string = _gettext(self::escapePercent($str));
359
        if (! is_null($param)) {
360
            $params = $this->ruleExprEvaluate('[' . $param . ']');
361
        } else {
362
            $params = [];
363
        }
364
        return vsprintf($string, $params);
365
    }
366
367
    /**
368
     * Splits justification to text and formula.
369
     *
370
     * @param array $rule the rule
371
     *
372
     * @return string[]
373
     */
374
    public static function splitJustification(array $rule): array
375
    {
376
        $jst = preg_split('/\s*\|\s*/', $rule['justification'], 2);
377
        if (count($jst) > 1) {
0 ignored issues
show
It seems like $jst can also be of type false; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

377
        if (count(/** @scrutinizer ignore-type */ $jst) > 1) {
Loading history...
378
            return [$jst[0], $jst[1]];
379
        }
380
        return [$rule['justification']];
381
    }
382
383
    /**
384
     * Adds a rule to the result list
385
     *
386
     * @param string $type type of rule
387
     * @param array  $rule rule itself
388
     *
389
     * @return void
390
     */
391
    public function addRule(string $type, array $rule): void
392
    {
393
        switch ($type) {
394
            case 'notfired':
395
            case 'fired':
396
                $jst = self::splitJustification($rule);
397
                if (count($jst) > 1) {
398
                    try {
399
                        /* Translate */
400
                        $str = $this->translate($jst[0], $jst[1]);
401
                    } catch (Exception $e) {
402
                        $this->storeError(
403
                            sprintf(
404
                                __('Failed formatting string for rule \'%s\'.'),
405
                                $rule['name']
406
                            ),
407
                            $e
408
                        );
409
                        return;
410
                    }
411
412
                    $rule['justification'] = $str;
413
                } else {
414
                    $rule['justification'] = $this->translate($rule['justification']);
415
                }
416
                $rule['id'] = $rule['name'];
417
                $rule['name'] = $this->translate($rule['name']);
418
                $rule['issue'] = $this->translate($rule['issue']);
419
420
                // Replaces {server_variable} with 'server_variable'
421
                // linking to server_variables.php
422
                $rule['recommendation'] = preg_replace_callback(
423
                    '/\{([a-z_0-9]+)\}/Ui',
424
                    [$this, 'replaceVariable'],
425
                    $this->translate($rule['recommendation'])
426
                );
427
428
                // Replaces external Links with Core::linkURL() generated links
429
                $rule['recommendation'] = preg_replace_callback(
430
                    '#href=("|\')(https?://[^\1]+)\1#i',
431
                    [$this, 'replaceLinkURL'],
432
                    $rule['recommendation']
433
                );
434
                break;
435
        }
436
437
        $this->runResult[$type][] = $rule;
438
    }
439
440
    /**
441
     * Callback for wrapping links with Core::linkURL
442
     *
443
     * @param array $matches List of matched elements form preg_replace_callback
444
     *
445
     * @return string Replacement value
446
     */
447
    private function replaceLinkURL(array $matches): string
448
    {
449
        return 'href="' . Core::linkURL($matches[2]) . '" target="_blank" rel="noopener noreferrer"';
450
    }
451
452
    /**
453
     * Callback for wrapping variable edit links
454
     *
455
     * @param array $matches List of matched elements form preg_replace_callback
456
     *
457
     * @return string Replacement value
458
     */
459
    private function replaceVariable(array $matches): string
460
    {
461
        return '<a href="server_variables.php' . Url::getCommon(['filter' => $matches[1]])
462
                . '">' . htmlspecialchars($matches[1]) . '</a>';
463
    }
464
465
    /**
466
     * Runs a code expression, replacing variable names with their respective
467
     * values
468
     *
469
     * @param string $expr expression to evaluate
470
     *
471
     * @return mixed result of evaluated expression
472
     *
473
     * @throws Exception
474
     */
475
    public function ruleExprEvaluate(string $expr)
476
    {
477
        // Actually evaluate the code
478
        // This can throw exception
479
        $value = $this->expression->evaluate(
480
            $expr,
481
            array_merge($this->variables, $this->globals)
482
        );
483
484
        return $value;
485
    }
486
487
    /**
488
     * Reads the rule file into an array, throwing errors messages on syntax
489
     * errors.
490
     *
491
     * @return array with parsed data
492
     */
493
    public static function parseRulesFile(): array
494
    {
495
        $filename = 'libraries/advisory_rules.txt';
496
        $file = file($filename, FILE_IGNORE_NEW_LINES);
497
498
        $errors = [];
499
        $rules = [];
500
        $lines = [];
501
502
        if ($file === false) {
503
            $errors[] = sprintf(
504
                __('Error in reading file: The file \'%s\' does not exist or is not readable!'),
505
                $filename
506
            );
507
            return ['rules' => $rules, 'lines' => $lines, 'errors' => $errors];
508
        }
509
510
        $ruleSyntax = [
511
            'name', 'formula', 'test', 'issue', 'recommendation', 'justification'
512
        ];
513
        $numRules = count($ruleSyntax);
514
        $numLines = count($file);
515
        $ruleNo = -1;
516
        $ruleLine = -1;
517
518
        for ($i = 0; $i < $numLines; $i++) {
519
            $line = $file[$i];
520
            if ($line == "" || $line[0] == '#') {
521
                continue;
522
            }
523
524
            // Reading new rule
525
            if (substr($line, 0, 4) == 'rule') {
526
                if ($ruleLine > 0) {
527
                    $errors[] = sprintf(
528
                        __(
529
                            'Invalid rule declaration on line %1$s, expected line '
530
                            . '%2$s of previous rule.'
531
                        ),
532
                        $i + 1,
533
                        $ruleSyntax[$ruleLine++]
534
                    );
535
                    continue;
536
                }
537
                if (preg_match("/rule\s'(.*)'( \[(.*)\])?$/", $line, $match)) {
538
                    $ruleLine = 1;
539
                    $ruleNo++;
540
                    $rules[$ruleNo] = ['name' => $match[1]];
541
                    $lines[$ruleNo] = ['name' => $i + 1];
542
                    if (isset($match[3])) {
543
                        $rules[$ruleNo]['precondition'] = $match[3];
544
                        $lines[$ruleNo]['precondition'] = $i + 1;
545
                    }
546
                } else {
547
                    $errors[] = sprintf(
548
                        __('Invalid rule declaration on line %s.'),
549
                        $i + 1
550
                    );
551
                }
552
                continue;
553
            } else {
554
                if ($ruleLine == -1) {
555
                    $errors[] = sprintf(
556
                        __('Unexpected characters on line %s.'),
557
                        $i + 1
558
                    );
559
                }
560
            }
561
562
            // Reading rule lines
563
            if ($ruleLine > 0) {
564
                if (!isset($line[0])) {
565
                    continue; // Empty lines are ok
566
                }
567
                // Non tabbed lines are not
568
                if ($line[0] != "\t") {
569
                    $errors[] = sprintf(
570
                        __(
571
                            'Unexpected character on line %1$s. Expected tab, but '
572
                            . 'found "%2$s".'
573
                        ),
574
                        $i + 1,
575
                        $line[0]
576
                    );
577
                    continue;
578
                }
579
                $rules[$ruleNo][$ruleSyntax[$ruleLine]] = chop(
580
                    mb_substr($line, 1)
581
                );
582
                $lines[$ruleNo][$ruleSyntax[$ruleLine]] = $i + 1;
583
                ++$ruleLine;
584
            }
585
586
            // Rule complete
587
            if ($ruleLine == $numRules) {
588
                $ruleLine = -1;
589
            }
590
        }
591
592
        return ['rules' => $rules, 'lines' => $lines, 'errors' => $errors];
593
    }
594
595
    /**
596
     * Formats interval like 10 per hour
597
     *
598
     * @param float   $num       number to format
599
     * @param integer $precision required precision
600
     *
601
     * @return string formatted string
602
     */
603
    public static function byTime(float $num, int $precision): string
604
    {
605
        if ($num >= 1) { // per second
606
            $per = __('per second');
607
        } elseif ($num * 60 >= 1) { // per minute
608
            $num = $num * 60;
609
            $per = __('per minute');
610
        } elseif ($num * 60 * 60 >= 1) { // per hour
611
            $num = $num * 60 * 60;
612
            $per = __('per hour');
613
        } else {
614
            $num = $num * 60 * 60 * 24;
615
            $per = __('per day');
616
        }
617
618
        $num = round($num, $precision);
619
620
        if ($num == 0) {
621
            $num = '<' . pow(10, -$precision);
622
        }
623
624
        return "$num $per";
625
    }
626
627
    /**
628
     * Wrapper for PhpMyAdmin\Util::timespanFormat
629
     *
630
     * This function is used when evaluating advisory_rules.txt
631
     *
632
     * @param int $seconds the timespan
633
     *
634
     * @return string  the formatted value
635
     */
636
    public static function timespanFormat(int $seconds): string
637
    {
638
        return Util::timespanFormat($seconds);
639
    }
640
641
    /**
642
     * Wrapper around PhpMyAdmin\Util::formatByteDown
643
     *
644
     * This function is used when evaluating advisory_rules.txt
645
     *
646
     * @param double|string $value the value to format
647
     * @param int           $limes the sensitiveness
648
     * @param int           $comma the number of decimals to retain
649
     *
650
     * @return string the formatted value with unit
651
     */
652
    public static function formatByteDown($value, int $limes = 6, int $comma = 0): string
653
    {
654
        return implode(' ', Util::formatByteDown($value, $limes, $comma));
655
    }
656
}
657