API: Abstract out parameter validation
authorBrad Jorsch <bjorsch@wikimedia.org>
Tue, 22 May 2018 23:23:20 +0000 (19:23 -0400)
committerGergő Tisza <tgr.huwiki@gmail.com>
Sun, 23 Jun 2019 08:58:44 +0000 (10:58 +0200)
With the introduction of a REST API into MediaWiki core, we're going to
want to share parameter validation logic rather than having similar code
in both the Action API and the REST API. This abstracts out parameter
validation logic as a library.

There will be at least two follow-up patches:
* One to add calls in the REST API, plus the interface for the REST API
  to do body validation. Should be reasonably straightforward.
* One to adjust the Action API to use this. That'll be much less
  straightforward, as the Action API needs some MediaWiki-specific types
  (which the REST API might use too in the future) and needs to override
  the defaults on some of the library's checks (to maintain back-compat).

Bug: T142080
Bug: T223239
Change-Id: I5c0cc3a8d686ace97596df5832c450a6a50f902c
Depends-On: Iea05dc439688871c574c639e617765ae88a75ff7

37 files changed:
includes/AutoLoader.php
includes/libs/ParamValidator/Callbacks.php [new file with mode: 0644]
includes/libs/ParamValidator/ParamValidator.php [new file with mode: 0644]
includes/libs/ParamValidator/README.md [new file with mode: 0644]
includes/libs/ParamValidator/SimpleCallbacks.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/BooleanDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/EnumDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/FloatDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/IntegerDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/LimitDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/PasswordDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/PresenceBooleanDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/StringDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/TimestampDef.php [new file with mode: 0644]
includes/libs/ParamValidator/TypeDef/UploadDef.php [new file with mode: 0644]
includes/libs/ParamValidator/Util/UploadedFile.php [new file with mode: 0644]
includes/libs/ParamValidator/Util/UploadedFileStream.php [new file with mode: 0644]
includes/libs/ParamValidator/ValidationException.php [new file with mode: 0644]
tests/common/TestsAutoLoader.php
tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php [new file with mode: 0644]

index 57e4341..b893bc9 100644 (file)
@@ -143,6 +143,7 @@ class AutoLoader {
                        'MediaWiki\\Sparql\\' => __DIR__ . '/sparql/',
                        'MediaWiki\\Storage\\' => __DIR__ . '/Storage/',
                        'MediaWiki\\Tidy\\' => __DIR__ . '/tidy/',
+                       'Wikimedia\\ParamValidator\\' => __DIR__ . '/libs/ParamValidator/',
                        'Wikimedia\\Services\\' => __DIR__ . '/libs/services/',
                ];
        }
