Merge "installer: Add a defined check to overrideConfig method"
[lhc/web/wiklou.git] / includes / libs / ParamValidator / ParamValidator.php
1 <?php
2
3 namespace Wikimedia\ParamValidator;
4
5 use DomainException;
6 use InvalidArgumentException;
7 use Wikimedia\Assert\Assert;
8 use Wikimedia\ObjectFactory;
9
10 /**
11 * Service for formatting and validating API parameters
12 *
13 * A settings array is simply an array with keys being the relevant PARAM_*
14 * constants from this class, TypeDef, and its subclasses.
15 *
16 * As a general overview of the architecture here:
17 * - ParamValidator handles some general validation of the parameter,
18 * then hands off to a TypeDef subclass to validate the specific representation
19 * based on the parameter's type.
20 * - TypeDef subclasses handle conversion between the string representation
21 * submitted by the client and the output PHP data types, validating that the
22 * strings are valid representations of the intended type as they do so.
23 * - ValidationException is used to report fatal errors in the validation back
24 * to the caller, since the return value represents the successful result of
25 * the validation and might be any type or class.
26 * - The Callbacks interface allows ParamValidator to reach out and fetch data
27 * it needs to perform the validation. Currently that includes:
28 * - Fetching the value of the parameter being validated (largely since a generic
29 * caller cannot know whether it needs to fetch a string from $_GET/$_POST or
30 * an array from $_FILES).
31 * - Reporting of non-fatal warnings back to the caller.
32 * - Fetching the "high limits" flag when necessary, to avoid the need for loading
33 * the user unnecessarily.
34 *
35 * @since 1.34
36 */
37 class ParamValidator {
38
39 /**
40 * @name Constants for parameter settings arrays
41 * These constants are keys in the settings array that define how the
42 * parameters coming in from the request are to be interpreted.
43 *
44 * If a constant is associated with a ValidationException, the failure code
45 * and data are described. ValidationExceptions are typically thrown, but
46 * those indicated as "non-fatal" are instead passed to
47 * Callbacks::recordCondition().
48 *
49 * Additional constants may be defined by TypeDef subclasses, or by other
50 * libraries for controlling things like auto-generated parameter documentation.
51 * For purposes of namespacing the constants, the values of all constants
52 * defined by this library begin with 'param-'.
53 *
54 * @{
55 */
56
57 /** (mixed) Default value of the parameter. If omitted, null is the default. */
58 const PARAM_DEFAULT = 'param-default';
59
60 /**
61 * (string|array) Type of the parameter.
62 * Must be a registered type or an array of enumerated values (in which case the "enum"
63 * type must be registered). If omitted, the default is the PHP type of the default value
64 * (see PARAM_DEFAULT).
65 */
66 const PARAM_TYPE = 'param-type';
67
68 /**
69 * (bool) Indicate that the parameter is required.
70 *
71 * ValidationException codes:
72 * - 'missingparam': The parameter is omitted/empty (and no default was set). No data.
73 */
74 const PARAM_REQUIRED = 'param-required';
75
76 /**
77 * (bool) Indicate that the parameter is multi-valued.
78 *
79 * A multi-valued parameter may be submitted in one of several formats. All
80 * of the following result a value of `[ 'a', 'b', 'c' ]`.
81 * - "a|b|c", i.e. pipe-separated.
82 * - "\x1Fa\x1Fb\x1Fc", i.e. separated by U+001F, with a signalling U+001F at the start.
83 * - As a string[], e.g. from a query string like "foo[]=a&foo[]=b&foo[]=c".
84 *
85 * Each of the multiple values is passed individually to the TypeDef.
86 * $options will contain a 'values-list' key holding the entire list.
87 *
88 * By default duplicates are removed from the resulting parameter list. Use
89 * PARAM_ALLOW_DUPLICATES to override that behavior.
90 *
91 * ValidationException codes:
92 * - 'toomanyvalues': More values were supplied than are allowed. See
93 * PARAM_ISMULTI_LIMIT1, PARAM_ISMULTI_LIMIT2, and constructor option
94 * 'ismultiLimits'. Data:
95 * - 'limit': The limit that was exceeded.
96 * - 'unrecognizedvalues': Non-fatal. Invalid values were passed and
97 * PARAM_IGNORE_INVALID_VALUES was set. Data:
98 * - 'values': The unrecognized values.
99 */
100 const PARAM_ISMULTI = 'param-ismulti';
101
102 /**
103 * (int) Maximum number of multi-valued parameter values allowed
104 *
105 * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
106 * the limit when useHighLimits() returns true.
107 *
108 * ValidationException codes:
109 * - 'toomanyvalues': The limit was exceeded. Data:
110 * - 'limit': The limit that was exceeded.
111 */
112 const PARAM_ISMULTI_LIMIT1 = 'param-ismulti-limit1';
113
114 /**
115 * (int) Maximum number of multi-valued parameter values allowed for users
116 * allowed high limits.
117 *
118 * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
119 * the limit when useHighLimits() returns true.
120 *
121 * ValidationException codes:
122 * - 'toomanyvalues': The limit was exceeded. Data:
123 * - 'limit': The limit that was exceeded.
124 */
125 const PARAM_ISMULTI_LIMIT2 = 'param-ismulti-limit2';
126
127 /**
128 * (bool|string) Whether a magic "all values" value exists for multi-valued
129 * enumerated types, and if so what that value is.
130 *
131 * When PARAM_TYPE has a defined set of values and PARAM_ISMULTI is true,
132 * this allows for an asterisk ('*') to be passed in place of a pipe-separated list of
133 * every possible value. If a string is set, it will be used in place of the asterisk.
134 */
135 const PARAM_ALL = 'param-all';
136
137 /**
138 * (bool) Allow the same value to be set more than once when PARAM_ISMULTI is true?
139 *
140 * If not truthy, the set of values will be passed through
141 * `array_values( array_unique() )`. The default is falsey.
142 */
143 const PARAM_ALLOW_DUPLICATES = 'param-allow-duplicates';
144
145 /**
146 * (bool) Indicate that the parameter's value should not be logged.
147 *
148 * ValidationException codes: (non-fatal)
149 * - 'param-sensitive': Always recorded.
150 */
151 const PARAM_SENSITIVE = 'param-sensitive';
152
153 /**
154 * (bool) Indicate that a deprecated parameter was used.
155 *
156 * ValidationException codes: (non-fatal)
157 * - 'param-deprecated': Always recorded.
158 */
159 const PARAM_DEPRECATED = 'param-deprecated';
160
161 /**
162 * (bool) Whether to ignore invalid values.
163 *
164 * This controls whether certain ValidationExceptions are considered fatal
165 * or non-fatal. The default is false.
166 */
167 const PARAM_IGNORE_INVALID_VALUES = 'param-ignore-invalid-values';
168
169 /**@}*/
170
171 /** Magic "all values" value when PARAM_ALL is true. */
172 const ALL_DEFAULT_STRING = '*';
173
174 /** A list of standard type names and types that may be passed as `$typeDefs` to __construct(). */
175 public static $STANDARD_TYPES = [
176 'boolean' => [ 'class' => TypeDef\BooleanDef::class ],
177 'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef::class ],
178 'integer' => [ 'class' => TypeDef\IntegerDef::class ],
179 'limit' => [ 'class' => TypeDef\LimitDef::class ],
180 'float' => [ 'class' => TypeDef\FloatDef::class ],
181 'double' => [ 'class' => TypeDef\FloatDef::class ],
182 'string' => [ 'class' => TypeDef\StringDef::class ],
183 'password' => [ 'class' => TypeDef\PasswordDef::class ],
184 'NULL' => [
185 'class' => TypeDef\StringDef::class,
186 'args' => [ [
187 'allowEmptyWhenRequired' => true,
188 ] ],
189 ],
190 'timestamp' => [ 'class' => TypeDef\TimestampDef::class ],
191 'upload' => [ 'class' => TypeDef\UploadDef::class ],
192 'enum' => [ 'class' => TypeDef\EnumDef::class ],
193 ];
194
195 /** @var Callbacks */
196 private $callbacks;
197
198 /** @var ObjectFactory */
199 private $objectFactory;
200
201 /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */
202 private $typeDefs = [];
203
204 /** @var int Default values for PARAM_ISMULTI_LIMIT1 */
205 private $ismultiLimit1;
206
207 /** @var int Default values for PARAM_ISMULTI_LIMIT2 */
208 private $ismultiLimit2;
209
210 /**
211 * @param Callbacks $callbacks
212 * @param ObjectFactory $objectFactory To turn specs into TypeDef objects
213 * @param array $options Associative array of additional settings
214 * - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::$STANDARD_TYPES will be used.
215 * Pass an empty array if you want to start with no registered types.
216 * - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and
217 * PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`.
218 */
219 public function __construct(
220 Callbacks $callbacks,
221 ObjectFactory $objectFactory,
222 array $options = []
223 ) {
224 $this->callbacks = $callbacks;
225 $this->objectFactory = $objectFactory;
226
227 $this->addTypeDefs( $options['typeDefs'] ?? self::$STANDARD_TYPES );
228 $this->ismultiLimit1 = $options['ismultiLimits'][0] ?? 50;
229 $this->ismultiLimit2 = $options['ismultiLimits'][1] ?? 500;
230 }
231
232 /**
233 * List known type names
234 * @return string[]
235 */
236 public function knownTypes() {
237 return array_keys( $this->typeDefs );
238 }
239
240 /**
241 * Register multiple type handlers
242 *
243 * @see addTypeDef()
244 * @param array $typeDefs Associative array mapping `$name` to `$typeDef`.
245 */
246 public function addTypeDefs( array $typeDefs ) {
247 foreach ( $typeDefs as $name => $def ) {
248 $this->addTypeDef( $name, $def );
249 }
250 }
251
252 /**
253 * Register a type handler
254 *
255 * To allow code to omit PARAM_TYPE in settings arrays to derive the type
256 * from PARAM_DEFAULT, it is strongly recommended that the following types be
257 * registered: "boolean", "integer", "double", "string", "NULL", and "enum".
258 *
259 * When using ObjectFactory specs, the following extra arguments are passed:
260 * - The Callbacks object for this ParamValidator instance.
261 *
262 * @param string $name Type name
263 * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one.
264 */
265 public function addTypeDef( $name, $typeDef ) {
266 Assert::parameterType(
267 implode( '|', [ TypeDef::class, 'array' ] ),
268 $typeDef,
269 '$typeDef'
270 );
271
272 if ( isset( $this->typeDefs[$name] ) ) {
273 throw new InvalidArgumentException( "Type '$name' is already registered" );
274 }
275 $this->typeDefs[$name] = $typeDef;
276 }
277
278 /**
279 * Register a type handler, overriding any existing handler
280 * @see addTypeDef
281 * @param string $name Type name
282 * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type.
283 */
284 public function overrideTypeDef( $name, $typeDef ) {
285 Assert::parameterType(
286 implode( '|', [ TypeDef::class, 'array', 'null' ] ),
287 $typeDef,
288 '$typeDef'
289 );
290
291 if ( $typeDef === null ) {
292 unset( $this->typeDefs[$name] );
293 } else {
294 $this->typeDefs[$name] = $typeDef;
295 }
296 }
297
298 /**
299 * Test if a type is registered
300 * @param string $name Type name
301 * @return bool
302 */
303 public function hasTypeDef( $name ) {
304 return isset( $this->typeDefs[$name] );
305 }
306
307 /**
308 * Get the TypeDef for a type
309 * @param string|array $type Any array is considered equivalent to the string "enum".
310 * @return TypeDef|null
311 */
312 public function getTypeDef( $type ) {
313 if ( is_array( $type ) ) {
314 $type = 'enum';
315 }
316
317 if ( !isset( $this->typeDefs[$type] ) ) {
318 return null;
319 }
320
321 $def = $this->typeDefs[$type];
322 if ( !$def instanceof TypeDef ) {
323 $def = $this->objectFactory->createObject( $def, [
324 'extraArgs' => [ $this->callbacks ],
325 'assertClass' => TypeDef::class,
326 ] );
327 $this->typeDefs[$type] = $def;
328 }
329
330 return $def;
331 }
332
333 /**
334 * Normalize a parameter settings array
335 * @param array|mixed $settings Default value or an array of settings
336 * using PARAM_* constants.
337 * @return array
338 */
339 public function normalizeSettings( $settings ) {
340 // Shorthand
341 if ( !is_array( $settings ) ) {
342 $settings = [
343 self::PARAM_DEFAULT => $settings,
344 ];
345 }
346
347 // When type is not given, determine it from the type of the PARAM_DEFAULT
348 if ( !isset( $settings[self::PARAM_TYPE] ) ) {
349 $settings[self::PARAM_TYPE] = gettype( $settings[self::PARAM_DEFAULT] ?? null );
350 }
351
352 $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
353 if ( $typeDef ) {
354 $settings = $typeDef->normalizeSettings( $settings );
355 }
356
357 return $settings;
358 }
359
360 /**
361 * Fetch and valiate a parameter value using a settings array
362 *
363 * @param string $name Parameter name
364 * @param array|mixed $settings Default value or an array of settings
365 * using PARAM_* constants.
366 * @param array $options Options array, passed through to the TypeDef and Callbacks.
367 * @return mixed Validated parameter value
368 * @throws ValidationException if the value is invalid
369 */
370 public function getValue( $name, $settings, array $options = [] ) {
371 $settings = $this->normalizeSettings( $settings );
372
373 $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
374 if ( !$typeDef ) {
375 throw new DomainException(
376 "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
377 );
378 }
379
380 $value = $typeDef->getValue( $name, $settings, $options );
381
382 if ( $value !== null ) {
383 if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) {
384 $this->callbacks->recordCondition(
385 new ValidationException( $name, $value, $settings, 'param-sensitive', [] ),
386 $options
387 );
388 }
389
390 // Set a warning if a deprecated parameter has been passed
391 if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) {
392 $this->callbacks->recordCondition(
393 new ValidationException( $name, $value, $settings, 'param-deprecated', [] ),
394 $options
395 );
396 }
397 } elseif ( isset( $settings[self::PARAM_DEFAULT] ) ) {
398 $value = $settings[self::PARAM_DEFAULT];
399 }
400
401 return $this->validateValue( $name, $value, $settings, $options );
402 }
403
404 /**
405 * Valiate a parameter value using a settings array
406 *
407 * @param string $name Parameter name
408 * @param null|mixed $value Parameter value
409 * @param array|mixed $settings Default value or an array of settings
410 * using PARAM_* constants.
411 * @param array $options Options array, passed through to the TypeDef and Callbacks.
412 * - An additional option, 'values-list', will be set when processing the
413 * values of a multi-valued parameter.
414 * @return mixed Validated parameter value(s)
415 * @throws ValidationException if the value is invalid
416 */
417 public function validateValue( $name, $value, $settings, array $options = [] ) {
418 $settings = $this->normalizeSettings( $settings );
419
420 $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
421 if ( !$typeDef ) {
422 throw new DomainException(
423 "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
424 );
425 }
426
427 if ( $value === null ) {
428 if ( !empty( $settings[self::PARAM_REQUIRED] ) ) {
429 throw new ValidationException( $name, $value, $settings, 'missingparam', [] );
430 }
431 return null;
432 }
433
434 // Non-multi
435 if ( empty( $settings[self::PARAM_ISMULTI] ) ) {
436 return $typeDef->validate( $name, $value, $settings, $options );
437 }
438
439 // Split the multi-value and validate each parameter
440 $limit1 = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1;
441 $limit2 = $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2;
442 $valuesList = is_array( $value ) ? $value : self::explodeMultiValue( $value, $limit2 + 1 );
443
444 // Handle PARAM_ALL
445 $enumValues = $typeDef->getEnumValues( $name, $settings, $options );
446 if ( is_array( $enumValues ) && isset( $settings[self::PARAM_ALL] ) &&
447 count( $valuesList ) === 1
448 ) {
449 $allValue = is_string( $settings[self::PARAM_ALL] )
450 ? $settings[self::PARAM_ALL]
451 : self::ALL_DEFAULT_STRING;
452 if ( $valuesList[0] === $allValue ) {
453 return $enumValues;
454 }
455 }
456
457 // Avoid checking useHighLimits() unless it's actually necessary
458 $sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks->useHighLimits( $options )
459 ? $limit2
460 : $limit1;
461 if ( count( $valuesList ) > $sizeLimit ) {
462 throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [
463 'limit' => $sizeLimit
464 ] );
465 }
466
467 $options['values-list'] = $valuesList;
468 $validValues = [];
469 $invalidValues = [];
470 foreach ( $valuesList as $v ) {
471 try {
472 $validValues[] = $typeDef->validate( $name, $v, $settings, $options );
473 } catch ( ValidationException $ex ) {
474 if ( empty( $settings[self::PARAM_IGNORE_INVALID_VALUES] ) ) {
475 throw $ex;
476 }
477 $invalidValues[] = $v;
478 }
479 }
480 if ( $invalidValues ) {
481 $this->callbacks->recordCondition(
482 new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [
483 'values' => $invalidValues,
484 ] ),
485 $options
486 );
487 }
488
489 // Throw out duplicates if requested
490 if ( empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) {
491 $validValues = array_values( array_unique( $validValues ) );
492 }
493
494 return $validValues;
495 }
496
497 /**
498 * Split a multi-valued parameter string, like explode()
499 *
500 * Note that, unlike explode(), this will return an empty array when given
501 * an empty string.
502 *
503 * @param string $value
504 * @param int $limit
505 * @return string[]
506 */
507 public static function explodeMultiValue( $value, $limit ) {
508 if ( $value === '' || $value === "\x1f" ) {
509 return [];
510 }
511
512 if ( substr( $value, 0, 1 ) === "\x1f" ) {
513 $sep = "\x1f";
514 $value = substr( $value, 1 );
515 } else {
516 $sep = '|';
517 }
518
519 return explode( $sep, $value, $limit );
520 }
521
522 }