Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
340 / 340
100.00% covered (success)
100.00%
26 / 26
CRAP
100.00% covered (success)
100.00%
1 / 1
AFPTreeParser
100.00% covered (success)
100.00%
340 / 340
100.00% covered (success)
100.00%
26 / 26
111
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetState
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 move
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNextToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getState
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setState
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildSyntaxTree
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 doLevelEntry
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 doLevelSemicolon
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 doLevelSet
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
13
 doLevelConditions
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
1 / 1
13
 doLevelBoolOps
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 doLevelCompares
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 doLevelSumRels
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 doLevelMulRels
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 doLevelPow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 doLevelBoolInvert
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 doLevelKeywordOperators
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 doLevelUnarys
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 doLevelArrayElements
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 doLevelParenthesis
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 doLevelFunction
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
11
 doLevelAtom
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
16
 checkLogDeprecatedVar
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * A version of the abuse filter parser that separates parsing the filter and
5 * evaluating it into different passes, allowing the parse tree to be cached.
6 *
7 * @file
8 * @phan-file-suppress PhanPossiblyInfiniteRecursionSameParams Recursion controlled by class props
9 */
10
11namespace MediaWiki\Extension\AbuseFilter\Parser;
12
13use MediaWiki\Extension\AbuseFilter\KeywordsManager;
14use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
15use Psr\Log\LoggerInterface;
16use Wikimedia\Stats\StatsFactory;
17use Wikimedia\Timestamp\ConvertibleTimestamp;
18
19/**
20 * A parser that transforms the text of the filter into a parse tree.
21 */
22class AFPTreeParser {
23    /**
24     * @var array<int,array{0:AFPToken,1:int}> Contains the AFPTokens for the code being parsed
25     */
26    private $mTokens;
27    /**
28     * @var AFPToken The current token
29     */
30    private $mCur;
31    /** @var int The position of the current token */
32    private $mPos;
33
34    /**
35     * @var string|null The ID of the filter being parsed, if available. Can also be "global-$ID"
36     */
37    private $mFilter;
38
39    public const CACHE_VERSION = 2;
40
41    public function __construct(
42        private readonly LoggerInterface $logger,
43        private readonly StatsFactory $statsFactory,
44        private readonly KeywordsManager $keywordsManager
45    ) {
46        $this->resetState();
47    }
48
49    /**
50     * @param string $filter
51     */
52    public function setFilter( $filter ) {
53        $this->mFilter = $filter;
54    }
55
56    /**
57     * Resets the state
58     */
59    private function resetState() {
60        $this->mTokens = [];
61        $this->mPos = 0;
62        $this->mFilter = null;
63    }
64
65    /**
66     * Advances the parser to the next token in the filter code.
67     */
68    private function move() {
69        [ $this->mCur, $this->mPos ] = $this->mTokens[$this->mPos];
70    }
71
72    /**
73     * Get the next token. This is similar to move() but doesn't change class members,
74     *   allowing to look ahead without rolling back the state.
75     *
76     * @return AFPToken
77     */
78    private function getNextToken() {
79        return $this->mTokens[$this->mPos][0];
80    }
81
82    /**
83     * getState() function allows parser state to be rollbacked to several tokens
84     * back.
85     *
86     * @return AFPParserState
87     */
88    private function getState() {
89        return new AFPParserState( $this->mCur, $this->mPos );
90    }
91
92    /**
93     * setState() function allows parser state to be rollbacked to several tokens
94     * back.
95     */
96    private function setState( AFPParserState $state ) {
97        $this->mCur = $state->token;
98        $this->mPos = $state->pos;
99    }
100
101    /**
102     * Parse the supplied filter source code into a tree.
103     *
104     * @param array<int,array{0:AFPToken,1:int}> $tokens
105     * @return AFPSyntaxTree
106     * @throws UserVisibleException
107     */
108    public function parse( array $tokens ): AFPSyntaxTree {
109        $this->mTokens = $tokens;
110        $this->mPos = 0;
111
112        return $this->buildSyntaxTree();
113    }
114
115    private function buildSyntaxTree(): AFPSyntaxTree {
116        $startTime = ConvertibleTimestamp::hrtime();
117        $root = $this->doLevelEntry();
118        $this->statsFactory->withComponent( 'AbuseFilter' )
119            ->getTiming( 'parser_duration_seconds' )
120            ->setLabel( 'phase', 'buildtree' )
121            ->observeNanoseconds( ConvertibleTimestamp::hrtime() - $startTime );
122        return new AFPSyntaxTree( $root );
123    }
124
125    /* Levels */
126
127    /**
128     * Handles unexpected characters after the expression.
129     * @return AFPTreeNode|null Null only if no statements
130     * @throws UserVisibleException
131     */
132    private function doLevelEntry() {
133        $result = $this->doLevelSemicolon();
134
135        if ( $this->mCur->type !== AFPToken::TNONE ) {
136            throw new UserVisibleException(
137                'unexpectedatend',
138                $this->mPos, [ $this->mCur->type ]
139            );
140        }
141
142        return $result;
143    }
144
145    /**
146     * Handles the semicolon operator.
147     *
148     * @return AFPTreeNode|null
149     */
150    private function doLevelSemicolon() {
151        $statements = [];
152
153        do {
154            $this->move();
155            $position = $this->mPos;
156
157            if (
158                $this->mCur->type === AFPToken::TNONE ||
159                ( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value == ')' )
160            ) {
161                // Handle special cases which the other parser handled in doLevelAtom
162                break;
163            }
164
165            // Allow empty statements.
166            if ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR ) {
167                continue;
168            }
169
170            $statements[] = $this->doLevelSet();
171            $position = $this->mPos;
172        } while ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR );
173
174        // Flatten the tree if possible.
175        if ( count( $statements ) === 0 ) {
176            return null;
177        } elseif ( count( $statements ) === 1 ) {
178            return $statements[0];
179        } else {
180            return new AFPTreeNode( AFPTreeNode::SEMICOLON, $statements, $position );
181        }
182    }
183
184    /**
185     * Handles variable assignment.
186     *
187     * @return AFPTreeNode
188     * @throws UserVisibleException
189     */
190    private function doLevelSet() {
191        if ( $this->mCur->type === AFPToken::TID ) {
192            $varname = (string)$this->mCur->value;
193
194            // Speculatively parse the assignment statement assuming it can
195            // potentially be an assignment, but roll back if it isn't.
196            // @todo Use $this->getNextToken for clearer code
197            $initialState = $this->getState();
198            $this->move();
199
200            if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) {
201                $position = $this->mPos;
202                $this->move();
203                $value = $this->doLevelSet();
204
205                return new AFPTreeNode( AFPTreeNode::ASSIGNMENT, [ $varname, $value ], $position );
206            }
207
208            if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) {
209                $this->move();
210
211                if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
212                    $index = 'append';
213                } else {
214                    // Parse index offset.
215                    $this->setState( $initialState );
216                    $this->move();
217                    $index = $this->doLevelSemicolon();
218                    if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) {
219                        throw new UserVisibleException( 'expectednotfound', $this->mPos,
220                            [ ']', $this->mCur->type, $this->mCur->value ] );
221                    }
222                }
223
224                $this->move();
225                if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) {
226                    $position = $this->mPos;
227                    $this->move();
228                    $value = $this->doLevelSet();
229                    if ( $index === 'append' ) {
230                        return new AFPTreeNode(
231                            AFPTreeNode::ARRAY_APPEND, [ $varname, $value ], $position );
232                    } else {
233                        return new AFPTreeNode(
234                            AFPTreeNode::INDEX_ASSIGNMENT,
235                            [ $varname, $index, $value ],
236                            $position
237                        );
238                    }
239                }
240            }
241
242            // If we reached this point, we did not find an assignment.  Roll back
243            // and assume this was just a literal.
244            $this->setState( $initialState );
245        }
246
247        return $this->doLevelConditions();
248    }
249
250    /**
251     * Handles ternary operator and if-then-else-end.
252     *
253     * @return AFPTreeNode
254     * @throws UserVisibleException
255     */
256    private function doLevelConditions() {
257        if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'if' ) {
258            $position = $this->mPos;
259            $this->move();
260            $condition = $this->doLevelBoolOps();
261
262            if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'then' ) ) {
263                throw new UserVisibleException( 'expectednotfound',
264                    $this->mPos,
265                    [
266                        'then',
267                        $this->mCur->type,
268                        $this->mCur->value
269                    ]
270                );
271            }
272            $this->move();
273
274            $valueIfTrue = $this->doLevelConditions();
275
276            if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'else' ) {
277                $this->move();
278                $valueIfFalse = $this->doLevelConditions();
279            } else {
280                $valueIfFalse = null;
281            }
282
283            if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'end' ) ) {
284                throw new UserVisibleException( 'expectednotfound',
285                    $this->mPos,
286                    [
287                        'end',
288                        $this->mCur->type,
289                        $this->mCur->value
290                    ]
291                );
292            }
293            $this->move();
294
295            return new AFPTreeNode(
296                AFPTreeNode::CONDITIONAL,
297                [ $condition, $valueIfTrue, $valueIfFalse ],
298                $position
299            );
300        }
301
302        $condition = $this->doLevelBoolOps();
303        if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '?' ) {
304            $position = $this->mPos;
305            $this->move();
306
307            $valueIfTrue = $this->doLevelConditions();
308            if ( !( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':' ) ) {
309                throw new UserVisibleException( 'expectednotfound',
310                    $this->mPos,
311                    [
312                        ':',
313                        $this->mCur->type,
314                        $this->mCur->value
315                    ]
316                );
317            }
318            $this->move();
319
320            $valueIfFalse = $this->doLevelConditions();
321            return new AFPTreeNode(
322                AFPTreeNode::CONDITIONAL,
323                [ $condition, $valueIfTrue, $valueIfFalse ],
324                $position
325            );
326        }
327
328        return $condition;
329    }
330
331    /**
332     * Handles logic operators.
333     *
334     * @return AFPTreeNode
335     */
336    private function doLevelBoolOps() {
337        $leftOperand = $this->doLevelCompares();
338        $ops = [ '&', '|', '^' ];
339        while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
340            $op = $this->mCur->value;
341            $position = $this->mPos;
342            $this->move();
343
344            $rightOperand = $this->doLevelCompares();
345
346            $leftOperand = new AFPTreeNode(
347                AFPTreeNode::LOGIC,
348                [ $op, $leftOperand, $rightOperand ],
349                $position
350            );
351        }
352        return $leftOperand;
353    }
354
355    /**
356     * Handles comparison operators.
357     *
358     * @return AFPTreeNode
359     */
360    private function doLevelCompares() {
361        $leftOperand = $this->doLevelSumRels();
362        $equalityOps = [ '==', '===', '!=', '!==', '=' ];
363        $orderOps = [ '<', '>', '<=', '>=' ];
364        // Only allow either a single operation, or a combination of a single equalityOps and a single
365        // orderOps. This resembles what PHP does, and allows `a < b == c` while rejecting `a < b < c`
366        $allowedOps = array_merge( $equalityOps, $orderOps );
367        while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $allowedOps ) ) {
368            $op = $this->mCur->value;
369            $allowedOps = in_array( $op, $equalityOps ) ?