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