diff --git a/includes/libs/ParamValidator/Callbacks.php b/includes/libs/ParamValidator/Callbacks.php
new file mode 100644 (file)
index 0000000..d94a81f
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+
+namespace Wikimedia\ParamValidator;
+
+use Psr\Http\Message\UploadedFileInterface;
+
+/**
+ * Interface defining callbacks needed by ParamValidator
+ *
+ * The user of ParamValidator is expected to pass an object implementing this
+ * interface to ParamValidator's constructor.
+ *
+ * All methods in this interface accept an "options array". This is the same `$options`
+ * passed to ParamValidator::getValue(), ParamValidator::validateValue(), and the like
+ * and is intended for communication of non-global state.
+ *
+ * @since 1.34
+ */
+interface Callbacks {
+
+       /**
+        * Test if a parameter exists in the request
+        * @param string $name Parameter name
+        * @param array $options Options array
+        * @return bool True if present, false if absent.
+        *  Return false for file upload parameters.
+        */
+       public function hasParam( $name, array $options );
+
+       /**
+        * Fetch a value from the request
+        *
+        * Return `$default` for file-upload parameters.
+        *
+        * @param string $name Parameter name to fetch
+        * @param mixed $default Default value to return if the parameter is unset.
+        * @param array $options Options array
+        * @return string|string[]|mixed A string or string[] if the parameter was found,
+        *  or $default if it was not.
+        */
+       public function getValue( $name, $default, array $options );
+
+       /**
+        * Test if a parameter exists as an upload in the request
+        * @param string $name Parameter name
+        * @param array $options Options array
+        * @return bool True if present, false if absent.
+        */
+       public function hasUpload( $name, array $options );
+
+       /**
+        * Fetch data for a file upload
+        * @param string $name Parameter name of the upload
+        * @param array $options Options array
+        * @return UploadedFileInterface|null Uploaded file, or null if there is no file for $name.
+        */
+       public function getUploadedFile( $name, array $options );
+
+       /**
+        * Record non-fatal conditions.
+        * @param ValidationException $condition
+        * @param array $options Options array
+        */
+       public function recordCondition( ValidationException $condition, array $options );
+
+       /**
+        * Indicate whether "high limits" should be used.
+        *
+        * Some settings have multiple limits, one for "normal" users and a higher
+        * one for "privileged" users. This is used to determine which class the
+        * current user is in when necessary.
+        *
+        * @param array $options Options array
+        * @return bool Whether the current user is privileged to use high limits
+        */
+       public function useHighLimits( array $options );
+
+}
diff --git a/includes/libs/ParamValidator/ParamValidator.php b/includes/libs/ParamValidator/ParamValidator.php
new file mode 100644 (file)
index 0000000..1085375
--- /dev/null
@@ -0,0 +1,522 @@
+<?php
+
+namespace Wikimedia\ParamValidator;
+
+use DomainException;
+use InvalidArgumentException;
+use Wikimedia\Assert\Assert;
+use Wikimedia\ObjectFactory;
+
+/**
+ * Service for formatting and validating API parameters
+ *
+ * A settings array is simply an array with keys being the relevant PARAM_*
+ * constants from this class, TypeDef, and its subclasses.
+ *
+ * As a general overview of the architecture here:
+ *  - ParamValidator handles some general validation of the parameter,
+ *    then hands off to a TypeDef subclass to validate the specific representation
+ *    based on the parameter's type.
+ *  - TypeDef subclasses handle conversion between the string representation
+ *    submitted by the client and the output PHP data types, validating that the
+ *    strings are valid representations of the intended type as they do so.
+ *  - ValidationException is used to report fatal errors in the validation back
+ *    to the caller, since the return value represents the successful result of
+ *    the validation and might be any type or class.
+ *  - The Callbacks interface allows ParamValidator to reach out and fetch data
+ *    it needs to perform the validation. Currently that includes:
+ *    - Fetching the value of the parameter being validated (largely since a generic
+ *      caller cannot know whether it needs to fetch a string from $_GET/$_POST or
+ *      an array from $_FILES).
+ *    - Reporting of non-fatal warnings back to the caller.
+ *    - Fetching the "high limits" flag when necessary, to avoid the need for loading
+ *      the user unnecessarily.
+ *
+ * @since 1.34
+ */
+class ParamValidator {
+
+       /**
+        * @name Constants for parameter settings arrays
+        * These constants are keys in the settings array that define how the
+        * parameters coming in from the request are to be interpreted.
+        *
+        * If a constant is associated with a ValidationException, the failure code
+        * and data are described. ValidationExceptions are typically thrown, but
+        * those indicated as "non-fatal" are instead passed to
+        * Callbacks::recordCondition().
+        *
+        * Additional constants may be defined by TypeDef subclasses, or by other
+        * libraries for controlling things like auto-generated parameter documentation.
+        * For purposes of namespacing the constants, the values of all constants
+        * defined by this library begin with 'param-'.
+        *
+        * @{
+        */
+
+       /** (mixed) Default value of the parameter. If omitted, null is the default. */
+       const PARAM_DEFAULT = 'param-default';
+
+       /**
+        * (string|array) Type of the parameter.
+        * Must be a registered type or an array of enumerated values (in which case the "enum"
+        * type must be registered). If omitted, the default is the PHP type of the default value
+        * (see PARAM_DEFAULT).
+        */
+       const PARAM_TYPE = 'param-type';
+
+       /**
+        * (bool) Indicate that the parameter is required.
+        *
+        * ValidationException codes:
+        *  - 'missingparam': The parameter is omitted/empty (and no default was set). No data.
+        */
+       const PARAM_REQUIRED = 'param-required';
+
+       /**
+        * (bool) Indicate that the parameter is multi-valued.
+        *
+        * A multi-valued parameter may be submitted in one of several formats. All
+        * of the following result a value of `[ 'a', 'b', 'c' ]`.
+        *  - "a|b|c", i.e. pipe-separated.
+        *  - "\x1Fa\x1Fb\x1Fc", i.e. separated by U+001F, with a signalling U+001F at the start.
+        *  - As a string[], e.g. from a query string like "foo[]=a&foo[]=b&foo[]=c".
+        *
+        * Each of the multiple values is passed individually to the TypeDef.
+        * $options will contain a 'values-list' key holding the entire list.
+        *
+        * By default duplicates are removed from the resulting parameter list. Use
+        * PARAM_ALLOW_DUPLICATES to override that behavior.
+        *
+        * ValidationException codes:
+        *  - 'toomanyvalues': More values were supplied than are allowed. See
+        *    PARAM_ISMULTI_LIMIT1, PARAM_ISMULTI_LIMIT2, and constructor option
+        *    'ismultiLimits'. Data:
+        *     - 'limit': The limit that was exceeded.
+        *  - 'unrecognizedvalues': Non-fatal. Invalid values were passed and
+        *    PARAM_IGNORE_INVALID_VALUES was set. Data:
+        *     - 'values': The unrecognized values.
+        */
+       const PARAM_ISMULTI = 'param-ismulti';
+
+       /**
+        * (int) Maximum number of multi-valued parameter values allowed
+        *
+        * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
+        * the limit when useHighLimits() returns true.
+        *
+        * ValidationException codes:
+        *  - 'toomanyvalues': The limit was exceeded. Data:
+        *     - 'limit': The limit that was exceeded.
+        */
+       const PARAM_ISMULTI_LIMIT1 = 'param-ismulti-limit1';
+
+       /**
+        * (int) Maximum number of multi-valued parameter values allowed for users
+        * allowed high limits.
+        *
+        * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
+        * the limit when useHighLimits() returns true.
+        *
+        * ValidationException codes:
+        *  - 'toomanyvalues': The limit was exceeded. Data:
+        *     - 'limit': The limit that was exceeded.
+        */
+       const PARAM_ISMULTI_LIMIT2 = 'param-ismulti-limit2';
+
+       /**
+        * (bool|string) Whether a magic "all values" value exists for multi-valued
+        * enumerated types, and if so what that value is.
+        *
+        * When PARAM_TYPE has a defined set of values and PARAM_ISMULTI is true,
+        * this allows for an asterisk ('*') to be passed in place of a pipe-separated list of
+        * every possible value. If a string is set, it will be used in place of the asterisk.
+        */
+       const PARAM_ALL = 'param-all';
+
+       /**
+        * (bool) Allow the same value to be set more than once when PARAM_ISMULTI is true?
+        *
+        * If not truthy, the set of values will be passed through
+        * `array_values( array_unique() )`. The default is falsey.
+        */
+       const PARAM_ALLOW_DUPLICATES = 'param-allow-duplicates';
+
+       /**
+        * (bool) Indicate that the parameter's value should not be logged.
+        *
+        * ValidationException codes: (non-fatal)
+        *  - 'param-sensitive': Always recorded.
+        */
+       const PARAM_SENSITIVE = 'param-sensitive';
+
+       /**
+        * (bool) Indicate that a deprecated parameter was used.
+        *
+        * ValidationException codes: (non-fatal)
+        *  - 'param-deprecated': Always recorded.
+        */
+       const PARAM_DEPRECATED = 'param-deprecated';
+
+       /**
+        * (bool) Whether to ignore invalid values.
+        *
+        * This controls whether certain ValidationExceptions are considered fatal
+        * or non-fatal. The default is false.
+        */
+       const PARAM_IGNORE_INVALID_VALUES = 'param-ignore-invalid-values';
+
+       /**@}*/
+
+       /** Magic "all values" value when PARAM_ALL is true. */
+       const ALL_DEFAULT_STRING = '*';
+
+       /** A list of standard type names and types that may be passed as `$typeDefs` to __construct(). */
+       public static $STANDARD_TYPES = [
+               'boolean' => [ 'class' => TypeDef\BooleanDef::class ],
+               'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef::class ],
+               'integer' => [ 'class' => TypeDef\IntegerDef::class ],
+               'limit' => [ 'class' => TypeDef\LimitDef::class ],
+               'float' => [ 'class' => TypeDef\FloatDef::class ],
+               'double' => [ 'class' => TypeDef\FloatDef::class ],
+               'string' => [ 'class' => TypeDef\StringDef::class ],
+               'password' => [ 'class' => TypeDef\PasswordDef::class ],
+               'NULL' => [
+                       'class' => TypeDef\StringDef::class,
+                       'args' => [ [
+                               'allowEmptyWhenRequired' => true,
+                       ] ],
+               ],
+               'timestamp' => [ 'class' => TypeDef\TimestampDef::class ],
+               'upload' => [ 'class' => TypeDef\UploadDef::class ],
+               'enum' => [ 'class' => TypeDef\EnumDef::class ],
+       ];
+
+       /** @var Callbacks */
+       private $callbacks;
+
+       /** @var ObjectFactory */
+       private $objectFactory;
+
+       /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */
+       private $typeDefs = [];
+
+       /** @var int Default values for PARAM_ISMULTI_LIMIT1 */
+       private $ismultiLimit1;
+
+       /** @var int Default values for PARAM_ISMULTI_LIMIT2 */
+       private $ismultiLimit2;
+
+       /**
+        * @param Callbacks $callbacks
+        * @param ObjectFactory $objectFactory To turn specs into TypeDef objects
+        * @param array $options Associative array of additional settings
+        *  - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::$STANDARD_TYPES will be used.
+        *    Pass an empty array if you want to start with no registered types.
+        *  - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and
+        *    PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`.
+        */
+       public function __construct(
+               Callbacks $callbacks,
+               ObjectFactory $objectFactory,
+               array $options = []
+       ) {
+               $this->callbacks = $callbacks;
+               $this->objectFactory = $objectFactory;
+
+               $this->addTypeDefs( $options['typeDefs'] ?? self::$STANDARD_TYPES );
+               $this->ismultiLimit1 = $options['ismultiLimits'][0] ?? 50;
+               $this->ismultiLimit2 = $options['ismultiLimits'][1] ?? 500;
+       }
+
+       /**
+        * List known type names
+        * @return string[]
+        */
+       public function knownTypes() {
+               return array_keys( $this->typeDefs );
+       }
+
+       /**
+        * Register multiple type handlers
+        *
+        * @see addTypeDef()
+        * @param array $typeDefs Associative array mapping `$name` to `$typeDef`.
+        */
+       public function addTypeDefs( array $typeDefs ) {
+               foreach ( $typeDefs as $name => $def ) {
+                       $this->addTypeDef( $name, $def );
+               }
+       }
+
+       /**
+        * Register a type handler
+        *
+        * To allow code to omit PARAM_TYPE in settings arrays to derive the type
+        * from PARAM_DEFAULT, it is strongly recommended that the following types be
+        * registered: "boolean", "integer", "double", "string", "NULL", and "enum".
+        *
+        * When using ObjectFactory specs, the following extra arguments are passed:
+        * - The Callbacks object for this ParamValidator instance.
+        *
+        * @param string $name Type name
+        * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one.
+        */
+       public function addTypeDef( $name, $typeDef ) {
+               Assert::parameterType(
+                       implode( '|', [ TypeDef::class, 'array' ] ),
+                       $typeDef,
+                       '$typeDef'
+               );
+
+               if ( isset( $this->typeDefs[$name] ) ) {
+                       throw new InvalidArgumentException( "Type '$name' is already registered" );
+               }
+               $this->typeDefs[$name] = $typeDef;
+       }
+
+       /**
+        * Register a type handler, overriding any existing handler
+        * @see addTypeDef
+        * @param string $name Type name
+        * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type.
+        */
+       public function overrideTypeDef( $name, $typeDef ) {
+               Assert::parameterType(
+                       implode( '|', [ TypeDef::class, 'array', 'null' ] ),
+                       $typeDef,
+                       '$typeDef'
+               );
+
+               if ( $typeDef === null ) {
+                       unset( $this->typeDefs[$name] );
+               } else {
+                       $this->typeDefs[$name] = $typeDef;
+               }
+       }
+
+       /**
+        * Test if a type is registered
+        * @param string $name Type name
+        * @return bool
+        */
+       public function hasTypeDef( $name ) {
+               return isset( $this->typeDefs[$name] );
+       }
+
+       /**
+        * Get the TypeDef for a type
+        * @param string|array $type Any array is considered equivalent to the string "enum".
+        * @return TypeDef|null
+        */
+       public function getTypeDef( $type ) {
+               if ( is_array( $type ) ) {
+                       $type = 'enum';
+               }
+
+               if ( !isset( $this->typeDefs[$type] ) ) {
+                       return null;
+               }
+
+               $def = $this->typeDefs[$type];
+               if ( !$def instanceof TypeDef ) {
+                       $def = $this->objectFactory->createObject( $def, [
+                               'extraArgs' => [ $this->callbacks ],
+                               'assertClass' => TypeDef::class,
+                       ] );
+                       $this->typeDefs[$type] = $def;
+               }
+
+               return $def;
+       }
+
+       /**
+        * Normalize a parameter settings array
+        * @param array|mixed $settings Default value or an array of settings
+        *  using PARAM_* constants.
+        * @return array
+        */
+       public function normalizeSettings( $settings ) {
+               // Shorthand
+               if ( !is_array( $settings ) ) {
+                       $settings = [
+                               self::PARAM_DEFAULT => $settings,
+                       ];
+               }
+
+               // When type is not given, determine it from the type of the PARAM_DEFAULT
+               if ( !isset( $settings[self::PARAM_TYPE] ) ) {
+                       $settings[self::PARAM_TYPE] = gettype( $settings[self::PARAM_DEFAULT] ?? null );
+               }
+
+               $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
+               if ( $typeDef ) {
+                       $settings = $typeDef->normalizeSettings( $settings );
+               }
+
+               return $settings;
+       }
+
+       /**
+        * Fetch and valiate a parameter value using a settings array
+        *
+        * @param string $name Parameter name
+        * @param array|mixed $settings Default value or an array of settings
+        *  using PARAM_* constants.
+        * @param array $options Options array, passed through to the TypeDef and Callbacks.
+        * @return mixed Validated parameter value
+        * @throws ValidationException if the value is invalid
+        */
+       public function getValue( $name, $settings, array $options = [] ) {
+               $settings = $this->normalizeSettings( $settings );
+
+               $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
+               if ( !$typeDef ) {
+                       throw new DomainException(
+                               "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
+                       );
+               }
+
+               $value = $typeDef->getValue( $name, $settings, $options );
+
+               if ( $value !== null ) {
+                       if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) {
+                               $this->callbacks->recordCondition(
+                                       new ValidationException( $name, $value, $settings, 'param-sensitive', [] ),
+                                       $options
+                               );
+                       }
+
+                       // Set a warning if a deprecated parameter has been passed
+                       if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) {
+                               $this->callbacks->recordCondition(
+                                       new ValidationException( $name, $value, $settings, 'param-deprecated', [] ),
+                                       $options
+                               );
+                       }
+               } elseif ( isset( $settings[self::PARAM_DEFAULT] ) ) {
+                       $value = $settings[self::PARAM_DEFAULT];
+               }
+
+               return $this->validateValue( $name, $value, $settings, $options );
+       }
+
+       /**
+        * Valiate a parameter value using a settings array
+        *
+        * @param string $name Parameter name
+        * @param null|mixed $value Parameter value
+        * @param array|mixed $settings Default value or an array of settings
+        *  using PARAM_* constants.
+        * @param array $options Options array, passed through to the TypeDef and Callbacks.
+        *  - An additional option, 'values-list', will be set when processing the
+        *    values of a multi-valued parameter.
+        * @return mixed Validated parameter value(s)
+        * @throws ValidationException if the value is invalid
+        */
+       public function validateValue( $name, $value, $settings, array $options = [] ) {
+               $settings = $this->normalizeSettings( $settings );
+
+               $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
+               if ( !$typeDef ) {
+                       throw new DomainException(
+                               "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
+                       );
+               }
+
+               if ( $value === null ) {
+                       if ( !empty( $settings[self::PARAM_REQUIRED] ) ) {
+                               throw new ValidationException( $name, $value, $settings, 'missingparam', [] );
+                       }
+                       return null;
+               }
+
+               // Non-multi
+               if ( empty( $settings[self::PARAM_ISMULTI] ) ) {
+                       return $typeDef->validate( $name, $value, $settings, $options );
+               }
+
+               // Split the multi-value and validate each parameter
+               $limit1 = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1;
+               $limit2 = $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2;
+               $valuesList = is_array( $value ) ? $value : self::explodeMultiValue( $value, $limit2 + 1 );
+
+               // Handle PARAM_ALL
+               $enumValues = $typeDef->getEnumValues( $name, $settings, $options );
+               if ( is_array( $enumValues ) && isset( $settings[self::PARAM_ALL] ) &&
+                       count( $valuesList ) === 1
+               ) {
+                       $allValue = is_string( $settings[self::PARAM_ALL] )
+                               ? $settings[self::PARAM_ALL]
+                               : self::ALL_DEFAULT_STRING;
+                       if ( $valuesList[0] === $allValue ) {
+                               return $enumValues;
+                       }
+               }
+
+               // Avoid checking useHighLimits() unless it's actually necessary
+               $sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks->useHighLimits( $options )
+                       ? $limit2
+                       : $limit1;
+               if ( count( $valuesList ) > $sizeLimit ) {
+                       throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [
+                               'limit' => $sizeLimit
+                       ] );
+               }
+
+               $options['values-list'] = $valuesList;
+               $validValues = [];
+               $invalidValues = [];
+               foreach ( $valuesList as $v ) {
+                       try {
+                               $validValues[] = $typeDef->validate( $name, $v, $settings, $options );
+                       } catch ( ValidationException $ex ) {
+                               if ( empty( $settings[self::PARAM_IGNORE_INVALID_VALUES] ) ) {
+                                       throw $ex;
+                               }
+                               $invalidValues[] = $v;
+                       }
+               }
+               if ( $invalidValues ) {
+                       $this->callbacks->recordCondition(
+                               new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [
+                                       'values' => $invalidValues,
+                               ] ),
+                               $options
+                       );
+               }
+
+               // Throw out duplicates if requested
+               if ( empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) {
+                       $validValues = array_values( array_unique( $validValues ) );
+               }
+
+               return $validValues;
+       }
+
+       /**
+        * Split a multi-valued parameter string, like explode()
+        *
+        * Note that, unlike explode(), this will return an empty array when given
+        * an empty string.
+        *
+        * @param string $value
+        * @param int $limit
+        * @return string[]
+        */
+       public static function explodeMultiValue( $value, $limit ) {
+               if ( $value === '' || $value === "\x1f" ) {
+                       return [];
+               }
+
+               if ( substr( $value, 0, 1 ) === "\x1f" ) {
+                       $sep = "\x1f";
+                       $value = substr( $value, 1 );
+               } else {
+                       $sep = '|';
+               }
+
+               return explode( $sep, $value, $limit );
+       }
+
+}
diff --git a/includes/libs/ParamValidator/README.md b/includes/libs/ParamValidator/README.md
new file mode 100644 (file)
index 0000000..dd992a4
--- /dev/null
@@ -0,0 +1,58 @@
+Wikimedia API Parameter Validator
+=================================
+
+This library implements a system for processing and validating parameters to an
+API from data like that in PHP's `$_GET`, `$_POST`, and `$_FILES` arrays, based
+on a declarative definition of available parameters.
+
+Usage
+-----
+
+<pre lang="php">
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\TypeDef\IntegerDef;
+use Wikimedia\ParamValidator\SimpleCallbacks as ParamValidatorCallbacks;
+use Wikimedia\ParamValidator\ValidationException;
+
+$validator = new ParamValidator(
+       new ParamValidatorCallbacks( $_POST + $_GET, $_FILES ),
+       $serviceContainer->getObjectFactory()
+);
+
+try {
+       $intValue = $validator->getValue( 'intParam', [
+                       ParamValidator::PARAM_TYPE => 'integer',
+                       ParamValidator::PARAM_DEFAULT => 0,
+                       IntegerDef::PARAM_MIN => 0,
+                       IntegerDef::PARAM_MAX => 5,
+       ] );
+} catch ( ValidationException $ex ) {
+       $error = lookupI18nMessage( 'param-validator-error-' . $ex->getFailureCode() );
+       echo "Validation error: $error\n";
+}
+</pre>
+
+I18n
+----
+
+This library is designed to generate output in a manner suited to use with an
+i18n system. To that end, errors and such are indicated by means of "codes"
+consisting of ASCII lowercase letters, digits, and hyphen (and always beginning
+with a letter).
+
+Additional details about each error, such as the allowed range for an integer
+value, are similarly returned by means of associative arrays with keys being
+similar "code" strings and values being strings, integers, or arrays of strings
+that are intended to be formatted as a list (e.g. joined with commas). The
+details for any particular "message" will also always have the same keys in the
+same order to facilitate use with i18n systems using positional rather than
+named parameters.
+
+For possible codes and their parameters, see the documentation of the relevant
+`PARAM_*` constants and TypeDef classes.
+
+Running tests
+-------------
+
+    composer install --prefer-dist
+    composer test
diff --git a/includes/libs/ParamValidator/SimpleCallbacks.php b/includes/libs/ParamValidator/SimpleCallbacks.php
new file mode 100644 (file)
index 0000000..77dab92
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+namespace Wikimedia\ParamValidator;
+
+use Wikimedia\ParamValidator\Util\UploadedFile;
+
+/**
+ * Simple Callbacks implementation for $_GET/$_POST/$_FILES data
+ *
+ * Options array keys used by this class:
+ *  - 'useHighLimits': (bool) Return value from useHighLimits()
+ *
+ * @since 1.34
+ */
+class SimpleCallbacks implements Callbacks {
+
+       /** @var (string|string[])[] $_GET/$_POST data */
+       private $params;
+
+       /** @var (array|UploadedFile)[] $_FILES data or UploadedFile instances */
+       private $files;
+
+       /** @var array Any recorded conditions */
+       private $conditions = [];
+
+       /**
+        * @param (string|string[])[] $params Data from $_POST + $_GET
+        * @param array[] $files Data from $_FILES
+        */
+       public function __construct( array $params, array $files = [] ) {
+               $this->params = $params;
+               $this->files = $files;
+       }
+
+       public function hasParam( $name, array $options ) {
+               return isset( $this->params[$name] );
+       }
+
+       public function getValue( $name, $default, array $options ) {
+               return $this->params[$name] ?? $default;
+       }
+
+       public function hasUpload( $name, array $options ) {
+               return isset( $this->files[$name] );
+       }
+
+       public function getUploadedFile( $name, array $options ) {
+               $file = $this->files[$name] ?? null;
+               if ( $file && !$file instanceof UploadedFile ) {
+                       $file = new UploadedFile( $file );
+                       $this->files[$name] = $file;
+               }
+               return $file;
+       }
+
+       public function recordCondition( ValidationException $condition, array $options ) {
+               $this->conditions[] = $condition;
+       }
+
+       /**
+        * Fetch any recorded conditions
+        * @return array[]
+        */
+       public function getRecordedConditions() {
+               return $this->conditions;
+       }
+
+       /**
+        * Clear any recorded conditions
+        */
+       public function clearRecordedConditions() {
+               $this->conditions = [];
+       }
+
+       public function useHighLimits( array $options ) {
+               return !empty( $options['useHighLimits'] );
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef.php b/includes/libs/ParamValidator/TypeDef.php
new file mode 100644 (file)
index 0000000..0d54add
--- /dev/null
@@ -0,0 +1,148 @@
+<?php
+
+namespace Wikimedia\ParamValidator;
+
+/**
+ * Base definition for ParamValidator types.
+ *
+ * All methods in this class accept an "options array". This is just the `$options`
+ * passed to ParamValidator::getValue(), ParamValidator::validateValue(), and the like
+ * and is intended for communication of non-global state.
+ *
+ * @since 1.34
+ */
+abstract class TypeDef {
+
+       /** @var Callbacks */
+       protected $callbacks;
+
+       public function __construct( Callbacks $callbacks ) {
+               $this->callbacks = $callbacks;
+       }
+
+       /**
+        * Get the value from the request
+        *
+        * @note Only override this if you need to use something other than
+        *  $this->callbacks->getValue() to fetch the value. Reformatting from a
+        *  string should typically be done by self::validate().
+        * @note Handling of ParamValidator::PARAM_DEFAULT should be left to ParamValidator,
+        *  as should PARAM_REQUIRED and the like.
+        *
+        * @param string $name Parameter name being fetched.
+        * @param array $settings Parameter settings array.
+        * @param array $options Options array.
+        * @return null|mixed Return null if the value wasn't present, otherwise a
+        *  value to be passed to self::validate().
+        */
+       public function getValue( $name, array $settings, array $options ) {
+               return $this->callbacks->getValue( $name, null, $options );
+       }
+
+       /**
+        * Validate the value
+        *
+        * When ParamValidator is processing a multi-valued parameter, this will be
+        * called once for each of the supplied values. Which may mean zero calls.
+        *
+        * When getValue() returned null, this will not be called.
+        *
+        * @param string $name Parameter name being validated.
+        * @param mixed $value Value to validate, from getValue().
+        * @param array $settings Parameter settings array.
+        * @param array $options Options array. Note the following values that may be set
+        *  by ParamValidator:
+        *   - values-list: (string[]) If defined, values of a multi-valued parameter are being processed
+        *     (and this array holds the full set of values).
+        * @return mixed Validated value
+        * @throws ValidationException if the value is invalid
+        */
+       abstract public function validate( $name, $value, array $settings, array $options );
+
+       /**
+        * Normalize a settings array
+        * @param array $settings
+        * @return array
+        */
+       public function normalizeSettings( array $settings ) {
+               return $settings;
+       }
+
+       /**
+        * Get the values for enum-like parameters
+        *
+        * This is primarily intended for documentation and implementation of
+        * PARAM_ALL; it is the responsibility of the TypeDef to ensure that validate()
+        * accepts the values returned here.
+        *
+        * @param string $name Parameter name being validated.
+        * @param array $settings Parameter settings array.
+        * @param array $options Options array.
+        * @return array|null All possible enumerated values, or null if this is
+        *  not an enumeration.
+        */
+       public function getEnumValues( $name, array $settings, array $options ) {
+               return null;
+       }
+
+       /**
+        * Convert a value to a string representation.
+        *
+        * This is intended as the inverse of getValue() and validate(): this
+        * should accept anything returned by those methods or expected to be used
+        * as PARAM_DEFAULT, and if the string from this method is passed in as client
+        * input or PARAM_DEFAULT it should give equivalent output from validate().
+        *
+        * @param string $name Parameter name being converted.
+        * @param mixed $value Parameter value being converted. Do not pass null.
+        * @param array $settings Parameter settings array.
+        * @param array $options Options array.
+        * @return string|null Return null if there is no representation of $value
+        *  reasonably satisfying the description given.
+        */
+       public function stringifyValue( $name, $value, array $settings, array $options ) {
+               return (string)$value;
+       }
+
+       /**
+        * "Describe" a settings array
+        *
+        * This is intended to format data about a settings array using this type
+        * in a way that would be useful for automatically generated documentation
+        * or a machine-readable interface specification.
+        *
+        * Keys in the description array should follow the same guidelines as the
+        * code described for ValidationException.
+        *
+        * By default, each value in the description array is a single string,
+        * integer, or array. When `$options['compact']` is supplied, each value is
+        * instead an array of such and related values may be combined. For example,
+        * a non-compact description for an integer type might include
+        * `[ 'default' => 0, 'min' => 0, 'max' => 5 ]`, while in compact mode it might
+        * instead report `[ 'default' => [ 'value' => 0 ], 'minmax' => [ 'min' => 0, 'max' => 5 ] ]`
+        * to facilitate auto-generated documentation turning that 'minmax' into
+        * "Value must be between 0 and 5" rather than disconnected statements
+        * "Value must be >= 0" and "Value must be <= 5".
+        *
+        * @param string $name Parameter name being described.
+        * @param array $settings Parameter settings array.
+        * @param array $options Options array. Defined options for this base class are:
+        *  - 'compact': (bool) Enable compact mode, as described above.
+        * @return array
+        */
+       public function describeSettings( $name, array $settings, array $options ) {
+               $compact = !empty( $options['compact'] );
+
+               $ret = [];
+
+               if ( isset( $settings[ParamValidator::PARAM_DEFAULT] ) ) {
+                       $value = $this->stringifyValue(
+                               $name, $settings[ParamValidator::PARAM_DEFAULT], $settings, $options
+                       );
+                       $ret['default'] = $compact ? [ 'value' => $value ] : $value;
+               }
+
+               return $ret;
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/BooleanDef.php b/includes/libs/ParamValidator/TypeDef/BooleanDef.php
new file mode 100644 (file)
index 0000000..f77c930
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\TypeDef;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * Type definition for boolean types
+ *
+ * This type accepts certain defined strings to mean 'true' or 'false'.
+ * The result from validate() is a PHP boolean.
+ *
+ * ValidationException codes:
+ *  - 'badbool': The value is not a recognized boolean. Data:
+ *     - 'truevals': List of recognized values for "true".
+ *     - 'falsevals': List of recognized values for "false".
+ *
+ * @since 1.34
+ */
+class BooleanDef extends TypeDef {
+
+       public static $TRUEVALS = [ 'true', 't', 'yes', 'y', 'on', '1' ];
+       public static $FALSEVALS = [ 'false', 'f', 'no', 'n', 'off', '0' ];
+
+       public function validate( $name, $value, array $settings, array $options ) {
+               $value = strtolower( $value );
+               if ( in_array( $value, self::$TRUEVALS, true ) ) {
+                       return true;
+               }
+               if ( $value === '' || in_array( $value, self::$FALSEVALS, true ) ) {
+                       return false;
+               }
+
+               throw new ValidationException( $name, $value, $settings, 'badbool', [
+                       'truevals' => self::$TRUEVALS,
+                       'falsevals' => array_merge( self::$FALSEVALS, [ 'the empty string' ] ),
+               ] );
+       }
+
+       public function stringifyValue( $name, $value, array $settings, array $options ) {
+               return $value ? self::$TRUEVALS[0] : self::$FALSEVALS[0];
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/EnumDef.php b/includes/libs/ParamValidator/TypeDef/EnumDef.php
new file mode 100644 (file)
index 0000000..0f4f690
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\TypeDef;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * Type definition for enumeration types.
+ *
+ * This class expects that PARAM_TYPE is an array of allowed values. Subclasses
+ * may override getEnumValues() to determine the allowed values differently.
+ *
+ * The result from validate() is one of the defined values.
+ *
+ * ValidationException codes:
+ *  - 'badvalue': The value is not a recognized value. No data.
+ *  - 'notmulti': PARAM_ISMULTI is not set and the unrecognized value seems to
+ *     be an attempt at using multiple values. No data.
+ *
+ * Additional codes may be generated when using certain PARAM constants. See
+ * the constants' documentation for details.
+ *
+ * @since 1.34
+ */
+class EnumDef extends TypeDef {
+
+       /**
+        * (array) Associative array of deprecated values.
+        *
+        * Keys are the deprecated parameter values, values are included in
+        * the ValidationException. If value is null, the parameter is considered
+        * not actually deprecated.
+        *
+        * Note that this does not add any values to the enumeration, it only
+        * documents existing values as being deprecated.
+        *
+        * ValidationException codes: (non-fatal)
+        *  - 'deprecated-value': A deprecated value was encountered. Data:
+        *     - 'flag': The value from the associative array.
+        */
+       const PARAM_DEPRECATED_VALUES = 'param-deprecated-values';
+
+       public function validate( $name, $value, array $settings, array $options ) {
+               $values = $this->getEnumValues( $name, $settings, $options );
+
+               if ( in_array( $value, $values, true ) ) {
+                       // Set a warning if a deprecated parameter value has been passed
+                       if ( isset( $settings[self::PARAM_DEPRECATED_VALUES][$value] ) ) {
+                               $this->callbacks->recordCondition(
+                                       new ValidationException( $name, $value, $settings, 'deprecated-value', [
+                                               'flag' => $settings[self::PARAM_DEPRECATED_VALUES][$value],
+                                       ] ),
+                                       $options
+                               );
+                       }
+
+                       return $value;
+               }
+
+               if ( !isset( $options['values-list'] ) &&
+                       count( ParamValidator::explodeMultiValue( $value, 2 ) ) > 1
+               ) {
+                       throw new ValidationException( $name, $value, $settings, 'notmulti', [] );
+               } else {
+                       throw new ValidationException( $name, $value, $settings, 'badvalue', [] );
+               }
+       }
+
+       public function getEnumValues( $name, array $settings, array $options ) {
+               return $settings[ParamValidator::PARAM_TYPE];
+       }
+
+       public function stringifyValue( $name, $value, array $settings, array $options ) {
+               if ( !is_array( $value ) ) {
+                       return parent::stringifyValue( $name, $value, $settings, $options );
+               }
+
+               foreach ( $value as $v ) {
+                       if ( strpos( $v, '|' ) !== false ) {
+                               return "\x1f" . implode( "\x1f", $value );
+                       }
+               }
+               return implode( '|', $value );
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/FloatDef.php b/includes/libs/ParamValidator/TypeDef/FloatDef.php
new file mode 100644 (file)
index 0000000..0a204b3
--- /dev/null
@@ -0,0 +1,72 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\TypeDef;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * Type definition for a floating-point type
+ *
+ * A valid representation consists of:
+ *  - an optional sign (`+` or `-`)
+ *  - a decimal number, using `.` as the decimal separator and no grouping
+ *  - an optional E-notation suffix: the letter 'e' or 'E', an optional
+ *    sign, and an integer
+ *
+ * Thus, for example, "12", "-.4", "6.022e23", or "+1.7e-10".
+ *
+ * The result from validate() is a PHP float.
+ *
+ * ValidationException codes:
+ *  - 'badfloat': The value was invalid. No data.
+ *  - 'notfinite': The value was in a valid format, but conversion resulted in
+ *    infinity or NAN.
+ *
+ * @since 1.34
+ */
+class FloatDef extends TypeDef {
+
+       public function validate( $name, $value, array $settings, array $options ) {
+               // Use a regex so as to avoid any potential oddness PHP's default conversion might allow.
+               if ( !preg_match( '/^[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?$/D', $value ) ) {
+                       throw new ValidationException( $name, $value, $settings, 'badfloat', [] );
+               }
+
+               $ret = (float)$value;
+               if ( !is_finite( $ret ) ) {
+                       throw new ValidationException( $name, $value, $settings, 'notfinite', [] );
+               }
+
+               return $ret;
+       }
+
+       /**
+        * Attempt to fix locale weirdness
+        *
+        * We don't have any usable number formatting function that's not locale-aware,
+        * and `setlocale()` isn't safe in multithreaded environments. Sigh.
+        *
+        * @param string $value Value to fix
+        * @return string
+        */
+       private function fixLocaleWeirdness( $value ) {
+               $localeData = localeconv();
+               if ( $localeData['decimal_point'] !== '.' ) {
+                       $value = strtr( $value, [
+                               $localeData['decimal_point'] => '.',
+                               // PHP's number formatting currently uses only the first byte from 'decimal_point'.
+                               // See upstream bug https://bugs.php.net/bug.php?id=78113
+                               $localeData['decimal_point'][0] => '.',
+                       ] );
+               }
+               return $value;
+       }
+
+       public function stringifyValue( $name, $value, array $settings, array $options ) {
+               // Ensure sufficient precision for round-tripping. PHP_FLOAT_DIG was added in PHP 7.2.
+               $digits = defined( 'PHP_FLOAT_DIG' ) ? PHP_FLOAT_DIG : 15;
+               return $this->fixLocaleWeirdness( sprintf( "%.{$digits}g", $value ) );
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/IntegerDef.php b/includes/libs/ParamValidator/TypeDef/IntegerDef.php
new file mode 100644 (file)
index 0000000..556301b
--- /dev/null
@@ -0,0 +1,171 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\TypeDef;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * Type definition for integer types
+ *
+ * A valid representation consists of an optional sign (`+` or `-`) followed by
+ * one or more decimal digits.
+ *
+ * The result from validate() is a PHP integer.
+ *
+ * * ValidationException codes:
+ *  - 'badinteger': The value was invalid or could not be represented as a PHP
+ *    integer. No data.
+ *
+ * Additional codes may be generated when using certain PARAM constants. See
+ * the constants' documentation for details.
+ *
+ * @since 1.34
+ */
+class IntegerDef extends TypeDef {
+
+       /**
+        * (bool) Whether to enforce the specified range.
+        *
+        * If set and truthy, ValidationExceptions from PARAM_MIN, PARAM_MAX, and
+        * PARAM_MAX2 are non-fatal.
+        */
+       const PARAM_IGNORE_RANGE = 'param-ignore-range';
+
+       /**
+        * (int) Minimum allowed value.
+        *
+        * ValidationException codes:
+        *  - 'belowminimum': The value was below the allowed minimum. Data:
+        *     - 'min': Allowed minimum, or empty string if there is none.
+        *     - 'max': Allowed (normal) maximum, or empty string if there is none.
+        *     - 'max2': Allowed (high limits) maximum, or empty string if there is none.
+        */
+       const PARAM_MIN = 'param-min';
+
+       /**
+        * (int) Maximum allowed value (normal limits)
+        *
+        * ValidationException codes:
+        *  - 'abovemaximum': The value was above the allowed maximum. Data:
+        *     - 'min': Allowed minimum, or empty string if there is none.
+        *     - 'max': Allowed (normal) maximum, or empty string if there is none.
+        *     - 'max2': Allowed (high limits) maximum, or empty string if there is none.
+        */
+       const PARAM_MAX = 'param-max';
+
+       /**
+        * (int) Maximum allowed value (high limits)
+        *
+        * If not specified, PARAM_MAX will be enforced for all users. Ignored if
+        * PARAM_MAX is not set.
+        *
+        * ValidationException codes:
+        *  - 'abovehighmaximum': The value was above the allowed maximum. Data:
+        *     - 'min': Allowed minimum, or empty string if there is none.
+        *     - 'max': Allowed (normal) maximum, or empty string if there is none.
+        *     - 'max2': Allowed (high limits) maximum, or empty string if there is none.
+        */
+       const PARAM_MAX2 = 'param-max2';
+
+       public function validate( $name, $value, array $settings, array $options ) {
+               if ( !preg_match( '/^[+-]?\d+$/D', $value ) ) {
+                       throw new ValidationException( $name, $value, $settings, 'badinteger', [] );
+               }
+               $ret = intval( $value, 10 );
+
+               // intval() returns min/max on overflow, so check that
+               if ( $ret === PHP_INT_MAX || $ret === PHP_INT_MIN ) {
+                       $tmp = ( $ret < 0 ? '-' : '' ) . ltrim( $value, '-0' );
+                       if ( $tmp !== (string)$ret ) {
+                               throw new ValidationException( $name, $value, $settings, 'badinteger', [] );
+                       }
+               }
+
+               $min = $settings[self::PARAM_MIN] ?? null;
+               $max = $settings[self::PARAM_MAX] ?? null;
+               $max2 = $settings[self::PARAM_MAX2] ?? null;
+               $err = null;
+
+               if ( $min !== null && $ret < $min ) {
+                       $err = 'belowminimum';
+                       $ret = $min;
+               } elseif ( $max !== null && $ret > $max ) {
+                       if ( $max2 !== null && $this->callbacks->useHighLimits( $options ) ) {
+                               if ( $ret > $max2 ) {
+                                       $err = 'abovehighmaximum';
+                                       $ret = $max2;
+                               }
+                       } else {
+                               $err = 'abovemaximum';
+                               $ret = $max;
+                       }
+               }
+               if ( $err !== null ) {
+                       $ex = new ValidationException( $name, $value, $settings, $err, [
+                               'min' => $min === null ? '' : $min,
+                               'max' => $max === null ? '' : $max,
+                               'max2' => $max2 === null ? '' : $max2,
+                       ] );
+                       if ( empty( $settings[self::PARAM_IGNORE_RANGE] ) ) {
+                               throw $ex;
+                       }
+                       $this->callbacks->recordCondition( $ex, $options );
+               }
+
+               return $ret;
+       }
+
+       public function normalizeSettings( array $settings ) {
+               if ( !isset( $settings[self::PARAM_MAX] ) ) {
+                       unset( $settings[self::PARAM_MAX2] );
+               }
+
+               if ( isset( $settings[self::PARAM_MAX2] ) && isset( $settings[self::PARAM_MAX] ) &&
+                       $settings[self::PARAM_MAX2] < $settings[self::PARAM_MAX]
+               ) {
+                       $settings[self::PARAM_MAX2] = $settings[self::PARAM_MAX];
+               }
+
+               return parent::normalizeSettings( $settings );
+       }
+
+       public function describeSettings( $name, array $settings, array $options ) {
+               $info = parent::describeSettings( $name, $settings, $options );
+
+               $min = $settings[self::PARAM_MIN] ?? '';
+               $max = $settings[self::PARAM_MAX] ?? '';
+               $max2 = $settings[self::PARAM_MAX2] ?? '';
+               if ( $max === '' || $max2 !== '' && $max2 <= $max ) {
+                       $max2 = '';
+               }
+
+               if ( empty( $options['compact'] ) ) {
+                       if ( $min !== '' ) {
+                               $info['min'] = $min;
+                       }
+                       if ( $max !== '' ) {
+                               $info['max'] = $max;
+                       }
+                       if ( $max2 !== '' ) {
+                               $info['max2'] = $max2;
+                       }
+               } else {
+                       $key = '';
+                       if ( $min !== '' ) {
+                               $key = 'min';
+                       }
+                       if ( $max2 !== '' ) {
+                               $key .= 'max2';
+                       } elseif ( $max !== '' ) {
+                               $key .= 'max';
+                       }
+                       if ( $key !== '' ) {
+                               $info[$key] = [ 'min' => $min, 'max' => $max, 'max2' => $max2 ];
+                       }
+               }
+
+               return $info;
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/LimitDef.php b/includes/libs/ParamValidator/TypeDef/LimitDef.php
new file mode 100644 (file)
index 0000000..99780c4
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+/**
+ * Type definition for "limit" types
+ *
+ * A limit type is an integer type that also accepts the magic value "max".
+ * IntegerDef::PARAM_MIN defaults to 0 for this type.
+ *
+ * @see IntegerDef
+ * @since 1.34
+ */
+class LimitDef extends IntegerDef {
+
+       /**
+        * @inheritDoc
+        *
+        * Additional `$options` accepted:
+        *  - 'parse-limit': (bool) Default true, set false to return 'max' rather
+        *    than determining the effective value.
+        */
+       public function validate( $name, $value, array $settings, array $options ) {
+               if ( $value === 'max' ) {
+                       if ( !isset( $options['parse-limit'] ) || $options['parse-limit'] ) {
+                               $value = $this->callbacks->useHighLimits( $options )
+                                       ? $settings[self::PARAM_MAX2] ?? $settings[self::PARAM_MAX] ?? PHP_INT_MAX
+                                       : $settings[self::PARAM_MAX] ?? PHP_INT_MAX;
+                       }
+                       return $value;
+               }
+
+               return parent::validate( $name, $value, $settings, $options );
+       }
+
+       public function normalizeSettings( array $settings ) {
+               $settings += [
+                       self::PARAM_MIN => 0,
+               ];
+
+               return parent::normalizeSettings( $settings );
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/PasswordDef.php b/includes/libs/ParamValidator/TypeDef/PasswordDef.php
new file mode 100644 (file)
index 0000000..289db54
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\ParamValidator;
+
+/**
+ * Type definition for "password" types
+ *
+ * This is a string type that forces PARAM_SENSITIVE = true.
+ *
+ * @see StringDef
+ * @since 1.34
+ */
+class PasswordDef extends StringDef {
+
+       public function normalizeSettings( array $settings ) {
+               $settings[ParamValidator::PARAM_SENSITIVE] = true;
+               return parent::normalizeSettings( $settings );
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/PresenceBooleanDef.php b/includes/libs/ParamValidator/TypeDef/PresenceBooleanDef.php
new file mode 100644 (file)
index 0000000..2e1c8f5
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\TypeDef;
+
+/**
+ * Type definition for checkbox-like boolean types
+ *
+ * This boolean is considered true if the parameter is present in the request,
+ * regardless of value. The only way for it to be false is for the parameter to
+ * be omitted entirely.
+ *
+ * The result from validate() is a PHP boolean.
+ *
+ * @since 1.34
+ */
+class PresenceBooleanDef extends TypeDef {
+
+       public function getValue( $name, array $settings, array $options ) {
+               return $this->callbacks->hasParam( $name, $options );
+       }
+
+       public function validate( $name, $value, array $settings, array $options ) {
+               return (bool)$value;
+       }
+
+       public function describeSettings( $name, array $settings, array $options ) {
+               $info = parent::describeSettings( $name, $settings, $options );
+               unset( $info['default'] );
+               return $info;
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/StringDef.php b/includes/libs/ParamValidator/TypeDef/StringDef.php
new file mode 100644 (file)
index 0000000..0ed310b
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\Callbacks;
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\TypeDef;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * Type definition for string types
+ *
+ * The result from validate() is a PHP string.
+ *
+ * ValidationException codes:
+ *  - 'missingparam': The parameter is the empty string (and that's not allowed). No data.
+ *
+ * Additional codes may be generated when using certain PARAM constants. See
+ * the constants' documentation for details.
+ *
+ * @since 1.34
+ */
+class StringDef extends TypeDef {
+
+       /**
+        * (integer) Maximum length of a string in bytes.
+        *
+        * ValidationException codes:
+        *  - 'maxbytes': The string is too long. Data:
+        *     - 'maxbytes': The maximum number of bytes allowed
+        *     - 'maxchars': The maximum number of characters allowed
+        */
+       const PARAM_MAX_BYTES = 'param-max-bytes';
+
+       /**
+        * (integer) Maximum length of a string in characters (Unicode codepoints).
+        *
+        * The string is assumed to be encoded as UTF-8.
+        *
+        * ValidationException codes:
+        *  - 'maxchars': The string is too long. Data:
+        *     - 'maxbytes': The maximum number of bytes allowed
+        *     - 'maxchars': The maximum number of characters allowed
+        */
+       const PARAM_MAX_CHARS = 'param-max-chars';
+
+       protected $allowEmptyWhenRequired = false;
+
+       /**
+        * @param Callbacks $callbacks
+        * @param array $options Options:
+        *  - allowEmptyWhenRequired: (bool) Whether to reject the empty string when PARAM_REQUIRED.
+        *    Defaults to false.
+        */
+       public function __construct( Callbacks $callbacks, array $options = [] ) {
+               parent::__construct( $callbacks );
+
+               $this->allowEmptyWhenRequired = !empty( $options['allowEmptyWhenRequired'] );
+       }
+
+       public function validate( $name, $value, array $settings, array $options ) {
+               if ( !$this->allowEmptyWhenRequired && $value === '' &&
+                       !empty( $settings[ParamValidator::PARAM_REQUIRED] )
+               ) {
+                       throw new ValidationException( $name, $value, $settings, 'missingparam', [] );
+               }
+
+               if ( isset( $settings[self::PARAM_MAX_BYTES] )
+                       && strlen( $value ) > $settings[self::PARAM_MAX_BYTES]
+               ) {
+                       throw new ValidationException( $name, $value, $settings, 'maxbytes', [
+                               'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? '',
+                               'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? '',
+                       ] );
+               }
+               if ( isset( $settings[self::PARAM_MAX_CHARS] )
+                       && mb_strlen( $value, 'UTF-8' ) > $settings[self::PARAM_MAX_CHARS]
+               ) {
+                       throw new ValidationException( $name, $value, $settings, 'maxchars', [
+                               'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? '',
+                               'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? '',
+                       ] );
+               }
+
+               return $value;
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/TimestampDef.php b/includes/libs/ParamValidator/TypeDef/TimestampDef.php
new file mode 100644 (file)
index 0000000..5d0bf4e
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\Callbacks;
+use Wikimedia\ParamValidator\TypeDef;
+use Wikimedia\ParamValidator\ValidationException;
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+use Wikimedia\Timestamp\TimestampException;
+
+/**
+ * Type definition for timestamp types
+ *
+ * This uses the wikimedia/timestamp library for parsing and formatting the
+ * timestamps.
+ *
+ * The result from validate() is a ConvertibleTimestamp by default, but this
+ * may be changed by both a constructor option and a PARAM constant.
+ *
+ * ValidationException codes:
+ *  - 'badtimestamp': The timestamp is not valid. No data, but the
+ *    TimestampException is available via Exception::getPrevious().
+ *  - 'unclearnowtimestamp': Non-fatal. The value is the empty string or "0".
+ *    Use 'now' instead if you really want the current timestamp. No data.
+ *
+ * @since 1.34
+ */
+class TimestampDef extends TypeDef {
+
+       /**
+        * (string|int) Timestamp format to return from validate()
+        *
+        * Values include:
+        *  - 'ConvertibleTimestamp': A ConvertibleTimestamp object.
+        *  - 'DateTime': A PHP DateTime object
+        *  - One of ConvertibleTimestamp's TS_* constants.
+        *
+        * This does not affect the format returned by stringifyValue().
+        */
+       const PARAM_TIMESTAMP_FORMAT = 'param-timestamp-format';
+
+       /** @var string|int */
+       protected $defaultFormat;
+
+       /** @var int */
+       protected $stringifyFormat;
+
+       /**
+        * @param Callbacks $callbacks
+        * @param array $options Options:
+        *  - defaultFormat: (string|int) Default for PARAM_TIMESTAMP_FORMAT.
+        *    Default if not specified is 'ConvertibleTimestamp'.
+        *  - stringifyFormat: (int) Format to use for stringifyValue().
+        *    Default is TS_ISO_8601.
+        */
+       public function __construct( Callbacks $callbacks, array $options = [] ) {
+               parent::__construct( $callbacks );
+
+               $this->defaultFormat = $options['defaultFormat'] ?? 'ConvertibleTimestamp';
+               $this->stringifyFormat = $options['stringifyFormat'] ?? TS_ISO_8601;
+       }
+
+       public function validate( $name, $value, array $settings, array $options ) {
+               // Confusing synonyms for the current time accepted by ConvertibleTimestamp
+               if ( !$value ) {
+                       $this->callbacks->recordCondition(
+                               new ValidationException( $name, $value, $settings, 'unclearnowtimestamp', [] ),
+                               $options
+                       );
+                       $value = 'now';
+               }
+
+               try {
+                       $timestamp = new ConvertibleTimestamp( $value === 'now' ? false : $value );
+               } catch ( TimestampException $ex ) {
+                       throw new ValidationException( $name, $value, $settings, 'badtimestamp', [], $ex );
+               }
+
+               $format = $settings[self::PARAM_TIMESTAMP_FORMAT] ?? $this->defaultFormat;
+               switch ( $format ) {
+                       case 'ConvertibleTimestamp':
+                               return $timestamp;
+
+                       case 'DateTime':
+                               // Eew, no getter.
+                               return $timestamp->timestamp;
+
+                       default:
+                               return $timestamp->getTimestamp( $format );
+               }
+       }
+
+       public function stringifyValue( $name, $value, array $settings, array $options ) {
+               if ( !$value instanceof ConvertibleTimestamp ) {
+                       $value = new ConvertibleTimestamp( $value );
+               }
+               return $value->getTimestamp( $this->stringifyFormat );
+       }
+
+}
diff --git a/includes/libs/ParamValidator/TypeDef/UploadDef.php b/includes/libs/ParamValidator/TypeDef/UploadDef.php
new file mode 100644 (file)
index 0000000..b436a6d
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Psr\Http\Message\UploadedFileInterface;
+use Wikimedia\ParamValidator\TypeDef;
+use Wikimedia\ParamValidator\Util\UploadedFile;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * Type definition for upload types
+ *
+ * The result from validate() is an object implementing UploadedFileInterface.
+ *
+ * ValidationException codes:
+ *  - 'badupload': The upload is not valid. No data.
+ *  - 'badupload-inisize': The upload exceeded the maximum in php.ini. Data:
+ *     - 'size': The configured size (in bytes).
+ *  - 'badupload-formsize': The upload exceeded the maximum in the form post. No data.
+ *  - 'badupload-partial': The file was only partially uploaded. No data.
+ *  - 'badupload-nofile': There was no file. No data.
+ *  - 'badupload-notmpdir': PHP has no temporary directory to store the upload. No data.
+ *  - 'badupload-cantwrite': PHP could not store the upload. No data.
+ *  - 'badupload-phpext': A PHP extension rejected the upload. No data.
+ *  - 'badupload-notupload': The field was present in the submission but was not encoded as
+ *    an upload. No data.
+ *  - 'badupload-unknown': Some unknown PHP upload error code. Data:
+ *     - 'code': The code.
+ *
+ * @since 1.34
+ */
+class UploadDef extends TypeDef {
+
+       public function getValue( $name, array $settings, array $options ) {
+               $ret = $this->callbacks->getUploadedFile( $name, $options );
+
+               if ( $ret && $ret->getError() === UPLOAD_ERR_NO_FILE &&
+                       !$this->callbacks->hasParam( $name, $options )
+               ) {
+                       // This seems to be that the client explicitly specified "no file" for the field
+                       // instead of just omitting the field completely. DWTM.
+                       $ret = null;
+               } elseif ( !$ret && $this->callbacks->hasParam( $name, $options ) ) {
+                       // The client didn't format their upload properly so it came in as an ordinary
+                       // field. Convert it to an error.
+                       $ret = new UploadedFile( [
+                               'name' => '',
+                               'type' => '',
+                               'tmp_name' => '',
+                               'error' => -42, // PHP's UPLOAD_ERR_* are all positive numbers.
+                               'size' => 0,
+                       ] );
+               }
+
+               return $ret;
+       }
+
+       /**
+        * Fetch the value of PHP's upload_max_filesize ini setting
+        *
+        * This method exists so it can be mocked by unit tests that can't
+        * affect ini_get() directly.
+        *
+        * @codeCoverageIgnore
+        * @return string|false
+        */
+       protected function getIniSize() {
+               return ini_get( 'upload_max_filesize' );
+       }
+
+       public function validate( $name, $value, array $settings, array $options ) {
+               static $codemap = [
+                       -42 => 'notupload', // Local from getValue()
+                       UPLOAD_ERR_FORM_SIZE => 'formsize',
+                       UPLOAD_ERR_PARTIAL => 'partial',
+                       UPLOAD_ERR_NO_FILE => 'nofile',
+                       UPLOAD_ERR_NO_TMP_DIR => 'notmpdir',
+                       UPLOAD_ERR_CANT_WRITE => 'cantwrite',
+                       UPLOAD_ERR_EXTENSION => 'phpext',
+               ];
+
+               if ( !$value instanceof UploadedFileInterface ) {
+                       // Err?
+                       throw new ValidationException( $name, $value, $settings, 'badupload', [] );
+               }
+
+               $err = $value->getError();
+               if ( $err === UPLOAD_ERR_OK ) {
+                       return $value;
+               } elseif ( $err === UPLOAD_ERR_INI_SIZE ) {
+                       static $prefixes = [
+                               'g' => 1024 ** 3,
+                               'm' => 1024 ** 2,
+                               'k' => 1024 ** 1,
+                       ];
+                       $size = $this->getIniSize();
+                       $last = strtolower( substr( $size, -1 ) );
+                       $size = intval( $size, 10 ) * ( $prefixes[$last] ?? 1 );
+                       throw new ValidationException( $name, $value, $settings, 'badupload-inisize', [
+                               'size' => $size,
+                       ] );
+               } elseif ( isset( $codemap[$err] ) ) {
+                       throw new ValidationException( $name, $value, $settings, 'badupload-' . $codemap[$err], [] );
+               } else {
+                       throw new ValidationException( $name, $value, $settings, 'badupload-unknown', [
+                               'code' => $err,
+                       ] );
+               }
+       }
+
+       public function stringifyValue( $name, $value, array $settings, array $options ) {
+               // Not going to happen.
+               return null;
+       }
+
+}
diff --git a/includes/libs/ParamValidator/Util/UploadedFile.php b/includes/libs/ParamValidator/Util/UploadedFile.php
new file mode 100644 (file)
index 0000000..2be9119
--- /dev/null
@@ -0,0 +1,141 @@
+<?php
+
+namespace Wikimedia\ParamValidator\Util;
+
+use Psr\Http\Message\UploadedFileInterface;
+use RuntimeException;
+use Wikimedia\AtEase\AtEase;
+
+/**
+ * A simple implementation of UploadedFileInterface
+ *
+ * This exists so ParamValidator needn't depend on any specific PSR-7
+ * implementation for a class implementing UploadedFileInterface. It shouldn't
+ * be used directly by other code, other than perhaps when implementing
+ * Callbacks::getUploadedFile() when another PSR-7 library is not already in use.
+ *
+ * @since 1.34
+ */
+class UploadedFile implements UploadedFileInterface {
+
+       /** @var array File data */
+       private $data;
+
+       /** @var bool */
+       private $fromUpload;
+
+       /** @var UploadedFileStream|null */
+       private $stream = null;
+
+       /** @var bool Whether moveTo() was called */
+       private $moved = false;
+
+       /**
+        * @param array $data Data from $_FILES
+        * @param bool $fromUpload Set false if using this task with data not from
+        *  $_FILES. Intended for unit testing.
+        */
+       public function __construct( array $data, $fromUpload = true ) {
+               $this->data = $data;
+               $this->fromUpload = $fromUpload;
+       }
+
+       /**
+        * Throw if there was an error
+        * @throws RuntimeException
+        */
+       private function checkError() {
+               switch ( $this->data['error'] ) {
+                       case UPLOAD_ERR_OK:
+                               break;
+
+                       case UPLOAD_ERR_INI_SIZE:
+                               throw new RuntimeException( 'Upload exceeded maximum size' );
+
+                       case UPLOAD_ERR_FORM_SIZE:
+                               throw new RuntimeException( 'Upload exceeded form-specified maximum size' );
+
+                       case UPLOAD_ERR_PARTIAL:
+                               throw new RuntimeException( 'File was only partially uploaded' );
+
+                       case UPLOAD_ERR_NO_FILE:
+                               throw new RuntimeException( 'No file was uploaded' );
+
+                       case UPLOAD_ERR_NO_TMP_DIR:
+                               throw new RuntimeException( 'PHP has no temporary folder for storing uploaded files' );
+
+                       case UPLOAD_ERR_CANT_WRITE:
+                               throw new RuntimeException( 'PHP was unable to save the uploaded file' );
+
+                       case UPLOAD_ERR_EXTENSION:
+                               throw new RuntimeException( 'A PHP extension stopped the file upload' );
+
+                       default:
+                               throw new RuntimeException( 'Unknown upload error code ' . $this->data['error'] );
+               }
+
+               if ( $this->moved ) {
+                       throw new RuntimeException( 'File has already been moved' );
+               }
+               if ( !isset( $this->data['tmp_name'] ) || !file_exists( $this->data['tmp_name'] ) ) {
+                       throw new RuntimeException( 'Uploaded file is missing' );
+               }
+       }
+
+       public function getStream() {
+               if ( $this->stream ) {
+                       return $this->stream;
+               }
+
+               $this->checkError();
+               $this->stream = new UploadedFileStream( $this->data['tmp_name'] );
+               return $this->stream;
+       }
+
+       public function moveTo( $targetPath ) {
+               $this->checkError();
+
+               if ( $this->fromUpload && !is_uploaded_file( $this->data['tmp_name'] ) ) {
+                       throw new RuntimeException( 'Specified file is not an uploaded file' );
+               }
+
+               // TODO remove the function_exists check once we drop HHVM support
+               if ( function_exists( 'error_clear_last' ) ) {
+                       error_clear_last();
+               }
+               $ret = AtEase::quietCall(
+                       $this->fromUpload ? 'move_uploaded_file' : 'rename',
+                       $this->data['tmp_name'],
+                       $targetPath
+               );
+               if ( $ret === false ) {
+                       $err = error_get_last();
+                       throw new RuntimeException( "Move failed: " . ( $err['message'] ?? 'Unknown error' ) );
+               }
+
+               $this->moved = true;
+               if ( $this->stream ) {
+                       $this->stream->close();
+                       $this->stream = null;
+               }
+       }
+
+       public function getSize() {
+               return $this->data['size'] ?? null;
+       }
+
+       public function getError() {
+               return $this->data['error'] ?? UPLOAD_ERR_NO_FILE;
+       }
+
+       public function getClientFilename() {
+               $ret = $this->data['name'] ?? null;
+               return $ret === '' ? null : $ret;
+       }
+
+       public function getClientMediaType() {
+               $ret = $this->data['type'] ?? null;
+               return $ret === '' ? null : $ret;
+       }
+
+}
diff --git a/includes/libs/ParamValidator/Util/UploadedFileStream.php b/includes/libs/ParamValidator/Util/UploadedFileStream.php
new file mode 100644 (file)
index 0000000..17eaaf4
--- /dev/null
@@ -0,0 +1,168 @@
+<?php
+
+namespace Wikimedia\ParamValidator\Util;
+
+use Exception;
+use Psr\Http\Message\StreamInterface;
+use RuntimeException;
+use Throwable;
+use Wikimedia\AtEase\AtEase;
+
+/**
+ * Implementation of StreamInterface for a file in $_FILES
+ *
+ * This exists so ParamValidator needn't depend on any specific PSR-7
+ * implementation for a class implementing UploadedFileInterface. It shouldn't
+ * be used directly by other code.
+ *
+ * @internal
+ * @since 1.34
+ */
+class UploadedFileStream implements StreamInterface {
+
+       /** @var resource File handle */
+       private $fp;
+
+       /** @var int|false|null File size. False if not set yet. */
+       private $size = false;
+
+       /**
+        * Call, throwing on error
+        * @param callable $func Callable to call
+        * @param array $args Arguments
+        * @param mixed $fail Failure return value
+        * @param string $msg Message prefix
+        * @return mixed
+        * @throws RuntimeException if $func returns $fail
+        */
+       private static function quietCall( callable $func, array $args, $fail, $msg ) {
+               // TODO remove the function_exists check once we drop HHVM support
+               if ( function_exists( 'error_clear_last' ) ) {
+                       error_clear_last();
+               }
+               $ret = AtEase::quietCall( $func, ...$args );
+               if ( $ret === $fail ) {
+                       $err = error_get_last();
+                       throw new RuntimeException( "$msg: " . ( $err['message'] ?? 'Unknown error' ) );
+               }
+               return $ret;
+       }
+
+       /**
+        * @param string $filename
+        */
+       public function __construct( $filename ) {
+               $this->fp = self::quietCall( 'fopen', [ $filename, 'r' ], false, 'Failed to open file' );
+       }
+
+       /**
+        * Check if the stream is open
+        * @throws RuntimeException if closed
+        */
+       private function checkOpen() {
+               if ( !$this->fp ) {
+                       throw new RuntimeException( 'Stream is not open' );
+               }
+       }
+
+       public function __destruct() {
+               $this->close();
+       }
+
+       public function __toString() {
+               try {
+                       $this->seek( 0 );
+                       return $this->getContents();
+               } catch ( Exception $ex ) {
+                       // Not allowed to throw
+                       return '';
+               } catch ( Throwable $ex ) {
+                       // Not allowed to throw
+                       return '';
+               }
+       }
+
+       public function close() {
+               if ( $this->fp ) {
+                       // Spec doesn't care about close errors.
+                       AtEase::quietCall( 'fclose', $this->fp );
+                       $this->fp = null;
+               }
+       }
+
+       public function detach() {
+               $ret = $this->fp;
+               $this->fp = null;
+               return $ret;
+       }
+
+       public function getSize() {
+               if ( $this->size === false ) {
+                       $this->size = null;
+
+                       if ( $this->fp ) {
+                               // Spec doesn't care about errors here.
+                               $stat = AtEase::quietCall( 'fstat', $this->fp );
+                               $this->size = $stat['size'] ?? null;
+                       }
+               }
+
+               return $this->size;
+       }
+
+       public function tell() {
+               $this->checkOpen();
+               return self::quietCall( 'ftell', [ $this->fp ], -1, 'Cannot determine stream position' );
+       }
+
+       public function eof() {
+               // Spec doesn't care about errors here.
+               return !$this->fp || AtEase::quietCall( 'feof', $this->fp );
+       }
+
+       public function isSeekable() {
+               return (bool)$this->fp;
+       }
+
+       public function seek( $offset, $whence = SEEK_SET ) {
+               $this->checkOpen();
+               self::quietCall( 'fseek', [ $this->fp, $offset, $whence ], -1, 'Seek failed' );
+       }
+
+       public function rewind() {
+               $this->seek( 0 );
+       }
+
+       public function isWritable() {
+               return false;
+       }
+
+       public function write( $string ) {
+               $this->checkOpen();
+               throw new RuntimeException( 'Stream is read-only' );
+       }
+
+       public function isReadable() {
+               return (bool)$this->fp;
+       }
+
+       public function read( $length ) {
+               $this->checkOpen();
+               return self::quietCall( 'fread', [ $this->fp, $length ], false, 'Read failed' );
+       }
+
+       public function getContents() {
+               $this->checkOpen();
+               return self::quietCall( 'stream_get_contents', [ $this->fp ], false, 'Read failed' );
+       }
+
+       public function getMetadata( $key = null ) {
+               $this->checkOpen();
+               $ret = self::quietCall( 'stream_get_meta_data', [ $this->fp ], false, 'Metadata fetch failed' );
+               if ( $key !== null ) {
+                       $ret = $ret[$key] ?? null;
+               }
+               return $ret;
+       }
+
+}
diff --git a/includes/libs/ParamValidator/ValidationException.php b/includes/libs/ParamValidator/ValidationException.php
new file mode 100644 (file)
index 0000000..c8d995e
--- /dev/null
@@ -0,0 +1,128 @@
+<?php
+
+namespace Wikimedia\ParamValidator;
+
+use Exception;
+use Throwable;
+use UnexpectedValueException;
+
+/**
+ * Error reporting for ParamValidator
+ *
+ * @since 1.34
+ */
+class ValidationException extends UnexpectedValueException {
+
+       /** @var string */
+       protected $paramName;
+
+       /** @var mixed */
+       protected $paramValue;
+
+       /** @var array */
+       protected $settings;
+
+       /** @var string */
+       protected $failureCode;
+
+       /** @var (string|int|string[])[] */
+       protected $failureData;
+
+       /**
+        * @param string $name Parameter name being validated
+        * @param mixed $value Value of the parameter
+        * @param array $settings Settings array being used for validation
+        * @param string $code Failure code. See getFailureCode() for requirements.
+        * @param (string|int|string[])[] $data Data for the failure code.
+        *  See getFailureData() for requirements.
+        * @param Throwable|Exception|null $previous Previous exception causing this failure
+        */
+       public function __construct( $name, $value, $settings, $code, $data, $previous = null ) {
+               parent::__construct( self::formatMessage( $name, $code, $data ), 0, $previous );
+
+               $this->paramName = $name;
+               $this->paramValue = $value;
+               $this->settings = $settings;
+               $this->failureCode = $code;
+               $this->failureData = $data;
+       }
+
+       /**
+        * Make a simple English message for the exception
+        * @param string $name
+        * @param string $code
+        * @param array $data
+        * @return string
+        */
+       private static function formatMessage( $name, $code, $data ) {
+               $ret = "Validation of `$name` failed: $code";
+               foreach ( $data as $k => $v ) {
+                       if ( is_array( $v ) ) {
+                               $v = implode( ', ', $v );
+                       }
+                       $ret .= "; $k => $v";
+               }
+               return $ret;
+       }
+
+       /**
+        * Fetch the parameter name that failed validation
+        * @return string
+        */
+       public function getParamName() {
+               return $this->paramName;
+       }
+
+       /**
+        * Fetch the parameter value that failed validation
+        * @return mixed
+        */
+       public function getParamValue() {
+               return $this->paramValue;
+       }
+
+       /**
+        * Fetch the settings array that failed validation
+        * @return array
+        */
+       public function getSettings() {
+               return $this->settings;
+       }
+
+       /**
+        * Fetch the validation failure code
+        *
+        * A validation failure code is a reasonably short string matching the regex
+        * `/^[a-z][a-z0-9-]*$/`.
+        *
+        * Users are encouraged to use this with a suitable i18n mechanism rather
+        * than relying on the limited English text returned by getMessage().
+        *
+        * @return string
+        */
+       public function getFailureCode() {
+               return $this->failureCode;
+       }
+
+       /**
+        * Fetch the validation failure data
+        *
+        * This returns additional data relevant to the particular failure code.
+        *
+        * Keys in the array are short ASCII strings. Values are strings or
+        * integers, or arrays of strings intended to be displayed as a
+        * comma-separated list. For any particular code the same keys are always
+        * returned in the same order, making it safe to use array_values() and
+        * access them positionally if that is desired.
+        *
+        * For example, the data for a hypothetical "integer-out-of-range" code
+        * might have data `[ 'min' => 0, 'max' => 100 ]` indicating the range of
+        * allowed values.
+        *
+        * @return (string|int|string[])[]
+        */
+       public function getFailureData() {
+               return $this->failureData;
+       }
+
+}
index b60577c..8b6c6d5 100644 (file)
@@ -179,6 +179,7 @@ $wgAutoloadClasses += [
 
        # tests/phpunit/includes/libs
        'GenericArrayObjectTest' => "$testDir/phpunit/includes/libs/GenericArrayObjectTest.php",
+       'Wikimedia\ParamValidator\TypeDef\TypeDefTestCase' => "$testDir/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php",
 
        # tests/phpunit/maintenance
        'MediaWiki\Tests\Maintenance\DumpAsserter' => "$testDir/phpunit/maintenance/DumpAsserter.php",
diff --git a/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php b/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php
new file mode 100644 (file)
index 0000000..01b1c02
--- /dev/null
@@ -0,0 +1,506 @@
+<?php
+
+namespace Wikimedia\ParamValidator;
+
+use Psr\Container\ContainerInterface;
+use Wikimedia\ObjectFactory;
+
+/**
+ * @covers Wikimedia\ParamValidator\ParamValidator
+ */
+class ParamValidatorTest extends \PHPUnit\Framework\TestCase {
+
+       public function testTypeRegistration() {
+               $validator = new ParamValidator(
+                       new SimpleCallbacks( [] ),
+                       new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) )
+               );
+               $this->assertSame( array_keys( ParamValidator::$STANDARD_TYPES ), $validator->knownTypes() );
+
+               $validator = new ParamValidator(
+                       new SimpleCallbacks( [] ),
+                       new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ),
+                       [ 'typeDefs' => [ 'foo' => [], 'bar' => [] ] ]
+               );
+               $validator->addTypeDef( 'baz', [] );
+               try {
+                       $validator->addTypeDef( 'baz', [] );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+               }
+               $validator->overrideTypeDef( 'bar', null );
+               $validator->overrideTypeDef( 'baz', [] );
+               $this->assertSame( [ 'foo', 'baz' ], $validator->knownTypes() );
+
+               $this->assertTrue( $validator->hasTypeDef( 'foo' ) );
+               $this->assertFalse( $validator->hasTypeDef( 'bar' ) );
+               $this->assertTrue( $validator->hasTypeDef( 'baz' ) );
+               $this->assertFalse( $validator->hasTypeDef( 'bazz' ) );
+       }
+
+       public function testGetTypeDef() {
+               $callbacks = new SimpleCallbacks( [] );
+               $factory = $this->getMockBuilder( ObjectFactory::class )
+                       ->setConstructorArgs( [ $this->getMockForAbstractClass( ContainerInterface::class ) ] )
+                       ->setMethods( [ 'createObject' ] )
+                       ->getMock();
+               $factory->method( 'createObject' )
+                       ->willReturnCallback( function ( $spec, $options ) use ( $callbacks ) {
+                               $this->assertInternalType( 'array', $spec );
+                               $this->assertSame(
+                                       [ 'extraArgs' => [ $callbacks ], 'assertClass' => TypeDef::class ], $options
+                               );
+                               $ret = $this->getMockBuilder( TypeDef::class )
+                                       ->setConstructorArgs( [ $callbacks ] )
+                                       ->getMockForAbstractClass();
+                               $ret->spec = $spec;
+                               return $ret;
+                       } );
+               $validator = new ParamValidator( $callbacks, $factory );
+
+               $def = $validator->getTypeDef( 'boolean' );
+               $this->assertInstanceOf( TypeDef::class, $def );
+               $this->assertSame( ParamValidator::$STANDARD_TYPES['boolean'], $def->spec );
+
+               $def = $validator->getTypeDef( [] );
+               $this->assertInstanceOf( TypeDef::class, $def );
+               $this->assertSame( ParamValidator::$STANDARD_TYPES['enum'], $def->spec );
+
+               $def = $validator->getTypeDef( 'missing' );
+               $this->assertNull( $def );
+       }
+
+       public function testGetTypeDef_caching() {
+               $callbacks = new SimpleCallbacks( [] );
+
+               $mb = $this->getMockBuilder( TypeDef::class )
+                       ->setConstructorArgs( [ $callbacks ] );
+               $def1 = $mb->getMockForAbstractClass();
+               $def2 = $mb->getMockForAbstractClass();
+               $this->assertNotSame( $def1, $def2, 'sanity check' );
+
+               $factory = $this->getMockBuilder( ObjectFactory::class )
+                       ->setConstructorArgs( [ $this->getMockForAbstractClass( ContainerInterface::class ) ] )
+                       ->setMethods( [ 'createObject' ] )
+                       ->getMock();
+               $factory->expects( $this->once() )->method( 'createObject' )->willReturn( $def1 );
+
+               $validator = new ParamValidator( $callbacks, $factory, [ 'typeDefs' => [
+                       'foo' => [],
+                       'bar' => $def2,
+               ] ] );
+
+               $this->assertSame( $def1, $validator->getTypeDef( 'foo' ) );
+
+               // Second call doesn't re-call ObjectFactory
+               $this->assertSame( $def1, $validator->getTypeDef( 'foo' ) );
+
+               // When registered a TypeDef directly, doesn't call ObjectFactory
+               $this->assertSame( $def2, $validator->getTypeDef( 'bar' ) );
+       }
+
+       /**
+        * @expectedException \UnexpectedValueException
+        * @expectedExceptionMessage Expected instance of Wikimedia\ParamValidator\TypeDef, got stdClass
+        */
+       public function testGetTypeDef_error() {
+               $validator = new ParamValidator(
+                       new SimpleCallbacks( [] ),
+                       new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ),
+                       [ 'typeDefs' => [ 'foo' => [ 'class' => \stdClass::class ] ] ]
+               );
+               $validator->getTypeDef( 'foo' );
+       }
+
+       /** @dataProvider provideNormalizeSettings */
+       public function testNormalizeSettings( $input, $expect ) {
+               $callbacks = new SimpleCallbacks( [] );
+
+               $mb = $this->getMockBuilder( TypeDef::class )
+                       ->setConstructorArgs( [ $callbacks ] )
+                       ->setMethods( [ 'normalizeSettings' ] );
+               $mock1 = $mb->getMockForAbstractClass();
+               $mock1->method( 'normalizeSettings' )->willReturnCallback( function ( $s ) {
+                       $s['foo'] = 'FooBar!';
+                       return $s;
+               } );
+               $mock2 = $mb->getMockForAbstractClass();
+               $mock2->method( 'normalizeSettings' )->willReturnCallback( function ( $s ) {
+                       $s['bar'] = 'FooBar!';
+                       return $s;
+               } );
+
+               $validator = new ParamValidator(
+                       $callbacks,
+                       new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ),
+                       [ 'typeDefs' => [ 'foo' => $mock1, 'bar' => $mock2 ] ]
+               );
+
+               $this->assertSame( $expect, $validator->normalizeSettings( $input ) );
+       }
+
+       public static function provideNormalizeSettings() {
+               return [
+                       'Plain value' => [
+                               'ok?',
+                               [ ParamValidator::PARAM_DEFAULT => 'ok?', ParamValidator::PARAM_TYPE => 'string' ],
+                       ],
+                       'Simple array' => [
+                               [ 'test' => 'ok?' ],
+                               [ 'test' => 'ok?', ParamValidator::PARAM_TYPE => 'NULL' ],
+                       ],
+                       'A type with overrides' => [
+                               [ ParamValidator::PARAM_TYPE => 'foo', 'test' => 'ok?' ],
+                               [ ParamValidator::PARAM_TYPE => 'foo', 'test' => 'ok?', 'foo' => 'FooBar!' ],
+                       ],
+               ];
+       }
+
+       /** @dataProvider provideExplodeMultiValue */
+       public function testExplodeMultiValue( $value, $limit, $expect ) {
+               $this->assertSame( $expect, ParamValidator::explodeMultiValue( $value, $limit ) );
+       }
+
+       public static function provideExplodeMultiValue() {
+               return [
+                       [ 'foobar', 100, [ 'foobar' ] ],
+                       [ 'foo|bar|baz', 100, [ 'foo', 'bar', 'baz' ] ],
+                       [ "\x1Ffoo\x1Fbar\x1Fbaz", 100, [ 'foo', 'bar', 'baz' ] ],
+                       [ 'foo|bar|baz', 2, [ 'foo', 'bar|baz' ] ],
+                       [ "\x1Ffoo\x1Fbar\x1Fbaz", 2, [ 'foo', "bar\x1Fbaz" ] ],
+                       [ '|bar|baz', 100, [ '', 'bar', 'baz' ] ],
+                       [ "\x1F\x1Fbar\x1Fbaz", 100, [ '', 'bar', 'baz' ] ],
+                       [ '', 100, [] ],
+                       [ "\x1F", 100, [] ],
+               ];
+       }
+
+       /**
+        * @expectedException DomainException
+        * @expectedExceptionMessage Param foo's type is unknown - string
+        */
+       public function testGetValue_badType() {
+               $validator = new ParamValidator(
+                       new SimpleCallbacks( [] ),
+                       new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ),
+                       [ 'typeDefs' => [] ]
+               );
+               $validator->getValue( 'foo', 'default', [] );
+       }
+
+       /** @dataProvider provideGetValue */
+       public function testGetValue(
+               $settings, $parseLimit, $get, $value, $isSensitive, $isDeprecated
+       ) {
+               $callbacks = new SimpleCallbacks( $get );
+               $dummy = (object)[];
+               $options = [ $dummy ];
+
+               $settings += [
+                       ParamValidator::PARAM_TYPE => 'xyz',
+                       ParamValidator::PARAM_DEFAULT => null,
+               ];
+
+               $mockDef = $this->getMockBuilder( TypeDef::class )
+                       ->setConstructorArgs( [ $callbacks ] )
+                       ->getMockForAbstractClass();
+
+               // Mock the validateValue method so we can test only getValue
+               $validator = $this->getMockBuilder( ParamValidator::class )
+                       ->setConstructorArgs( [
+                               $callbacks,
+                               new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ),
+                               [ 'typeDefs' => [ 'xyz' => $mockDef ] ]
+                       ] )
+                       ->setMethods( [ 'validateValue' ] )
+                       ->getMock();
+               $validator->expects( $this->once() )->method( 'validateValue' )
+                       ->with(
+                               $this->identicalTo( 'foobar' ),
+                               $this->identicalTo( $value ),
+                               $this->identicalTo( $settings ),
+                               $this->identicalTo( $options )
+                       )
+                       ->willReturn( $dummy );
+
+               $this->assertSame( $dummy, $validator->getValue( 'foobar', $settings, $options ) );
+
+               $expectConditions = [];
+               if ( $isSensitive ) {
+                       $expectConditions[] = new ValidationException(
+                               'foobar', $value, $settings, 'param-sensitive', []
+                       );
+               }
+               if ( $isDeprecated ) {
+                       $expectConditions[] = new ValidationException(
+                               'foobar', $value, $settings, 'param-deprecated', []
+                       );
+               }
+               $this->assertEquals( $expectConditions, $callbacks->getRecordedConditions() );
+       }
+
+       public static function provideGetValue() {
+               $sen = [ ParamValidator::PARAM_SENSITIVE => true ];
+               $dep = [ ParamValidator::PARAM_DEPRECATED => true ];
+               $dflt = [ ParamValidator::PARAM_DEFAULT => 'DeFaUlT' ];
+               return [
+                       'Simple case' => [ [], false, [ 'foobar' => '!!!' ], '!!!', false, false ],
+                       'Not provided' => [ $sen + $dep, false, [], null, false, false ],
+                       'Not provided, default' => [ $sen + $dep + $dflt, true, [], 'DeFaUlT', false, false ],
+                       'Provided' => [ $dflt, false, [ 'foobar' => 'XYZ' ], 'XYZ', false, false ],
+                       'Provided, sensitive' => [ $sen, false, [ 'foobar' => 'XYZ' ], 'XYZ', true, false ],
+                       'Provided, deprecated' => [ $dep, false, [ 'foobar' => 'XYZ' ], 'XYZ', false, true ],
+                       'Provided array' => [ $dflt, false, [ 'foobar' => [ 'XYZ' ] ], [ 'XYZ' ], false, false ],
+               ];
+       }
+
+       /**
+        * @expectedException DomainException
+        * @expectedExceptionMessage Param foo's type is unknown - string
+        */
+       public function testValidateValue_badType() {
+               $validator = new ParamValidator(
+                       new SimpleCallbacks( [] ),
+                       new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ),
+                       [ 'typeDefs' => [] ]
+               );
+               $validator->validateValue( 'foo', null, 'default', [] );
+       }
+
+       /** @dataProvider provideValidateValue */
+       public function testValidateValue(
+               $value, $settings, $highLimits, $valuesList, $calls, $expect, $expectConditions = [],
+               $constructorOptions = []
+       ) {
+               $callbacks = new SimpleCallbacks( [] );
+               $settings += [
+                       ParamValidator::PARAM_TYPE => 'xyz',
+                       ParamValidator::PARAM_DEFAULT => null,
+               ];
+               $dummy = (object)[];
+               $options = [ $dummy, 'useHighLimits' => $highLimits ];
+               $eOptions = $options;
+               $eOptions2 = $eOptions;
+               if ( $valuesList !== null ) {
+                       $eOptions2['values-list'] = $valuesList;
+               }
+
+               $mockDef = $this->getMockBuilder( TypeDef::class )
+                       ->setConstructorArgs( [ $callbacks ] )
+                       ->setMethods( [ 'validate', 'getEnumValues' ] )
+                       ->getMockForAbstractClass();
+               $mockDef->method( 'getEnumValues' )
+                       ->with(
+                               $this->identicalTo( 'foobar' ), $this->identicalTo( $settings ), $this->identicalTo( $eOptions )
+                       )
+                       ->willReturn( [ 'a', 'b', 'c', 'd', 'e', 'f' ] );
+               $mockDef->expects( $this->exactly( count( $calls ) ) )->method( 'validate' )->willReturnCallback(
+                       function ( $n, $v, $s, $o ) use ( $settings, $eOptions2, $calls ) {
+                               $this->assertSame( 'foobar', $n );
+                               $this->assertSame( $settings, $s );
+                               $this->assertSame( $eOptions2, $o );
+
+                               if ( !array_key_exists( $v, $calls ) ) {
+                                       $this->fail( "Called with unexpected value '$v'" );
+                               }
+                               if ( $calls[$v] === null ) {
+                                       throw new ValidationException( $n, $v, $s, 'badvalue', [] );
+                               }
+                               return $calls[$v];
+                       }
+               );
+
+               $validator = new ParamValidator(
+                       $callbacks,
+                       new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ),
+                       $constructorOptions + [ 'typeDefs' => [ 'xyz' => $mockDef ] ]
+               );
+
+               if ( $expect instanceof ValidationException ) {
+                       try {
+                               $validator->validateValue( 'foobar', $value, $settings, $options );
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( ValidationException $ex ) {
+                               $this->assertSame( $expect->getFailureCode(), $ex->getFailureCode() );
+                               $this->assertSame( $expect->getFailureData(), $ex->getFailureData() );
+                       }
+               } else {
+                       $this->assertSame(
+                               $expect, $validator->validateValue( 'foobar', $value, $settings, $options )
+                       );
+
+                       $conditions = [];
+                       foreach ( $callbacks->getRecordedConditions() as $c ) {
+                               $conditions[] = array_merge( [ $c->getFailureCode() ], $c->getFailureData() );
+                       }
+                       $this->assertSame( $expectConditions, $conditions );
+               }
+       }
+
+       public static function provideValidateValue() {
+               return [
+                       'No value' => [ null, [], false, null, [], null ],
+                       'No value, required' => [
+                               null,
+                               [ ParamValidator::PARAM_REQUIRED => true ],
+                               false,
+                               null,
+                               [],
+                               new ValidationException( 'foobar', null, [], 'missingparam', [] ),
+                       ],
+                       'Non-multi value' => [ 'abc', [], false, null, [ 'abc' => 'def' ], 'def' ],
+                       'Simple multi value' => [
+                               'a|b|c|d',
+                               [ ParamValidator::PARAM_ISMULTI => true ],
+                               false,
+                               [ 'a', 'b', 'c', 'd' ],
+                               [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ],
+                               [ 'A', 'B', 'C', 'D' ],
+                       ],
+                       'Array multi value' => [
+                               [ 'a', 'b', 'c', 'd' ],
+                               [ ParamValidator::PARAM_ISMULTI => true ],
+                               false,
+                               [ 'a', 'b', 'c', 'd' ],
+                               [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ],
+                               [ 'A', 'B', 'C', 'D' ],
+                       ],
+                       'Multi value with PARAM_ALL' => [
+                               '*',
+                               [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => true ],
+                               false,
+                               null,
+                               [],
+                               [ 'a', 'b', 'c', 'd', 'e', 'f' ],
+                       ],
+                       'Multi value with PARAM_ALL = "x"' => [
+                               'x',
+                               [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => "x" ],
+                               false,
+                               null,
+                               [],
+                               [ 'a', 'b', 'c', 'd', 'e', 'f' ],
+                       ],
+                       'Multi value with PARAM_ALL = "x", passing "*"' => [
+                               '*',
+                               [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => "x" ],
+                               false,
+                               [ '*' ],
+                               [ '*' => '?' ],
+                               [ '?' ],
+                       ],
+
+                       'Too many values' => [
+                               'a|b|c|d',
+                               [
+                                       ParamValidator::PARAM_ISMULTI => true,
+                                       ParamValidator::PARAM_ISMULTI_LIMIT1 => 2,
+                                       ParamValidator::PARAM_ISMULTI_LIMIT2 => 4,
+                               ],
+                               false,
+                               null,
+                               [],
+                               new ValidationException( 'foobar', 'a|b|c|d', [], 'toomanyvalues', [ 'limit' => 2 ] ),
+                       ],
+                       'Too many values as array' => [
+                               [ 'a', 'b', 'c', 'd' ],
+                               [
+                                       ParamValidator::PARAM_ISMULTI => true,
+                                       ParamValidator::PARAM_ISMULTI_LIMIT1 => 2,
+                                       ParamValidator::PARAM_ISMULTI_LIMIT2 => 4,
+                               ],
+                               false,
+                               null,
+                               [],
+                               new ValidationException(
+                                       'foobar', [ 'a', 'b', 'c', 'd' ], [], 'toomanyvalues', [ 'limit' => 2 ]
+                               ),
+                       ],
+                       'Not too many values for highlimits' => [
+                               'a|b|c|d',
+                               [
+                                       ParamValidator::PARAM_ISMULTI => true,
+                                       ParamValidator::PARAM_ISMULTI_LIMIT1 => 2,
+                                       ParamValidator::PARAM_ISMULTI_LIMIT2 => 4,
+                               ],
+                               true,
+                               [ 'a', 'b', 'c', 'd' ],
+                               [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ],
+                               [ 'A', 'B', 'C', 'D' ],
+                       ],
+                       'Too many values for highlimits' => [
+                               'a|b|c|d|e',
+                               [
+                                       ParamValidator::PARAM_ISMULTI => true,
+                                       ParamValidator::PARAM_ISMULTI_LIMIT1 => 2,
+                                       ParamValidator::PARAM_ISMULTI_LIMIT2 => 4,
+                               ],
+                               true,
+                               null,
+                               [],
+                               new ValidationException( 'foobar', 'a|b|c|d|e', [], 'toomanyvalues', [ 'limit' => 4 ] ),
+                       ],
+
+                       'Too many values via default' => [
+                               'a|b|c|d',
+                               [
+                                       ParamValidator::PARAM_ISMULTI => true,
+                               ],
+                               false,
+                               null,
+                               [],
+                               new ValidationException( 'foobar', 'a|b|c|d', [], 'toomanyvalues', [ 'limit' => 2 ] ),
+                               [],
+                               [ 'ismultiLimits' => [ 2, 4 ] ],
+                       ],
+                       'Not too many values for highlimits via default' => [
+                               'a|b|c|d',
+                               [
+                                       ParamValidator::PARAM_ISMULTI => true,
+                               ],
+                               true,
+                               [ 'a', 'b', 'c', 'd' ],
+                               [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ],
+                               [ 'A', 'B', 'C', 'D' ],
+                               [],
+                               [ 'ismultiLimits' => [ 2, 4 ] ],
+                       ],
+                       'Too many values for highlimits via default' => [
+                               'a|b|c|d|e',
+                               [
+                                       ParamValidator::PARAM_ISMULTI => true,
+                               ],
+                               true,
+                               null,
+                               [],
+                               new ValidationException( 'foobar', 'a|b|c|d|e', [], 'toomanyvalues', [ 'limit' => 4 ] ),
+                               [],
+                               [ 'ismultiLimits' => [ 2, 4 ] ],
+                       ],
+
+                       'Invalid values' => [
+                               'a|b|c|d',
+                               [ ParamValidator::PARAM_ISMULTI => true ],
+                               false,
+                               [ 'a', 'b', 'c', 'd' ],
+                               [ 'a' => 'A', 'b' => null ],
+                               new ValidationException( 'foobar', 'b', [], 'badvalue', [] ),
+                       ],
+                       'Ignored invalid values' => [
+                               'a|b|c|d',
+                               [
+                                       ParamValidator::PARAM_ISMULTI => true,
+                                       ParamValidator::PARAM_IGNORE_INVALID_VALUES => true,
+                               ],
+                               false,
+                               [ 'a', 'b', 'c', 'd' ],
+                               [ 'a' => 'A', 'b' => null, 'c' => null, 'd' => 'D' ],
+                               [ 'A', 'D' ],
+                               [
+                                       [ 'unrecognizedvalues', 'values' => [ 'b', 'c' ] ],
+                               ],
+                       ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php b/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php
new file mode 100644 (file)
index 0000000..ebe1dcc
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+namespace Wikimedia\ParamValidator;
+
+use Psr\Http\Message\UploadedFileInterface;
+
+/**
+ * @covers Wikimedia\ParamValidator\SimpleCallbacks
+ */
+class SimpleCallbacksTest extends \PHPUnit\Framework\TestCase {
+
+       public function testDataAccess() {
+               $callbacks = new SimpleCallbacks(
+                       [ 'foo' => 'Foo!', 'bar' => null ],
+                       [
+                               'file1' => [
+                                       'name' => 'example.txt',
+                                       'type' => 'text/plain',
+                                       'tmp_name' => '...',
+                                       'error' => UPLOAD_ERR_OK,
+                                       'size' => 123,
+                               ],
+                               'file2' => [
+                                       'name' => '',
+                                       'type' => '',
+                                       'tmp_name' => '',
+                                       'error' => UPLOAD_ERR_NO_FILE,
+                                       'size' => 0,
+                               ],
+                       ]
+               );
+
+               $this->assertTrue( $callbacks->hasParam( 'foo', [] ) );
+               $this->assertFalse( $callbacks->hasParam( 'bar', [] ) );
+               $this->assertFalse( $callbacks->hasParam( 'baz', [] ) );
+               $this->assertFalse( $callbacks->hasParam( 'file1', [] ) );
+
+               $this->assertSame( 'Foo!', $callbacks->getValue( 'foo', null, [] ) );
+               $this->assertSame( null, $callbacks->getValue( 'bar', null, [] ) );
+               $this->assertSame( 123, $callbacks->getValue( 'bar', 123, [] ) );
+               $this->assertSame( null, $callbacks->getValue( 'baz', null, [] ) );
+               $this->assertSame( null, $callbacks->getValue( 'file1', null, [] ) );
+
+               $this->assertFalse( $callbacks->hasUpload( 'foo', [] ) );
+               $this->assertFalse( $callbacks->hasUpload( 'bar', [] ) );
+               $this->assertTrue( $callbacks->hasUpload( 'file1', [] ) );
+               $this->assertTrue( $callbacks->hasUpload( 'file2', [] ) );
+               $this->assertFalse( $callbacks->hasUpload( 'baz', [] ) );
+
+               $this->assertNull( $callbacks->getUploadedFile( 'foo', [] ) );
+               $this->assertNull( $callbacks->getUploadedFile( 'bar', [] ) );
+               $this->assertInstanceOf(
+                       UploadedFileInterface::class, $callbacks->getUploadedFile( 'file1', [] )
+               );
+               $this->assertInstanceOf(
+                       UploadedFileInterface::class, $callbacks->getUploadedFile( 'file2', [] )
+               );
+               $this->assertNull( $callbacks->getUploadedFile( 'baz', [] ) );
+
+               $file = $callbacks->getUploadedFile( 'file1', [] );
+               $this->assertSame( 'example.txt', $file->getClientFilename() );
+               $file = $callbacks->getUploadedFile( 'file2', [] );
+               $this->assertSame( UPLOAD_ERR_NO_FILE, $file->getError() );
+
+               $this->assertFalse( $callbacks->useHighLimits( [] ) );
+               $this->assertFalse( $callbacks->useHighLimits( [ 'useHighLimits' => false ] ) );
+               $this->assertTrue( $callbacks->useHighLimits( [ 'useHighLimits' => true ] ) );
+       }
+
+       public function testRecording() {
+               $callbacks = new SimpleCallbacks( [] );
+
+               $this->assertSame( [], $callbacks->getRecordedConditions() );
+
+               $ex1 = new ValidationException( 'foo', 'Foo!', [], 'foo', [] );
+               $callbacks->recordCondition( $ex1, [] );
+               $ex2 = new ValidationException( 'bar', null, [], 'barbar', [ 'bAr' => 'BaR' ] );
+               $callbacks->recordCondition( $ex2, [] );
+               $callbacks->recordCondition( $ex2, [] );
+               $this->assertSame( [ $ex1, $ex2, $ex2 ], $callbacks->getRecordedConditions() );
+
+               $callbacks->clearRecordedConditions();
+               $this->assertSame( [], $callbacks->getRecordedConditions() );
+               $callbacks->recordCondition( $ex1, [] );
+               $this->assertSame( [ $ex1 ], $callbacks->getRecordedConditions() );
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php
new file mode 100644 (file)
index 0000000..75afb33
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * @covers \Wikimedia\ParamValidator\TypeDef\BooleanDef
+ */
+class BooleanDefTest extends TypeDefTestCase {
+
+       protected static $testClass = BooleanDef::class;
+
+       public function provideValidate() {
+               $ex = new ValidationException( 'test', '', [], 'badbool', [
+                       'truevals' => BooleanDef::$TRUEVALS,
+                       'falsevals' => array_merge( BooleanDef::$FALSEVALS, [ 'the empty string' ] ),
+               ] );
+
+               foreach ( [
+                       [ BooleanDef::$TRUEVALS, true ],
+                       [ BooleanDef::$FALSEVALS, false ],
+                       [ [ '' ], false ],
+                       [ [ '2', 'foobar' ], $ex ],
+               ] as list( $vals, $expect ) ) {
+                       foreach ( $vals as $v ) {
+                               yield "Value '$v'" => [ $v, $expect ];
+                               $v2 = ucfirst( $v );
+                               if ( $v2 !== $v ) {
+                                       yield "Value '$v2'" => [ $v2, $expect ];
+                               }
+                               $v3 = strtoupper( $v );
+                               if ( $v3 !== $v2 ) {
+                                       yield "Value '$v3'" => [ $v3, $expect ];
+                               }
+                       }
+               }
+       }
+
+       public function provideStringifyValue() {
+               return [
+                       [ true, 'true' ],
+                       [ false, 'false' ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php
new file mode 100644 (file)
index 0000000..18d0aca
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef\EnumDef
+ */
+class EnumDefTest extends TypeDefTestCase {
+
+       protected static $testClass = EnumDef::class;
+
+       public function provideValidate() {
+               $settings = [
+                       ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ],
+                       EnumDef::PARAM_DEPRECATED_VALUES => [
+                               'b' => [ 'not-to-be' ],
+                               'c' => true,
+                       ],
+               ];
+
+               return [
+                       'Basic' => [ 'a', 'a', $settings ],
+                       'Deprecated' => [ 'c', 'c', $settings, [], [ [ 'deprecated-value', 'flag' => true ] ] ],
+                       'Deprecated with message' => [
+                               'b', 'b', $settings, [],
+                               [ [ 'deprecated-value', 'flag' => [ 'not-to-be' ] ] ],
+                       ],
+                       'Bad value, non-multi' => [
+                               'x', new ValidationException( 'test', 'x', $settings, 'badvalue', [] ),
+                               $settings,
+                       ],
+                       'Bad value, non-multi but looks like it' => [
+                               'x|y', new ValidationException( 'test', 'x|y', $settings, 'notmulti', [] ),
+                               $settings,
+                       ],
+                       'Bad value, multi' => [
+                               'x|y', new ValidationException( 'test', 'x|y', $settings, 'badvalue', [] ),
+                               $settings + [ ParamValidator::PARAM_ISMULTI => true ],
+                               [ 'values-list' => [ 'x|y' ] ],
+                       ],
+               ];
+       }
+
+       public function provideGetEnumValues() {
+               return [
+                       'Basic test' => [
+                               [ ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ] ],
+                               [ 'a', 'b', 'c', 'd' ],
+                       ],
+               ];
+       }
+
+       public function provideStringifyValue() {
+               return [
+                       'Basic test' => [ 123, '123' ],
+                       'Array' => [ [ 1, 2, 3 ], '1|2|3' ],
+                       'Array with pipes' => [ [ 1, 2, '3|4', 5 ], "\x1f1\x1f2\x1f3|4\x1f5" ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php
new file mode 100644 (file)
index 0000000..7bd053a
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\SimpleCallbacks;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef\FloatDef
+ */
+class FloatDefTest extends TypeDefTestCase {
+
+       protected static $testClass = FloatDef::class;
+
+       public function provideValidate() {
+               return [
+                       [ '123', 123.0 ],
+                       [ '123.4', 123.4 ],
+                       [ '0.4', 0.4 ],
+                       [ '.4', 0.4 ],
+
+                       [ '+123', 123.0 ],
+                       [ '+123.4', 123.4 ],
+                       [ '+0.4', 0.4 ],
+                       [ '+.4', 0.4 ],
+
+                       [ '-123', -123.0 ],
+                       [ '-123.4', -123.4 ],
+                       [ '-.4', -0.4 ],
+                       [ '-.4', -0.4 ],
+
+                       [ '123e5', 12300000.0 ],
+                       [ '123E5', 12300000.0 ],
+                       [ '123.4e+5', 12340000.0 ],
+                       [ '123E5', 12300000.0 ],
+                       [ '-123.4e-5', -0.001234 ],
+                       [ '.4E-5', 0.000004 ],
+
+                       [ '0', 0 ],
+                       [ '000000', 0 ],
+                       [ '0000.0000', 0 ],
+                       [ '000001.0002000000', 1.0002 ],
+                       [ '1e0', 1 ],
+                       [ '1e-0000', 1 ],
+                       [ '1e+00010', 1e10 ],
+
+                       'Weird, but ok' => [ '-0', 0 ],
+                       'Underflow is ok' => [ '1e-9999', 0 ],
+
+                       'Empty decimal part' => [ '1.', new ValidationException( 'test', '1.', [], 'badfloat', [] ) ],
+                       'Bad sign' => [ ' 1', new ValidationException( 'test', ' 1', [], 'badfloat', [] ) ],
+                       'Comma as decimal separator or thousands grouping?'
+                               => [ '1,234', new ValidationException( 'test', '1,234', [], 'badfloat', [] ) ],
+                       'U+2212 minus' => [ '−1', new ValidationException( 'test', '−1', [], 'badfloat', [] ) ],
+                       'Overflow' => [ '1e9999', new ValidationException( 'test', '1e9999', [], 'notfinite', [] ) ],
+                       'Overflow, -INF'
+                               => [ '-1e9999', new ValidationException( 'test', '-1e9999', [], 'notfinite', [] ) ],
+                       'Bogus value' => [ 'foo', new ValidationException( 'test', 'foo', [], 'badfloat', [] ) ],
+                       'Bogus value (2)' => [ '123f4', new ValidationException( 'test', '123f4', [], 'badfloat', [] ) ],
+                       'Newline' => [ "123\n", new ValidationException( 'test', "123\n", [], 'badfloat', [] ) ],
+               ];
+       }
+
+       public function provideStringifyValue() {
+               $digits = defined( 'PHP_FLOAT_DIG' ) ? PHP_FLOAT_DIG : 15;
+
+               return [
+                       [ 1.2, '1.2' ],
+                       [ 10 / 3, '3.' . str_repeat( '3', $digits - 1 ) ],
+                       [ 1e100, '1.0e+100' ],
+                       [ 6.022e-23, '6.022e-23' ],
+               ];
+       }
+
+       /** @dataProvider provideLocales */
+       public function testStringifyValue_localeWeirdness( $locale ) {
+               static $cats = [ LC_ALL, LC_MONETARY, LC_NUMERIC ];
+
+               $curLocales = [];
+               foreach ( $cats as $c ) {
+                       $curLocales[$c] = setlocale( $c, '0' );
+                       if ( $curLocales[$c] === false ) {
+                               $this->markTestSkipped( 'Locale support is unavailable' );
+                       }
+               }
+               try {
+                       foreach ( $cats as $c ) {
+                               if ( setlocale( $c, $locale ) === false ) {
+                                       $this->markTestSkipped( "Locale \"$locale\" is unavailable" );
+                               }
+                       }
+
+                       $typeDef = $this->getInstance( new SimpleCallbacks( [] ), [] );
+                       $this->assertSame( '123456.789', $typeDef->stringifyValue( 'test', 123456.789, [], [] ) );
+                       $this->assertSame( '-123456.789', $typeDef->stringifyValue( 'test', -123456.789, [], [] ) );
+                       $this->assertSame( '1.0e+20', $typeDef->stringifyValue( 'test', 1e20, [], [] ) );
+                       $this->assertSame( '1.0e-20', $typeDef->stringifyValue( 'test', 1e-20, [], [] ) );
+               } finally {
+                       foreach ( $curLocales as $c => $v ) {
+                               setlocale( $c, $v );
+                       }
+               }
+       }
+
+       public function provideLocales() {
+               return [
+                       // May as well test these.
+                       [ 'C' ],
+                       [ 'C.UTF-8' ],
+
+                       // Some hopefullt-common locales with decimal_point = ',' and thousands_sep = '.'
+                       [ 'de_DE' ],
+                       [ 'de_DE.utf8' ],
+                       [ 'es_ES' ],
+                       [ 'es_ES.utf8' ],
+
+                       // This one, on my system at least, has decimal_point as U+066B.
+                       [ 'ps_AF' ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php
new file mode 100644 (file)
index 0000000..21fc987
--- /dev/null
@@ -0,0 +1,159 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef\IntegerDef
+ */
+class IntegerDefTest extends TypeDefTestCase {
+
+       protected static $testClass = IntegerDef::class;
+
+       /**
+        * @param string $v Representing a positive integer
+        * @return string Representing $v + 1
+        */
+       private static function plusOne( $v ) {
+               for ( $i = strlen( $v ) - 1; $i >= 0; $i-- ) {
+                       if ( $v[$i] === '9' ) {
+                               $v[$i] = '0';
+                       } else {
+                               $v[$i] = $v[$i] + 1;
+                               return $v;
+                       }
+               }
+               return '1' . $v;
+       }
+
+       public function provideValidate() {
+               $badinteger = new ValidationException( 'test', '...', [], 'badinteger', [] );
+               $belowminimum = new ValidationException(
+                       'test', '...', [], 'belowminimum', [ 'min' => 0, 'max' => 2, 'max2' => '' ]
+               );
+               $abovemaximum = new ValidationException(
+                       'test', '...', [], 'abovemaximum', [ 'min' => 0, 'max' => 2, 'max2' => '' ]
+               );
+               $abovemaximum2 = new ValidationException(
+                       'test', '...', [], 'abovemaximum', [ 'min' => 0, 'max' => 2, 'max2' => 4 ]
+               );
+               $abovehighmaximum = new ValidationException(
+                       'test', '...', [], 'abovehighmaximum', [ 'min' => 0, 'max' => 2, 'max2' => 4 ]
+               );
+               $asWarn = function ( ValidationException $ex ) {
+                       return [ $ex->getFailureCode() ] + $ex->getFailureData();
+               };
+
+               $minmax = [
+                       IntegerDef::PARAM_MIN => 0,
+                       IntegerDef::PARAM_MAX => 2,
+               ];
+               $minmax2 = [
+                       IntegerDef::PARAM_MIN => 0,
+                       IntegerDef::PARAM_MAX => 2,
+                       IntegerDef::PARAM_MAX2 => 4,
+               ];
+               $ignore = [
+                       IntegerDef::PARAM_IGNORE_RANGE => true,
+               ];
+               $usehigh = [ 'useHighLimits' => true ];
+
+               return [
+                       [ '123', 123 ],
+                       [ '-123', -123 ],
+                       [ '000123', 123 ],
+                       [ '000', 0 ],
+                       [ '-0', 0 ],
+                       [ (string)PHP_INT_MAX, PHP_INT_MAX ],
+                       [ '0000' . PHP_INT_MAX, PHP_INT_MAX ],
+                       [ (string)PHP_INT_MIN, PHP_INT_MIN ],
+                       [ '-0000' . substr( PHP_INT_MIN, 1 ), PHP_INT_MIN ],
+
+                       'Overflow' => [ self::plusOne( (string)PHP_INT_MAX ), $badinteger ],
+                       'Negative overflow' => [ '-' . self::plusOne( substr( PHP_INT_MIN, 1 ) ), $badinteger ],
+
+                       'Float' => [ '1.5', $badinteger ],
+                       'Float (e notation)' => [ '1e1', $badinteger ],
+                       'Bad sign (space)' => [ ' 1', $badinteger ],
+                       'Bad sign (newline)' => [ "\n1", $badinteger ],
+                       'Bogus value' => [ 'foo', $badinteger ],
+                       'Bogus value (2)' => [ '1foo', $badinteger ],
+                       'Hex value' => [ '0x123', $badinteger ],
+                       'Newline' => [ "1\n", $badinteger ],
+
+                       'Ok with range' => [ '1', 1, $minmax ],
+                       'Below minimum' => [ '-1', $belowminimum, $minmax ],
+                       'Below minimum, ignored' => [ '-1', 0, $minmax + $ignore, [], [ $asWarn( $belowminimum ) ] ],
+                       'Above maximum' => [ '3', $abovemaximum, $minmax ],
+                       'Above maximum, ignored' => [ '3', 2, $minmax + $ignore, [], [ $asWarn( $abovemaximum ) ] ],
+                       'Not above max2 but can\'t use it' => [ '3', $abovemaximum2, $minmax2, [] ],
+                       'Not above max2 but can\'t use it, ignored'
+                               => [ '3', 2, $minmax2 + $ignore, [], [ $asWarn( $abovemaximum2 ) ] ],
+                       'Not above max2' => [ '3', 3, $minmax2, $usehigh ],
+                       'Above max2' => [ '5', $abovehighmaximum, $minmax2, $usehigh ],
+                       'Above max2, ignored'
+                               => [ '5', 4, $minmax2 + $ignore, $usehigh, [ $asWarn( $abovehighmaximum ) ] ],
+               ];
+       }
+
+       public function provideNormalizeSettings() {
+               return [
+                       [ [], [] ],
+                       [
+                               [ IntegerDef::PARAM_MAX => 2 ],
+                               [ IntegerDef::PARAM_MAX => 2 ],
+                       ],
+                       [
+                               [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
+                               [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
+                       ],
+                       [
+                               [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 2 ],
+                               [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 4 ],
+                       ],
+                       [
+                               [ IntegerDef::PARAM_MAX2 => 2 ],
+                               [],
+                       ],
+               ];
+       }
+
+       public function provideDescribeSettings() {
+               return [
+                       'Basic' => [ [], [], [] ],
+                       'Default' => [
+                               [ ParamValidator::PARAM_DEFAULT => 123 ],
+                               [ 'default' => '123' ],
+                               [ 'default' => [ 'value' => '123' ] ],
+                       ],
+                       'Min' => [
+                               [ ParamValidator::PARAM_DEFAULT => 123, IntegerDef::PARAM_MIN => 0 ],
+                               [ 'default' => '123', 'min' => 0 ],
+                               [ 'default' => [ 'value' => '123' ], 'min' => [ 'min' => 0, 'max' => '', 'max2' => '' ] ],
+                       ],
+                       'Max' => [
+                               [ IntegerDef::PARAM_MAX => 2 ],
+                               [ 'max' => 2 ],
+                               [ 'max' => [ 'min' => '', 'max' => 2, 'max2' => '' ] ],
+                       ],
+                       'Max2' => [
+                               [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
+                               [ 'max' => 2, 'max2' => 4 ],
+                               [ 'max2' => [ 'min' => '', 'max' => 2, 'max2' => 4 ] ],
+                       ],
+                       'Minmax' => [
+                               [ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2 ],
+                               [ 'min' => 0, 'max' => 2 ],
+                               [ 'minmax' => [ 'min' => 0, 'max' => 2, 'max2' => '' ] ],
+                       ],
+                       'Minmax2' => [
+                               [ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
+                               [ 'min' => 0, 'max' => 2, 'max2' => 4 ],
+                               [ 'minmax2' => [ 'min' => 0, 'max' => 2, 'max2' => 4 ] ],
+                       ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php
new file mode 100644 (file)
index 0000000..2bf25e5
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+require_once __DIR__ . '/IntegerDefTest.php';
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef\LimitDef
+ */
+class LimitDefTest extends IntegerDefTest {
+
+       protected static $testClass = LimitDef::class;
+
+       public function provideValidate() {
+               yield from parent::provideValidate();
+
+               $useHigh = [ 'useHighLimits' => true ];
+               $max = [ IntegerDef::PARAM_MAX => 2 ];
+               $max2 = [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ];
+
+               yield 'Max' => [ 'max', 2, $max ];
+               yield 'Max, use high' => [ 'max', 2, $max, $useHigh ];
+               yield 'Max2' => [ 'max', 2, $max2 ];
+               yield 'Max2, use high' => [ 'max', 4, $max2, $useHigh ];
+       }
+
+       public function provideNormalizeSettings() {
+               return [
+                       [ [], [ IntegerDef::PARAM_MIN => 0 ] ],
+                       [
+                               [ IntegerDef::PARAM_MAX => 2 ],
+                               [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MIN => 0 ],
+                       ],
+                       [
+                               [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
+                               [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
+                       ],
+                       [
+                               [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 2 ],
+                               [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 4 ],
+                       ],
+                       [
+                               [ IntegerDef::PARAM_MAX2 => 2 ],
+                               [ IntegerDef::PARAM_MIN => 0 ],
+                       ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php
new file mode 100644 (file)
index 0000000..dd97903
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\ParamValidator;
+
+require_once __DIR__ . '/StringDefTest.php';
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef\PasswordDef
+ */
+class PasswordDefTest extends StringDefTest {
+
+       protected static $testClass = PasswordDef::class;
+
+       public function provideNormalizeSettings() {
+               return [
+                       [ [], [ ParamValidator::PARAM_SENSITIVE => true ] ],
+                       [ [ ParamValidator::PARAM_SENSITIVE => false ], [ ParamValidator::PARAM_SENSITIVE => true ] ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php
new file mode 100644 (file)
index 0000000..dd690de
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\ParamValidator;
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef\PresenceBooleanDef
+ */
+class PresenceBooleanDefTest extends TypeDefTestCase {
+
+       protected static $testClass = PresenceBooleanDef::class;
+
+       public function provideValidate() {
+               return [
+                       [ null, false ],
+                       [ '', true ],
+                       [ '0', true ],
+                       [ '1', true ],
+                       [ 'anything really', true ],
+               ];
+       }
+
+       public function provideDescribeSettings() {
+               return [
+                       [ [], [], [] ],
+                       [ [ ParamValidator::PARAM_DEFAULT => 'foo' ], [], [] ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php
new file mode 100644 (file)
index 0000000..bae2f02
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\SimpleCallbacks;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef\StringDef
+ */
+class StringDefTest extends TypeDefTestCase {
+
+       protected static $testClass = StringDef::class;
+
+       protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
+               if ( static::$testClass === null ) {
+                       throw new \LogicException( 'Either assign static::$testClass or override ' . __METHOD__ );
+               }
+
+               return new static::$testClass( $callbacks, $options );
+       }
+
+       public function provideValidate() {
+               $req = [
+                       ParamValidator::PARAM_REQUIRED => true,
+               ];
+               $maxBytes = [
+                       StringDef::PARAM_MAX_BYTES => 4,
+               ];
+               $maxChars = [
+                       StringDef::PARAM_MAX_CHARS => 2,
+               ];
+
+               return [
+                       'Basic' => [ '123', '123' ],
+                       'Empty' => [ '', '' ],
+                       'Empty, required' => [
+                               '',
+                               new ValidationException( 'test', '', [], 'missingparam', [] ),
+                               $req,
+                       ],
+                       'Empty, required, allowed' => [ '', '', $req, [ 'allowEmptyWhenRequired' => true ] ],
+                       'Max bytes, ok' => [ 'abcd', 'abcd', $maxBytes ],
+                       'Max bytes, exceeded' => [
+                               'abcde',
+                               new ValidationException( 'test', '', [], 'maxbytes', [ 'maxbytes' => 4, 'maxchars' => '' ] ),
+                               $maxBytes,
+                       ],
+                       'Max bytes, ok (2)' => [ '😄', '😄', $maxBytes ],
+                       'Max bytes, exceeded (2)' => [
+                               '😭?',
+                               new ValidationException( 'test', '', [], 'maxbytes', [ 'maxbytes' => 4, 'maxchars' => '' ] ),
+                               $maxBytes,
+                       ],
+                       'Max chars, ok' => [ 'ab', 'ab', $maxChars ],
+                       'Max chars, exceeded' => [
+                               'abc',
+                               new ValidationException( 'test', '', [], 'maxchars', [ 'maxbytes' => '', 'maxchars' => 2 ] ),
+                               $maxChars,
+                       ],
+                       'Max chars, ok (2)' => [ '😄😄', '😄😄', $maxChars ],
+                       'Max chars, exceeded (2)' => [
+                               '😭??',
+                               new ValidationException( 'test', '', [], 'maxchars', [ 'maxbytes' => '', 'maxchars' => 2 ] ),
+                               $maxChars,
+                       ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php
new file mode 100644 (file)
index 0000000..8adf190
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+use Wikimedia\ParamValidator\SimpleCallbacks;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef\TimestampDef
+ */
+class TimestampDefTest extends TypeDefTestCase {
+
+       protected static $testClass = TimestampDef::class;
+
+       protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
+               if ( static::$testClass === null ) {
+                       throw new \LogicException( 'Either assign static::$testClass or override ' . __METHOD__ );
+               }
+
+               return new static::$testClass( $callbacks, $options );
+       }
+
+       /** @dataProvider provideValidate */
+       public function testValidate(
+               $value, $expect, array $settings = [], array $options = [], array $expectConds = []
+       ) {
+               $reset = ConvertibleTimestamp::setFakeTime( 1559764242 );
+               try {
+                       parent::testValidate( $value, $expect, $settings, $options, $expectConds );
+               } finally {
+                       ConvertibleTimestamp::setFakeTime( $reset );
+               }
+       }
+
+       public function provideValidate() {
+               $specific = new ConvertibleTimestamp( 1517630706 );
+               $specificMs = new ConvertibleTimestamp( 1517630706.999 );
+               $now = new ConvertibleTimestamp( 1559764242 );
+
+               $formatDT = [ TimestampDef::PARAM_TIMESTAMP_FORMAT => 'DateTime' ];
+               $formatMW = [ TimestampDef::PARAM_TIMESTAMP_FORMAT => TS_MW ];
+
+               return [
+                       // We don't try to validate all formats supported by ConvertibleTimestamp, just
+                       // some of the interesting ones.
+                       'ISO format' => [ '2018-02-03T04:05:06Z', $specific ],
+                       'ISO format with TZ' => [ '2018-02-03T00:05:06-04:00', $specific ],
+                       'ISO format without punctuation' => [ '20180203T040506', $specific ],
+                       'ISO format with ms' => [ '2018-02-03T04:05:06.999000Z', $specificMs ],
+                       'ISO format with ms without punctuation' => [ '20180203T040506.999', $specificMs ],
+                       'MW format' => [ '20180203040506', $specific ],
+                       'Generic format' => [ '2018-02-03 04:05:06', $specific ],
+                       'Generic format + GMT' => [ '2018-02-03 04:05:06 GMT', $specific ],
+                       'Generic format + TZ +0100' => [ '2018-02-03 05:05:06+0100', $specific ],
+                       'Generic format + TZ -01' => [ '2018-02-03 03:05:06-01', $specific ],
+                       'Seconds-since-epoch format' => [ '1517630706', $specific ],
+                       'Now' => [ 'now', $now ],
+
+                       // Warnings
+                       'Empty' => [ '', $now, [], [], [ [ 'unclearnowtimestamp' ] ] ],
+                       'Zero' => [ '0', $now, [], [], [ [ 'unclearnowtimestamp' ] ] ],
+
+                       // Error handling
+                       'Bad value' => [
+                               'bogus',
+                               new ValidationException( 'test', 'bogus', [], 'badtimestamp', [] ),
+                       ],
+
+                       // Formatting
+                       '=> DateTime' => [ 'now', $now->timestamp, $formatDT ],
+                       '=> TS_MW' => [ 'now', '20190605195042', $formatMW ],
+                       '=> TS_MW as default' => [ 'now', '20190605195042', [], [ 'defaultFormat' => TS_MW ] ],
+                       '=> TS_MW overriding default'
+                               => [ 'now', '20190605195042', $formatMW, [ 'defaultFormat' => TS_ISO_8601 ] ],
+               ];
+       }
+
+       public function provideStringifyValue() {
+               $specific = new ConvertibleTimestamp( '20180203040506' );
+
+               return [
+                       [ '20180203040506', '2018-02-03T04:05:06Z' ],
+                       [ $specific, '2018-02-03T04:05:06Z' ],
+                       [ $specific->timestamp, '2018-02-03T04:05:06Z' ],
+                       [ $specific, '20180203040506', [], [ 'stringifyFormat' => TS_MW ] ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php
new file mode 100644 (file)
index 0000000..fa86c79
--- /dev/null
@@ -0,0 +1,193 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\SimpleCallbacks;
+use Wikimedia\ParamValidator\TypeDef;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * Test case infrastructure for TypeDef subclasses
+ *
+ * Generally you'll only need to override static::$testClass and data providers
+ * for methods the TypeDef actually implements.
+ */
+abstract class TypeDefTestCase extends \PHPUnit\Framework\TestCase {
+
+       /** @var string|null TypeDef class name being tested */
+       protected static $testClass = null;
+
+       /**
+        * Create a SimpleCallbacks for testing
+        *
+        * The object created here should result in a call to the TypeDef's
+        * `getValue( 'test' )` returning an appropriate result for testing.
+        *
+        * @param mixed $value Value to return for 'test'
+        * @param array $options Options array.
+        * @return SimpleCallbacks
+        */
+       protected function getCallbacks( $value, array $options ) {
+               return new SimpleCallbacks( [ 'test' => $value ] );
+       }
+
+       /**
+        * Create an instance of the TypeDef subclass being tested
+        *
+        * @param SimpleCallbacks $callbacks From $this->getCallbacks()
+        * @param array $options Options array.
+        * @return TypeDef
+        */
+       protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
+               if ( static::$testClass === null ) {
+                       throw new \LogicException( 'Either assign static::$testClass or override ' . __METHOD__ );
+               }
+
+               return new static::$testClass( $callbacks );
+       }
+
+       /**
+        * @dataProvider provideValidate
+        * @param mixed $value Value for getCallbacks()
+        * @param mixed|ValidationException $expect Expected result from TypeDef::validate().
+        *  If a ValidationException, it is expected that a ValidationException
+        *  with matching failure code and data will be thrown. Otherwise, the return value must be equal.
+        * @param array $settings Settings array.
+        * @param array $options Options array
+        * @param array[] $expectConds Expected conditions reported. Each array is
+        *  `[ $ex->getFailureCode() ] + $ex->getFailureData()`.
+        */
+       public function testValidate(
+               $value, $expect, array $settings = [], array $options = [], array $expectConds = []
+       ) {
+               $callbacks = $this->getCallbacks( $value, $options );
+               $typeDef = $this->getInstance( $callbacks, $options );
+
+               if ( $expect instanceof ValidationException ) {
+                       try {
+                               $v = $typeDef->getValue( 'test', $settings, $options );
+                               $typeDef->validate( 'test', $v, $settings, $options );
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( ValidationException $ex ) {
+                               $this->assertEquals( $expect->getFailureCode(), $ex->getFailureCode() );
+                               $this->assertEquals( $expect->getFailureData(), $ex->getFailureData() );
+                       }
+               } else {
+                       $v = $typeDef->getValue( 'test', $settings, $options );
+                       $this->assertEquals( $expect, $typeDef->validate( 'test', $v, $settings, $options ) );
+               }
+
+               $conditions = [];
+               foreach ( $callbacks->getRecordedConditions() as $ex ) {
+                       $conditions[] = array_merge( [ $ex->getFailureCode() ], $ex->getFailureData() );
+               }
+               $this->assertSame( $expectConds, $conditions );
+       }
+
+       /**
+        * @return array|Iterable
+        */
+       abstract public function provideValidate();
+
+       /**
+        * @dataProvider provideNormalizeSettings
+        * @param array $settings
+        * @param array $expect
+        * @param array $options Options array
+        */
+       public function testNormalizeSettings( array $settings, array $expect, array $options = [] ) {
+               $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options );
+               $this->assertSame( $expect, $typeDef->normalizeSettings( $settings ) );
+       }
+
+       /**
+        * @return array|Iterable
+        */
+       public function provideNormalizeSettings() {
+               return [
+                       'Basic test' => [ [ 'param-foo' => 'bar' ], [ 'param-foo' => 'bar' ] ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideGetEnumValues
+        * @param array $settings
+        * @param array|null $expect
+        * @param array $options Options array
+        */
+       public function testGetEnumValues( array $settings, $expect, array $options = [] ) {
+               $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options );
+               $this->assertSame( $expect, $typeDef->getEnumValues( 'test', $settings, $options ) );
+       }
+
+       /**
+        * @return array|Iterable
+        */
+       public function provideGetEnumValues() {
+               return [
+                       'Basic test' => [ [], null ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideStringifyValue
+        * @param mixed $value
+        * @param string|null $expect
+        * @param array $settings
+        * @param array $options Options array
+        */
+       public function testStringifyValue( $value, $expect, array $settings = [], array $options = [] ) {
+               $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options );
+               $this->assertSame( $expect, $typeDef->stringifyValue( 'test', $value, $settings, $options ) );
+       }
+
+       /**
+        * @return array|Iterable
+        */
+       public function provideStringifyValue() {
+               return [
+                       'Basic test' => [ 123, '123' ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideDescribeSettings
+        * @param array $settings
+        * @param array $expectNormal
+        * @param array $expectCompact
+        * @param array $options Options array
+        */
+       public function testDescribeSettings(
+               array $settings, array $expectNormal, array $expectCompact, array $options = []
+       ) {
+               $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options );
+               $this->assertSame(
+                       $expectNormal,
+                       $typeDef->describeSettings( 'test', $settings, $options ),
+                       'Normal mode'
+               );
+               $this->assertSame(
+                       $expectCompact,
+                       $typeDef->describeSettings( 'test', $settings, [ 'compact' => true ] + $options ),
+                       'Compact mode'
+               );
+       }
+
+       /**
+        * @return array|Iterable
+        */
+       public function provideDescribeSettings() {
+               yield 'Basic test' => [ [], [], [] ];
+
+               foreach ( $this->provideStringifyValue() as $i => $v ) {
+                       yield "Default value (from provideStringifyValue data set \"$i\")" => [
+                               [ ParamValidator::PARAM_DEFAULT => $v[0] ] + ( $v[2] ?? [] ),
+                               [ 'default' => $v[1] ],
+                               [ 'default' => [ 'value' => $v[1] ] ],
+                               $v[3] ?? [],
+                       ];
+               }
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php
new file mode 100644 (file)
index 0000000..c81647c
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+
+namespace Wikimedia\ParamValidator\TypeDef;
+
+use Wikimedia\ParamValidator\SimpleCallbacks;
+use Wikimedia\ParamValidator\Util\UploadedFile;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef\UploadDef
+ */
+class UploadDefTest extends TypeDefTestCase {
+
+       protected static $testClass = UploadDef::class;
+
+       protected function getCallbacks( $value, array $options ) {
+               if ( $value instanceof UploadedFile ) {
+                       return new SimpleCallbacks( [], [ 'test' => $value ] );
+               } else {
+                       return new SimpleCallbacks( [ 'test' => $value ] );
+               }
+       }
+
+       protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
+               $ret = $this->getMockBuilder( UploadDef::class )
+                       ->setConstructorArgs( [ $callbacks ] )
+                       ->setMethods( [ 'getIniSize' ] )
+                       ->getMock();
+               $ret->method( 'getIniSize' )->willReturn( $options['inisize'] ?? 2 * 1024 * 1024 );
+               return $ret;
+       }
+
+       private function makeUpload( $err = UPLOAD_ERR_OK ) {
+               return new UploadedFile( [
+                       'name' => 'example.txt',
+                       'type' => 'text/plain',
+                       'size' => 0,
+                       'tmp_name' => '...',
+                       'error' => $err,
+               ] );
+       }
+
+       public function testGetNoFile() {
+               $typeDef = $this->getInstance(
+                       $this->getCallbacks( $this->makeUpload( UPLOAD_ERR_NO_FILE ), [] ),
+                       []
+               );
+
+               $this->assertNull( $typeDef->getValue( 'test', [], [] ) );
+               $this->assertNull( $typeDef->getValue( 'nothing', [], [] ) );
+       }
+
+       public function provideValidate() {
+               $okFile = $this->makeUpload();
+               $iniFile = $this->makeUpload( UPLOAD_ERR_INI_SIZE );
+               $exIni = new ValidationException(
+                       'test', '', [], 'badupload-inisize', [ 'size' => 2 * 1024 * 1024 * 1024 ]
+               );
+
+               return [
+                       'Valid upload' => [ $okFile, $okFile ],
+                       'Not an upload' => [
+                               'bar',
+                               new ValidationException( 'test', 'bar', [], 'badupload-notupload', [] ),
+                       ],
+
+                       'Too big (bytes)' => [ $iniFile, $exIni, [], [ 'inisize' => 2 * 1024 * 1024 * 1024 ] ],
+                       'Too big (k)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'k' ] ],
+                       'Too big (K)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'K' ] ],
+                       'Too big (m)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'm' ] ],
+                       'Too big (M)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'M' ] ],
+                       'Too big (g)' => [ $iniFile, $exIni, [], [ 'inisize' => '2g' ] ],
+                       'Too big (G)' => [ $iniFile, $exIni, [], [ 'inisize' => '2G' ] ],
+
+                       'Form size' => [
+                               $this->makeUpload( UPLOAD_ERR_FORM_SIZE ),
+                               new ValidationException( 'test', '', [], 'badupload-formsize', [] ),
+                       ],
+                       'Partial' => [
+                               $this->makeUpload( UPLOAD_ERR_PARTIAL ),
+                               new ValidationException( 'test', '', [], 'badupload-partial', [] ),
+                       ],
+                       'No tmp' => [
+                               $this->makeUpload( UPLOAD_ERR_NO_TMP_DIR ),
+                               new ValidationException( 'test', '', [], 'badupload-notmpdir', [] ),
+                       ],
+                       'Can\'t write' => [
+                               $this->makeUpload( UPLOAD_ERR_CANT_WRITE ),
+                               new ValidationException( 'test', '', [], 'badupload-cantwrite', [] ),
+                       ],
+                       'Ext abort' => [
+                               $this->makeUpload( UPLOAD_ERR_EXTENSION ),
+                               new ValidationException( 'test', '', [], 'badupload-phpext', [] ),
+                       ],
+                       'Unknown' => [
+                               $this->makeUpload( -43 ), // Should be safe from ever being an UPLOAD_ERR_* constant
+                               new ValidationException( 'test', '', [], 'badupload-unknown', [ 'code' => -43 ] ),
+                       ],
+
+                       'Validating null' => [
+                               null,
+                               new ValidationException( 'test', '', [], 'badupload', [] ),
+                       ],
+               ];
+       }
+
+       public function provideStringifyValue() {
+               return [
+                       'Yeah, right' => [ $this->makeUpload(), null ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php
new file mode 100644 (file)
index 0000000..7675a8c
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+namespace Wikimedia\ParamValidator;
+
+/**
+ * @covers Wikimedia\ParamValidator\TypeDef
+ */
+class TypeDefTest extends \PHPUnit\Framework\TestCase {
+
+       public function testMisc() {
+               $typeDef = $this->getMockBuilder( TypeDef::class )
+                       ->setConstructorArgs( [ new SimpleCallbacks( [] ) ] )
+                       ->getMockForAbstractClass();
+
+               $this->assertSame( [ 'foobar' ], $typeDef->normalizeSettings( [ 'foobar' ] ) );
+               $this->assertNull( $typeDef->getEnumValues( 'foobar', [], [] ) );
+               $this->assertSame( '123', $typeDef->stringifyValue( 'foobar', 123, [], [] ) );
+       }
+
+       public function testGetValue() {
+               $options = [ (object)[] ];
+
+               $callbacks = $this->getMockBuilder( Callbacks::class )->getMockForAbstractClass();
+               $callbacks->expects( $this->once() )->method( 'getValue' )
+                       ->with(
+                               $this->identicalTo( 'foobar' ),
+                               $this->identicalTo( null ),
+                               $this->identicalTo( $options )
+                       )
+                       ->willReturn( 'zyx' );
+
+               $typeDef = $this->getMockBuilder( TypeDef::class )
+                       ->setConstructorArgs( [ $callbacks ] )
+                       ->getMockForAbstractClass();
+
+               $this->assertSame(
+                       'zyx',
+                       $typeDef->getValue( 'foobar', [ ParamValidator::PARAM_DEFAULT => 'foo' ], $options )
+               );
+       }
+
+       public function testDescribeSettings() {
+               $typeDef = $this->getMockBuilder( TypeDef::class )
+                       ->setConstructorArgs( [ new SimpleCallbacks( [] ) ] )
+                       ->getMockForAbstractClass();
+
+               $this->assertSame(
+                       [],
+                       $typeDef->describeSettings(
+                               'foobar',
+                               [ ParamValidator::PARAM_TYPE => 'xxx' ],
+                               []
+                       )
+               );
+
+               $this->assertSame(
+                       [
+                               'default' => '123',
+                       ],
+                       $typeDef->describeSettings(
+                               'foobar',
+                               [ ParamValidator::PARAM_DEFAULT => 123 ],
+                               []
+                       )
+               );
+
+               $this->assertSame(
+                       [
+                               'default' => [ 'value' => '123' ],
+                       ],
+                       $typeDef->describeSettings(
+                               'foobar',
+                               [ ParamValidator::PARAM_DEFAULT => 123 ],
+                               [ 'compact' => true ]
+                       )
+               );
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php
new file mode 100644 (file)
index 0000000..9eaddf6
--- /dev/null
@@ -0,0 +1,294 @@
+<?php
+
+namespace Wikimedia\ParamValidator\Util;
+
+require_once __DIR__ . '/UploadedFileTestBase.php';
+
+use RuntimeException;
+use Wikimedia\AtEase\AtEase;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers Wikimedia\ParamValidator\Util\UploadedFileStream
+ */
+class UploadedFileStreamTest extends UploadedFileTestBase {
+
+       /**
+        * @expectedException RuntimeException
+        * @expectedExceptionMessage Failed to open file:
+        */
+       public function testConstruct_doesNotExist() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               unlink( $filename );
+
+               $this->assertFileNotExists( $filename, 'sanity check' );
+               $stream = new UploadedFileStream( $filename );
+       }
+
+       /**
+        * @expectedException RuntimeException
+        * @expectedExceptionMessage Failed to open file:
+        */
+       public function testConstruct_notReadable() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+
+               chmod( $filename, 0000 );
+               $stream = new UploadedFileStream( $filename );
+       }
+
+       public function testCloseOnDestruct() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+               $fp = TestingAccessWrapper::newFromObject( $stream )->fp;
+               $this->assertSame( 'f', fread( $fp, 1 ), 'sanity check' );
+               unset( $stream );
+               $this->assertFalse( AtEase::quietCall( 'fread', $fp, 1 ) );
+       }
+
+       public function testToString() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+
+               // Always starts at the start of the stream
+               $stream->seek( 3 );
+               $this->assertSame( 'foobar', (string)$stream );
+
+               // No exception when closed
+               $stream->close();
+               $this->assertSame( '', (string)$stream );
+       }
+
+       public function testToString_Error() {
+               if ( !class_exists( \Error::class ) ) {
+                       $this->markTestSkipped( 'No PHP Error class' );
+               }
+
+               // ... Yeah
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = $this->getMockBuilder( UploadedFileStream::class )
+                       ->setConstructorArgs( [ $filename ] )
+                       ->setMethods( [ 'getContents' ] )
+                       ->getMock();
+               $stream->method( 'getContents' )->willReturnCallback( function () {
+                       throw new \Error( 'Bogus' );
+               } );
+               $this->assertSame( '', (string)$stream );
+       }
+
+       public function testClose() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+
+               $stream->close();
+
+               // Second call doesn't error
+               $stream->close();
+       }
+
+       public function testDetach() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+
+               // We got the file descriptor
+               $fp = $stream->detach();
+               $this->assertNotNull( $fp );
+               $this->assertSame( 'f', fread( $fp, 1 ) );
+
+               // Stream operations now fail.
+               try {
+                       $stream->seek( 0 );
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+               }
+
+               // Stream close doesn't affect the file descriptor
+               $stream->close();
+               $this->assertSame( 'o', fread( $fp, 1 ) );
+
+               // Stream destruction doesn't affect the file descriptor
+               unset( $stream );
+               $this->assertSame( 'o', fread( $fp, 1 ) );
+
+               // On a closed stream, we don't get a file descriptor
+               $stream = new UploadedFileStream( $filename );
+               $stream->close();
+               $this->assertNull( $stream->detach() );
+       }
+
+       public function testGetSize() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+               file_put_contents( $filename, 'foobarbaz' );
+               $this->assertSame( 9, $stream->getSize() );
+
+               // Cached
+               file_put_contents( $filename, 'baz' );
+               clearstatcache();
+               $this->assertSame( 3, stat( $filename )['size'], 'sanity check' );
+               $this->assertSame( 9, $stream->getSize() );
+
+               // No error if closed
+               $stream = new UploadedFileStream( $filename );
+               $stream->close();
+               $this->assertSame( null, $stream->getSize() );
+
+               // No error even if the fd goes bad
+               $stream = new UploadedFileStream( $filename );
+               fclose( TestingAccessWrapper::newFromObject( $stream )->fp );
+               $this->assertSame( null, $stream->getSize() );
+       }
+
+       public function testSeekTell() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+
+               $stream->seek( 2 );
+               $this->assertSame( 2, $stream->tell() );
+               $stream->seek( 2, SEEK_CUR );
+               $this->assertSame( 4, $stream->tell() );
+               $stream->seek( -5, SEEK_END );
+               $this->assertSame( 1, $stream->tell() );
+               $stream->read( 2 );
+               $this->assertSame( 3, $stream->tell() );
+
+               $stream->close();
+               try {
+                       $stream->seek( 0 );
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+               }
+               try {
+                       $stream->tell();
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+               }
+       }
+
+       public function testEof() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+
+               $this->assertFalse( $stream->eof() );
+               $stream->getContents();
+               $this->assertTrue( $stream->eof() );
+               $stream->seek( -1, SEEK_END );
+               $this->assertFalse( $stream->eof() );
+
+               // No error if closed
+               $stream = new UploadedFileStream( $filename );
+               $stream->close();
+               $this->assertTrue( $stream->eof() );
+
+               // No error even if the fd goes bad
+               $stream = new UploadedFileStream( $filename );
+               fclose( TestingAccessWrapper::newFromObject( $stream )->fp );
+               $this->assertInternalType( 'boolean', $stream->eof() );
+       }
+
+       public function testIsFuncs() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+               $this->assertTrue( $stream->isSeekable() );
+               $this->assertTrue( $stream->isReadable() );
+               $this->assertFalse( $stream->isWritable() );
+
+               $stream->close();
+               $this->assertFalse( $stream->isSeekable() );
+               $this->assertFalse( $stream->isReadable() );
+               $this->assertFalse( $stream->isWritable() );
+       }
+
+       public function testRewind() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+
+               $stream->seek( 2 );
+               $this->assertSame( 2, $stream->tell() );
+               $stream->rewind();
+               $this->assertSame( 0, $stream->tell() );
+
+               $stream->close();
+               try {
+                       $stream->rewind();
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+               }
+       }
+
+       public function testWrite() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+
+               try {
+                       $stream->write( 'foo' );
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+               }
+       }
+
+       public function testRead() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+
+               $this->assertSame( 'foo', $stream->read( 3 ) );
+               $this->assertSame( 'bar', $stream->read( 10 ) );
+               $this->assertSame( '', $stream->read( 10 ) );
+               $stream->rewind();
+               $this->assertSame( 'foobar', $stream->read( 10 ) );
+
+               $stream->close();
+               try {
+                       $stream->read( 1 );
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+               }
+       }
+
+       public function testGetContents() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $stream = new UploadedFileStream( $filename );
+
+               $this->assertSame( 'foobar', $stream->getContents() );
+               $this->assertSame( '', $stream->getContents() );
+               $stream->seek( 3 );
+               $this->assertSame( 'bar', $stream->getContents() );
+
+               $stream->close();
+               try {
+                       $stream->getContents();
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+               }
+       }
+
+       public function testGetMetadata() {
+               // Whatever
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $fp = fopen( $filename, 'r' );
+               $expect = stream_get_meta_data( $fp );
+               fclose( $fp );
+
+               $stream = new UploadedFileStream( $filename );
+               $this->assertSame( $expect, $stream->getMetadata() );
+               foreach ( $expect as $k => $v ) {
+                       $this->assertSame( $v, $stream->getMetadata( $k ) );
+               }
+               $this->assertNull( $stream->getMetadata( 'bogus' ) );
+
+               $stream->close();
+               try {
+                       $stream->getMetadata();
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+               }
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php
new file mode 100644 (file)
index 0000000..80a74e7
--- /dev/null
@@ -0,0 +1,215 @@
+<?php
+
+namespace Wikimedia\ParamValidator\Util;
+
+require_once __DIR__ . '/UploadedFileTestBase.php';
+
+use Psr\Http\Message\StreamInterface;
+use RuntimeException;
+
+/**
+ * @covers Wikimedia\ParamValidator\Util\UploadedFile
+ */
+class UploadedFileTest extends UploadedFileTestBase {
+
+       public function testGetStream() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+
+               $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false );
+
+               // getStream() fails for non-OK uploads
+               foreach ( [
+                       UPLOAD_ERR_INI_SIZE,
+                       UPLOAD_ERR_FORM_SIZE,
+                       UPLOAD_ERR_PARTIAL,
+                       UPLOAD_ERR_NO_FILE,
+                       UPLOAD_ERR_NO_TMP_DIR,
+                       UPLOAD_ERR_CANT_WRITE,
+                       UPLOAD_ERR_EXTENSION,
+                       -42
+               ] as $code ) {
+                       $file2 = new UploadedFile( [ 'error' => $code, 'tmp_name' => $filename ], false );
+                       try {
+                               $file2->getStream();
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                               throw $ex;
+                       } catch ( RuntimeException $ex ) {
+                       }
+               }
+
+               // getStream() works
+               $stream = $file->getStream();
+               $this->assertInstanceOf( StreamInterface::class, $stream );
+               $stream->seek( 0 );
+               $this->assertSame( 'foobar', $stream->getContents() );
+
+               // Second call also works
+               $this->assertInstanceOf( StreamInterface::class, $file->getStream() );
+
+               // getStream() throws after move, and the stream is invalidated too
+               $file->moveTo( $filename . '.xxx' );
+               try {
+                       try {
+                               $file->getStream();
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                               throw $ex;
+                       } catch ( RuntimeException $ex ) {
+                               $this->assertSame( 'File has already been moved', $ex->getMessage() );
+                       }
+                       try {
+                               $stream->seek( 0 );
+                               $stream->getContents();
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                               throw $ex;
+                       } catch ( RuntimeException $ex ) {
+                       }
+               } finally {
+                       unlink( $filename . '.xxx' ); // Clean up
+               }
+
+               // getStream() fails if the file is missing
+               $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], true );
+               try {
+                       $file->getStream();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+                       $this->assertSame( 'Uploaded file is missing', $ex->getMessage() );
+               }
+       }
+
+       public function testMoveTo() {
+               // Successful move
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $this->assertFileExists( $filename, 'sanity check' );
+               $this->assertFileNotExists( "$filename.xxx", 'sanity check' );
+               $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false );
+               $file->moveTo( $filename . '.xxx' );
+               $this->assertFileNotExists( $filename );
+               $this->assertFileExists( "$filename.xxx" );
+
+               // Fails on a second move attempt
+               $this->assertFileNotExists( "$filename.yyy", 'sanity check' );
+               try {
+                       $file->moveTo( $filename . '.yyy' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+                       $this->assertSame( 'File has already been moved', $ex->getMessage() );
+               }
+               $this->assertFileNotExists( $filename );
+               $this->assertFileExists( "$filename.xxx" );
+               $this->assertFileNotExists( "$filename.yyy" );
+
+               // Fails if the file is missing
+               $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => "$filename.aaa" ], false );
+               $this->assertFileNotExists( "$filename.aaa", 'sanity check' );
+               $this->assertFileNotExists( "$filename.bbb", 'sanity check' );
+               try {
+                       $file->moveTo( $filename . '.bbb' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+                       $this->assertSame( 'Uploaded file is missing', $ex->getMessage() );
+               }
+               $this->assertFileNotExists( "$filename.aaa" );
+               $this->assertFileNotExists( "$filename.bbb" );
+
+               // Fails for non-upload file (when not flagged to ignore that)
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $this->assertFileExists( $filename, 'sanity check' );
+               $this->assertFileNotExists( "$filename.xxx", 'sanity check' );
+               $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ] );
+               try {
+                       $file->moveTo( $filename . '.xxx' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+                       $this->assertSame( 'Specified file is not an uploaded file', $ex->getMessage() );
+               }
+               $this->assertFileExists( $filename );
+               $this->assertFileNotExists( "$filename.xxx" );
+
+               // Fails for error uploads
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $this->assertFileExists( $filename, 'sanity check' );
+               $this->assertFileNotExists( "$filename.xxx", 'sanity check' );
+               foreach ( [
+                       UPLOAD_ERR_INI_SIZE,
+                       UPLOAD_ERR_FORM_SIZE,
+                       UPLOAD_ERR_PARTIAL,
+                       UPLOAD_ERR_NO_FILE,
+                       UPLOAD_ERR_NO_TMP_DIR,
+                       UPLOAD_ERR_CANT_WRITE,
+                       UPLOAD_ERR_EXTENSION,
+                       -42
+               ] as $code ) {
+                       $file = new UploadedFile( [ 'error' => $code, 'tmp_name' => $filename ], false );
+                       try {
+                               $file->moveTo( $filename . '.xxx' );
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                               throw $ex;
+                       } catch ( RuntimeException $ex ) {
+                       }
+                       $this->assertFileExists( $filename );
+                       $this->assertFileNotExists( "$filename.xxx" );
+               }
+
+               // Move failure triggers exception
+               $filename = $this->makeTemp( __FUNCTION__, 'file1' );
+               $filename2 = $this->makeTemp( __FUNCTION__, 'file2' );
+               $this->assertFileExists( $filename, 'sanity check' );
+               $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false );
+               try {
+                       $file->moveTo( $filename2 . DIRECTORY_SEPARATOR . 'foobar' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) {
+                       throw $ex;
+               } catch ( RuntimeException $ex ) {
+               }
+               $this->assertFileExists( $filename );
+       }
+
+       public function testInfoMethods() {
+               $filename = $this->makeTemp( __FUNCTION__ );
+               $file = new UploadedFile( [
+                       'name' => 'C:\\example.txt',
+                       'type' => 'text/plain',
+                       'size' => 1025,
+                       'error' => UPLOAD_ERR_OK,
+                       'tmp_name' => $filename,
+               ], false );
+               $this->assertSame( 1025, $file->getSize() );
+               $this->assertSame( UPLOAD_ERR_OK, $file->getError() );
+               $this->assertSame( 'C:\\example.txt', $file->getClientFilename() );
+               $this->assertSame( 'text/plain', $file->getClientMediaType() );
+
+               // None of these are allowed to error
+               $file = new UploadedFile( [], false );
+               $this->assertSame( null, $file->getSize() );
+               $this->assertSame( UPLOAD_ERR_NO_FILE, $file->getError() );
+               $this->assertSame( null, $file->getClientFilename() );
+               $this->assertSame( null, $file->getClientMediaType() );
+
+               // "if none was provided" behavior, given that $_FILES often contains
+               // the empty string.
+               $file = new UploadedFile( [
+                       'name' => '',
+                       'type' => '',
+                       'size' => 100,
+                       'error' => UPLOAD_ERR_NO_FILE,
+                       'tmp_name' => $filename,
+               ], false );
+               $this->assertSame( null, $file->getClientFilename() );
+               $this->assertSame( null, $file->getClientMediaType() );
+       }
+
+}
diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php
new file mode 100644 (file)
index 0000000..6e1bd6a
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+namespace Wikimedia\ParamValidator\Util;
+
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use Wikimedia\AtEase\AtEase;
+
+class UploadedFileTestBase extends \PHPUnit\Framework\TestCase {
+
+       /** @var string|null */
+       protected static $tmpdir;
+
+       public static function setUpBeforeClass() {
+               parent::setUpBeforeClass();
+
+               // Create a temporary directory for this test's files.
+               self::$tmpdir = null;
+               $base = sys_get_temp_dir() . DIRECTORY_SEPARATOR .
+                       'phpunit-ParamValidator-UploadedFileTest-' . time() . '-' . getmypid() . '-';
+               for ( $i = 0; $i < 10000; $i++ ) {
+                       $dir = $base . sprintf( '%04d', $i );
+                       if ( AtEase::quietCall( 'mkdir', $dir, 0700, false ) === true ) {
+                               self::$tmpdir = $dir;
+                               break;
+                       }
+               }
+               if ( self::$tmpdir === null ) {
+                       self::fail( "Could not create temporary directory '{$base}XXXX'" );
+               }
+       }
+
+       public static function tearDownAfterClass() {
+               parent::tearDownAfterClass();
+
+               // Clean up temporary directory.
+               if ( self::$tmpdir !== null ) {
+                       $iter = new RecursiveIteratorIterator(
+                               new RecursiveDirectoryIterator( self::$tmpdir, RecursiveDirectoryIterator::SKIP_DOTS ),
+                               RecursiveIteratorIterator::CHILD_FIRST
+                       );
+                       foreach ( $iter as $file ) {
+                               if ( $file->isDir() ) {
+                                       rmdir( $file->getRealPath() );
+                               } else {
+                                       unlink( $file->getRealPath() );
+                               }
+                       }
+                       rmdir( self::$tmpdir );
+                       self::$tmpdir = null;
+               }
+       }
+
+       protected static function assertTmpdir() {
+               if ( self::$tmpdir === null || !is_dir( self::$tmpdir ) ) {
+                       self::fail( 'No temporary directory for ' . static::class );
+               }
+       }
+
+       /**
+        * @param string $prefix For tempnam()
+        * @param string $content Contents of the file
+        * @return string Filename
+        */
+       protected function makeTemp( $prefix, $content = 'foobar' ) {
+               self::assertTmpdir();
+
+               $filename = tempnam( self::$tmpdir, $prefix );
+               if ( $filename === false ) {
+                       self::fail( 'Failed to create temporary file' );
+               }
+
+               self::assertSame(
+                       strlen( $content ),
+                       file_put_contents( $filename, $content ),
+                       'Writing test temporary file'
+               );
+
+               return $filename;
+       }
+
+}