Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
183 / 183
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
TemplateDataValidator
100.00% covered (success)
100.00%
183 / 183
100.00% covered (success)
100.00%
9 / 9
94
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
 validate
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
13
 validateParameters
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 validateParameter
100.00% covered (success)
100.00%
60 / 60
100.00% covered (success)
100.00%
1 / 1
32
 validateParameterOrder
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 validateSets
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
11
 validateMaps
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
15
 isValidCustomFormatString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidInterfaceText
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3namespace MediaWiki\Extension\TemplateData;
4
5use MediaWiki\Status\Status;
6use stdClass;
7
8/**
9 * @license GPL-2.0-or-later
10 */
11class TemplateDataValidator {
12
13    public const PREDEFINED_FORMATS = [
14        'block' => "{{_\n| _ = _\n}}",
15        'inline' => '{{_|_=_}}',
16    ];
17
18    private const VALID_ROOT_KEYS = [
19        'description',
20        'params',
21        'paramOrder',
22        'sets',
23        'maps',
24        'format',
25    ];
26
27    private const VALID_PARAM_KEYS = [
28        'label',
29        'required',
30        'suggested',
31        'description',
32        'example',
33        'deprecated',
34        'aliases',
35        'autovalue',
36        'default',
37        'inherits',
38        'type',
39        'suggestedvalues',
40    ];
41
42    private const VALID_TYPES = [
43        'content',
44        'line',
45        'number',
46        'boolean',
47        'string',
48        'date',
49        'unbalanced-wikitext',
50        'unknown',
51        'url',
52        'wiki-page-name',
53        'wiki-user-name',
54        'wiki-file-name',
55        'wiki-template-name',
56    ];
57
58    /** @var string[] */
59    private array $validParameterTypes;
60
61    /**
62     * @param string[] $additionalParameterTypes
63     */
64    public function __construct( array $additionalParameterTypes ) {
65        $this->validParameterTypes = array_merge( self::VALID_TYPES, $additionalParameterTypes );
66    }
67
68    /**
69     * @param mixed $data
70     *
71     * @return Status
72     */
73    public function validate( $data ): Status {
74        if ( $data === null ) {
75            return Status::newFatal( 'templatedata-invalid-parse' );
76        }
77
78        if ( !( $data instanceof stdClass ) ) {
79            return Status::newFatal( 'templatedata-invalid-type', 'templatedata', 'object' );
80        }
81
82        foreach ( $data as $key => $value ) {
83            if ( !in_array( $key, self::VALID_ROOT_KEYS ) ) {
84                return Status::newFatal( 'templatedata-invalid-unknown', $key );
85            }
86        }
87
88        // Root.description
89        if ( isset( $data->description ) ) {
90            if ( !$this->isValidInterfaceText( $data->description ) ) {
91                return Status::newFatal( 'templatedata-invalid-type', 'description',
92                    'string|object' );
93            }
94        }
95
96        // Root.format
97        if ( isset( $data->format ) ) {
98            if ( !is_string( $data->format ) ||
99                !( isset( self::PREDEFINED_FORMATS[$data->format] ) ||
100                    $this->isValidCustomFormatString( $data->format )
101                )
102            ) {
103                return Status::newFatal( 'templatedata-invalid-format', 'format' );
104            }
105        }
106
107        // Root.params
108        if ( !isset( $data->params ) ) {
109            return Status::newFatal( 'templatedata-invalid-missing', 'params', 'object' );
110        }
111
112        if ( !( $data->params instanceof stdClass ) ) {
113            return Status::newFatal( 'templatedata-invalid-type', 'params', 'object' );
114        }
115
116        return $this->validateParameters( $data->params ) ??
117            $this->validateParameterOrder( $data->paramOrder ?? null, $data->params ) ??
118            $this->validateSets( $data->sets ?? [], $data->params ) ??
119            $this->validateMaps( $data->maps ?? (object)[], $data->params ) ??
120            Status::newGood( $data );
121    }
122
123    /**
124     * @param stdClass $params
125     * @return Status|null Null on success, otherwise a Status object with the error message
126     */
127    private function validateParameters( stdClass $params ): ?Status {
128        foreach ( $params as $paramName => $param ) {
129            if ( trim( $paramName ) === '' ) {
130                return Status::newFatal( 'templatedata-invalid-unnamed-parameter' );
131            }
132
133            if ( !( $param instanceof stdClass ) ) {
134                return Status::newFatal( 'templatedata-invalid-type', "params.{$paramName}",
135                    'object' );
136            }
137
138            $status = $this->validateParameter( $paramName, $param );
139            if ( $status ) {
140                return $status;
141            }
142
143            if ( isset( $param->inherits ) && !isset( $params->{ $param->inherits } ) ) {
144                return Status::newFatal( 'templatedata-invalid-missing',
145                    "params.{$param->inherits}" );
146            }
147        }
148
149        return null;
150    }
151
152    /**
153     * @param string $paramName
154     * @param stdClass $param
155     * @return Status|null Null on success, otherwise a Status object with the error message
156     */
157    private function validateParameter( string $paramName, stdClass $param ): ?Status {
158        foreach ( $param as $key => $value ) {
159            if ( !in_array( $key, self::VALID_PARAM_KEYS ) ) {
160                return Status::newFatal( 'templatedata-invalid-unknown',
161                    "params.{$paramName}.{$key}" );
162            }
163        }
164
165        // Param.label
166        if ( isset( $param->label ) ) {
167            if ( !$this->isValidInterfaceText( $param->label ) ) {
168                return Status::newFatal( 'templatedata-invalid-type',
169                    "params.{$paramName}.label", 'string|object' );
170            }
171        }
172
173        // Param.required
174        if ( isset( $param->required ) ) {
175            if ( !is_bool( $param->required ) ) {
176                return Status::newFatal( 'templatedata-invalid-type',
177                    "params.{$paramName}.required", 'boolean' );
178            }
179        }
180
181        // Param.suggested
182        if ( isset( $param->suggested ) ) {
183            if ( !is_bool( $param->suggested ) ) {
184                return Status::newFatal( 'templatedata-invalid-type',
185                    "params.{$paramName}.suggested", 'boolean' );
186            }
187        }
188
189        // Param.description
190        if ( isset( $param->description ) ) {
191            if ( !$this->isValidInterfaceText( $param->description ) ) {
192                return Status::newFatal( 'templatedata-invalid-type',
193                    "params.{$paramName}.description", 'string|object' );
194            }
195        }
196
197        // Param.example
198        if ( isset( $param->example ) ) {
199            if ( !$this->isValidInterfaceText( $param->example ) ) {
200                return Status::newFatal( 'templatedata-invalid-type',
201                    "params.{$paramName}.example", 'string|object' );
202            }
203        }
204
205        // Param.deprecated
206        if ( isset( $param->deprecated ) ) {
207            if ( !is_bool( $param->deprecated ) && !is_string( $param->deprecated ) ) {
208                return Status::newFatal( 'templatedata-invalid-type',
209                    "params.{$paramName}.deprecated", 'boolean|string' );
210            }
211        }
212
213        // Param.aliases
214        if ( isset( $param->aliases ) ) {
215            if ( !is_array( $param->aliases ) ) {
216                return Status::newFatal( 'templatedata-invalid-type',
217                    "params.{$paramName}.aliases", 'array' );
218            }
219            foreach ( $param->aliases as $i => $alias ) {
220                if ( !is_int( $alias ) && !is_string( $alias ) ) {
221                    return Status::newFatal( 'templatedata-invalid-type',
222                        "params.{$paramName}.aliases[$i]", 'int|string' );
223                }
224            }
225        }
226
227        // Param.autovalue
228        if ( isset( $param->autovalue ) ) {
229            if ( !is_string( $param->autovalue ) ) {
230                // TODO: Validate the autovalue values.
231                return Status::newFatal( 'templatedata-invalid-type',
232                    "params.{$paramName}.autovalue", 'string' );
233            }
234        }
235
236        // Param.default
237        if ( isset( $param->default ) ) {
238            if ( !$this->isValidInterfaceText( $param->default ) ) {
239                return Status::newFatal( 'templatedata-invalid-type',
240                    "params.{$paramName}.default", 'string|object' );
241            }
242        }
243
244        // Param.type
245        if ( isset( $param->type ) ) {
246            if ( !is_string( $param->type ) ) {
247                return Status::newFatal( 'templatedata-invalid-type',
248                    "params.{$paramName}.type", 'string' );
249            }
250
251            if ( !in_array( $param->type, $this->validParameterTypes ) ) {
252                return Status::newFatal( 'templatedata-invalid-value',
253                    'params.' . $paramName . '.type' );
254            }
255        }
256
257        // Param.suggestedvalues
258        if ( isset( $param->suggestedvalues ) ) {
259            if ( !is_array( $param->suggestedvalues ) ) {
260                return Status::newFatal( 'templatedata-invalid-type',
261                    "params.{$paramName}.suggestedvalues", 'array' );
262            }
263            foreach ( $param->suggestedvalues as $i => $value ) {
264                if ( !is_string( $value ) ) {
265                    return Status::newFatal( 'templatedata-invalid-type',
266                        "params.{$paramName}.suggestedvalues[$i]", 'string' );
267                }
268            }
269        }
270
271        return null;
272    }
273
274    /**
275     * @param mixed $paramOrder
276     * @param stdClass $params
277     *
278     * @return Status|null
279     */
280    private function validateParameterOrder( $paramOrder, stdClass $params ): ?Status {
281        if ( $paramOrder === null ) {
282            return null;
283        } elseif ( !is_array( $paramOrder ) ) {
284            return Status::newFatal( 'templatedata-invalid-type', 'paramOrder', 'array' );
285        } elseif ( count( $paramOrder ) < count( (array)$params ) ) {
286            $missing = array_diff( array_keys( (array)$params ), $paramOrder );
287            return Status::newFatal( 'templatedata-invalid-missing',
288                'paramOrder[ "' . implode( '", "', $missing ) . '" ]' );
289        }
290
291        // Validate each of the values corresponds to a parameter and that there are no
292        // duplicates
293        $seen = [];
294        foreach ( $paramOrder as $i => $param ) {
295            if ( !isset( $params->$param ) ) {
296                return Status::newFatal( 'templatedata-invalid-value', "paramOrder[ \"$param\" ]" );
297            }
298            if ( isset( $seen[$param] ) ) {
299                return Status::newFatal( 'templatedata-invalid-duplicate-value',
300                    "paramOrder[$i]", "paramOrder[{$seen[$param]}]", $param );
301            }
302            $seen[$param] = $i;
303        }
304
305        return null;
306    }
307
308    /**
309     * @param mixed $sets
310     * @param stdClass $params
311     *
312     * @return Status|null
313     */
314    private function validateSets( $sets, stdClass $params ): ?Status {
315        if ( !is_array( $sets ) ) {
316            return Status::newFatal( 'templatedata-invalid-type', 'sets', 'array' );
317        }
318
319        foreach ( $sets as $setNr => $setObj ) {
320            if ( !( $setObj instanceof stdClass ) ) {
321                return Status::newFatal( 'templatedata-invalid-value', "sets.{$setNr}" );
322            }
323
324            if ( !isset( $setObj->label ) ) {
325                return Status::newFatal( 'templatedata-invalid-missing', "sets.{$setNr}.label",
326                    'string|object' );
327            }
328
329            if ( !$this->isValidInterfaceText( $setObj->label ) ) {
330                return Status::newFatal( 'templatedata-invalid-type', "sets.{$setNr}.label",
331                    'string|object' );
332            }
333
334            if ( !isset( $setObj->params ) ) {
335                return Status::newFatal( 'templatedata-invalid-missing', "sets.{$setNr}.params",
336                    'array' );
337            }
338
339            if ( !is_array( $setObj->params ) ) {
340                return Status::newFatal( 'templatedata-invalid-type', "sets.{$setNr}.params",
341                    'array' );
342            }
343
344            if ( !count( $setObj->params ) ) {
345                return Status::newFatal( 'templatedata-invalid-empty-array',
346                    "sets.{$setNr}.params" );
347            }
348
349            foreach ( $setObj->params as $i => $param ) {
350                if ( !isset( $params->$param ) ) {
351                    return Status::newFatal( 'templatedata-invalid-value',
352                        "sets.{$setNr}.params[$i]" );
353                }
354            }
355        }
356
357        return null;
358    }
359
360    /**
361     * @param mixed $maps
362     * @param stdClass $params
363     *
364     * @return Status|null
365     */
366    private function validateMaps( $maps, stdClass $params ): ?Status {
367        if ( !( $maps instanceof stdClass ) ) {
368            return Status::newFatal( 'templatedata-invalid-type', 'maps', 'object' );
369        }
370
371        foreach ( $maps as $consumerId => $map ) {
372            if ( !( $map instanceof stdClass ) ) {
373                return Status::newFatal( 'templatedata-invalid-type', "maps.$consumerId",
374                    'object' );
375            }
376
377            foreach ( $map as $key => $value ) {
378                // Key is not validated as this is used by a third-party application
379                // Value must be 2d array of parameter names, 1d array of parameter names, or valid
380                // parameter name
381                if ( is_array( $value ) ) {
382                    foreach ( $value as $key2 => $value2 ) {
383                        if ( is_array( $value2 ) ) {
384                            foreach ( $value2 as $key3 => $value3 ) {
385                                if ( !is_string( $value3 ) ) {
386                                    return Status::newFatal( 'templatedata-invalid-type',
387                                        "maps.{$consumerId}.{$key}[$key2][$key3]", 'string' );
388                                }
389                                if ( !isset( $params->$value3 ) ) {
390                                    return Status::newFatal( 'templatedata-invalid-param', $value3,
391                                        "maps.$consumerId.{$key}[$key2][$key3]" );
392                                }
393                            }
394                        } elseif ( is_string( $value2 ) ) {
395                            if ( !isset( $params->$value2 ) ) {
396                                return Status::newFatal( 'templatedata-invalid-param', $value2,
397                                    "maps.$consumerId.{$key}[$key2]" );
398                            }
399                        } else {
400                            return Status::newFatal( 'templatedata-invalid-type',
401                                "maps.{$consumerId}.{$key}[$key2]", 'string|array' );
402                        }
403                    }
404                } elseif ( is_string( $value ) ) {
405                    if ( !isset( $params->$value ) ) {
406                        return Status::newFatal( 'templatedata-invalid-param', $value,
407                            "maps.{$consumerId}.{$key}" );
408                    }
409                } else {
410                    return Status::newFatal( 'templatedata-invalid-type',
411                        "maps.{$consumerId}.{$key}", 'string|array' );
412                }
413            }
414        }
415
416        return null;
417    }
418
419    private function isValidCustomFormatString( ?string $format ): bool {
420        return $format && preg_match( '/^\n?{{ *_+\n? *\|\n? *_+ *= *_+\n? *}}\n?$/', $format );
421    }
422
423    /**
424     * @param mixed $text
425     * @return bool
426     */
427    private function isValidInterfaceText( $text ): bool {
428        if ( $text instanceof stdClass ) {
429            $isEmpty = true;
430            // An (array) cast would return private/protected properties as well
431            foreach ( get_object_vars( $text ) as $languageCode => $string ) {
432                // TODO: Do we need to validate if these are known interface language codes?
433                if ( !is_string( $languageCode ) ||
434                    ltrim( $languageCode ) === '' ||
435                    !is_string( $string )
436                ) {
437                    return false;
438                }
439                $isEmpty = false;
440            }
441            return !$isEmpty;
442        }
443
444        return is_string( $text );
445    }
446
447}