Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.47% covered (success)
96.47%
82 / 85
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserRequirementsConditionChecker
96.47% covered (success)
96.47%
82 / 85
71.43% covered (warning)
71.43%
5 / 7
44
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 checkCondition
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
8
 recursivelyCheckCondition
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 recursivelyCheckConditionInternal
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
25.09
 extractPrivateConditions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 extractConditions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 extractConditionsInternal
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use InvalidArgumentException;
10use LogicException;
11use MediaWiki\Config\ServiceOptions;
12use MediaWiki\Context\IContextSource;
13use MediaWiki\HookContainer\HookContainer;
14use MediaWiki\HookContainer\HookRunner;
15use MediaWiki\MainConfigNames;
16use MediaWiki\WikiMap\WikiMap;
17
18/**
19 * @since 1.45
20 */
21class UserRequirementsConditionChecker {
22
23    /**
24     * Logical operators recognized in $wgAutopromote.
25     *
26     * @since 1.45
27     */
28    public const VALID_OPS = [ '&', '|', '^', '!' ];
29
30    /** @internal For use by UserRequirementsConditionCheckerFactory */
31    public const CONSTRUCTOR_OPTIONS = [
32        MainConfigNames::UserRequirementsPrivateConditions,
33    ];
34
35    private HookRunner $hookRunner;
36
37    public function __construct(
38        private readonly ServiceOptions $options,
39        HookContainer $hookContainer,
40        private readonly UserFactory $userFactory,
41        private readonly IContextSource $context,
42        private readonly UserRequirementsConditionValidator $userRequirementsConditionValidator,
43        /** @var UserRequirementsConditionEvaluatorBase[] */
44        private readonly array $evaluators = [],
45    ) {
46        $this->hookRunner = new HookRunner( $hookContainer );
47        $this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
48    }
49
50    /**
51     * As recursivelyCheckCondition, but *not* recursive. The only valid conditions
52     * are those whose first element is one of APCOND_* defined in Defines.php.
53     * Other types will throw an exception if no extension evaluates them.
54     *
55     * @param array $cond A condition, which must not contain other conditions. This array must contain at least
56     *     one item, which is the condition type.
57     * @param UserIdentity $user The user to check the condition against
58     * @return ?bool Whether the condition is true for the user. Null if it's a private condition
59     *     and we're not supposed to evaluate these.
60     * @throws InvalidArgumentException if autopromote condition was not recognized.
61     * @throws LogicException if APCOND_BLOCKED is checked again before returning a result.
62     */
63    protected function checkCondition( array $cond, UserIdentity $user ): ?bool {
64        $isPerformingRequest = !defined( 'MW_NO_SESSION' ) && $user->equals( $this->context->getUser() );
65
66        $conditionType = $cond[0];
67        $args = array_slice( $cond, 1 );
68
69        foreach ( $this->evaluators as $evaluator ) {
70            $result = $evaluator->checkCondition( $conditionType, $args, $user, $isPerformingRequest );
71            if ( $result !== null ) {
72                return $result;
73            }
74        }
75
76        $result = null;
77        $type = $cond[0];
78        $args = array_slice( $cond, 1 );
79        $this->hookRunner->onUserRequirementsCondition( $conditionType, $args, $user, $isPerformingRequest, $result );
80
81        $isCurrentWiki = ( $user->getWikiId() === false ) || WikiMap::isCurrentWikiId( $user->getWikiId() );
82        if ( $isPerformingRequest && $isCurrentWiki ) {
83            // The legacy hook is run only if the tested user is the one performing
84            // the request (like for autopromote), and the user is from the local wiki.
85            // If any of these conditions is not met, we cannot invoke the hook,
86            // as it may produce incorrect results.
87            $userObject = $this->userFactory->newFromUserIdentity( $user );
88            $this->hookRunner->onAutopromoteCondition( $conditionType, $args, $userObject, $result );
89        }
90
91        if ( $result === null ) {
92            throw new InvalidArgumentException(
93                "Unrecognized condition $type in UserRequirementsCondition!"
94            );
95        }
96
97        return (bool)$result;
98    }
99
100    /**
101     * Recursively check a condition. Conditions are in the form
102     *   [ '&' or '|' or '^' or '!', cond1, cond2, ... ]
103     * where cond1, cond2, ... are themselves conditions; *OR*
104     *   APCOND_EMAILCONFIRMED, *OR*
105     *   [ APCOND_EMAILCONFIRMED ], *OR*
106     *   [ APCOND_EDITCOUNT, number of edits ], *OR*
107     *   [ APCOND_AGE, seconds since registration ], *OR*
108     *   similar constructs defined by extensions.
109     * This function evaluates the former type recursively, and passes off to
110     * checkCondition for evaluation of the latter type.
111     *
112     * If you change the logic of this method, please update
113     * ApiQuerySiteinfo::appendAutoPromote(), as it depends on this method.
114     *
115     * If the passed condition is invalid, false is returned without evaluating it.
116     *
117     * @param mixed $cond A condition, possibly containing other conditions
118     * @param UserIdentity $user The user to check the conditions against
119     * @param bool $usePrivateConditions Whether to evaluate private conditions
120     *
121     * @return ?bool Whether the condition is true; will be null if the condition value depends on any of the
122     *      unevaluated private conditions. Non-null value means that the skipped conditions have no effect
123     *      on the result. Null can be returned only if $usePrivateConditions is false.
124     */
125    public function recursivelyCheckCondition( $cond, UserIdentity $user, bool $usePrivateConditions = true ): ?bool {
126        if ( !$this->userRequirementsConditionValidator->isValid( $cond ) ) {
127            return false;
128        }
129
130        $skippedConditions = [];
131        if ( !$usePrivateConditions ) {
132            $skippedConditions = $this->options->get( MainConfigNames::UserRequirementsPrivateConditions );
133            $skippedConditions = array_fill_keys( $skippedConditions, true );
134        }
135
136        return $this->recursivelyCheckConditionInternal( $cond, $user, $skippedConditions );
137    }
138
139    /**
140     * Internal version of recursivelyCheckCondition, which operates on three-valued logic, for
141     * the purpose of supporting private conditions. The third state, beyond false and true, is
142     * null, which is recognized as an unknown value (e.g., false | null = null, true | null = true).
143     *
144     * @param mixed $cond A condition, possibly containing other conditions
145     * @param UserIdentity $user The user to check the conditions against
146     * @param array<string,bool> $skippedConditions Array whose keys tell which conditions to skip while evaluating
147     * @return ?bool Whether the condition is true; will be null if the condition value depends on any of
148     *     $skippedConditions. Non-null value means that the skipped conditions have no effect on the result.
149     */
150    private function recursivelyCheckConditionInternal( $cond, UserIdentity $user, array $skippedConditions ): ?bool {
151        if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], self::VALID_OPS ) ) {
152            // Recursive condition
153
154            // AND (all conditions pass)
155            if ( $cond[0] === '&' ) {
156                $hasNulls = false;
157                foreach ( array_slice( $cond, 1 ) as $subcond ) {
158                    $result = $this->recursivelyCheckConditionInternal( $subcond, $user, $skippedConditions );
159                    if ( $result === false ) {
160                        return false;
161                    }
162                    $hasNulls = $hasNulls || $result === null;
163                }
164
165                return $hasNulls ? null : true;
166            }
167
168            // OR (at least one condition passes)
169            if ( $cond[0] === '|' ) {
170                $hasNulls = false;
171                foreach ( array_slice( $cond, 1 ) as $subcond ) {
172                    $result = $this->recursivelyCheckConditionInternal( $subcond, $user, $skippedConditions );
173                    if ( $result === true ) {
174                        return true;
175                    }
176                    $hasNulls = $hasNulls || $result === null;
177                }
178
179                return $hasNulls ? null : false;
180            }
181
182            // XOR (exactly one condition passes)
183            if ( $cond[0] === '^' ) {
184                $result1 = $this->recursivelyCheckConditionInternal( $cond[1], $user, $skippedConditions );
185                $result2 = $this->recursivelyCheckConditionInternal( $cond[2], $user, $skippedConditions );
186                if ( $result1 === null || $result2 === null ) {
187                    return null;
188                }
189                return $result1 xor $result2;
190            }
191
192            // NOT (no conditions pass)
193            if ( $cond[0] === '!' ) {
194                $hasNulls = false;
195                foreach ( array_slice( $cond, 1 ) as $subcond ) {
196                    $result = $this->recursivelyCheckConditionInternal( $subcond, $user, $skippedConditions );
197                    if ( $result === true ) {
198                        return false;
199                    }
200                    $hasNulls = $hasNulls || $result === null;
201                }
202
203                return $hasNulls ? null : true;
204            }
205        }
206        // If we got here, the array presumably does not contain other conditions;
207        // it's not recursive. Pass it off to checkCondition.
208        if ( !is_array( $cond ) ) {
209            $cond = [ $cond ];
210        }
211
212        // Ensure the condition makes sense at all
213        if ( count( $cond ) < 1 ) {
214            return false;
215        }
216
217        if ( isset( $skippedConditions[$cond[0]] ) ) {
218            return null;
219        }
220
221        return $this->checkCondition( $cond, $user );
222    }
223
224    /**
225     * Goes through a condition passed as the input and extracts all private conditions that are used within it.
226     * @param mixed $cond A condition, possibly containing other conditions.
227     * @return list<mixed> A list of unique private conditions present in $cond
228     * @since 1.46
229     */
230    public function extractPrivateConditions( $cond ): array {
231        $allPrivateConditions = $this->options->get( MainConfigNames::UserRequirementsPrivateConditions );
232        $allConditionsUsed = $this->extractConditions( $cond );
233        $privateConditionsUsed = array_intersect( $allPrivateConditions, $allConditionsUsed );
234        return array_values( $privateConditionsUsed );
235    }
236
237    /**
238     * Goes through a condition passed as the input and extracts all simple conditions that are used within it.
239     *
240     * Simple condition is any condition that is not a logical operator, for example APCOND_EDITCOUNT is
241     * a simple condition.
242     * @param mixed $cond A condition, possibly containing other conditions.
243     * @return list<mixed> A list of unique private conditions present in $cond
244     * @since 1.46
245     */
246    public function extractConditions( $cond ): array {
247        $result = $this->extractConditionsInternal( $cond );
248        return array_values( array_unique( $result ) );
249    }
250
251    /**
252     * Internal backend for {@see extractConditions}. It returns a list of all simple conditions found
253     * in the input conditions. The result may contain duplicates.
254     * @param mixed $cond
255     * @return list<mixed>
256     */
257    private function extractConditionsInternal( $cond ): array {
258        if ( $cond === [] ) {
259            return [];
260        }
261
262        $result = [];
263        if ( is_array( $cond ) ) {
264            $op = $cond[0];
265            if ( in_array( $op, self::VALID_OPS ) ) {
266                foreach ( array_slice( $cond, 1 ) as $subcond ) {
267                    $result = array_merge(
268                        $result, $this->extractConditionsInternal( $subcond ) );
269                }
270            } else {
271                $result[] = $op;
272            }
273        } else {
274            $result[] = $cond;
275        }
276        return $result;
277    }
278}