'MediaWiki\\Sparql\\' => __DIR__ . '/sparql/',
'MediaWiki\\Storage\\' => __DIR__ . '/Storage/',
'MediaWiki\\Tidy\\' => __DIR__ . '/tidy/',
+ 'Wikimedia\\ParamValidator\\' => __DIR__ . '/libs/ParamValidator/',
'Wikimedia\\Services\\' => __DIR__ . '/libs/services/',
];
}
--- /dev/null
+<?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 );
+
+}
--- /dev/null
+<?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 );
+ }
+
+}
--- /dev/null
+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
--- /dev/null
+<?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'] );
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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];
+ }
+
+}
--- /dev/null
+<?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 );
+ }
+
+}
--- /dev/null
+<?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 ) );
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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 );
+ }
+
+}
--- /dev/null
+<?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 );
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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 );
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
# 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",
--- /dev/null
+<?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' ] ],
+ ],
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?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() );
+ }
+
+}
--- /dev/null
+<?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' ],
+ ];
+ }
+
+}
--- /dev/null
+<?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" ],
+ ];
+ }
+
+}
--- /dev/null
+<?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' ],
+ ];
+ }
+
+}
--- /dev/null
+<?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 ] ],
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?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 ],
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?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 ] ],
+ ];
+ }
+
+}
--- /dev/null
+<?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' ], [], [] ],
+ ];
+ }
+
+}
--- /dev/null
+<?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,
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?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 ] ],
+ ];
+ }
+
+}
--- /dev/null
+<?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] ?? [],
+ ];
+ }
+ }
+
+}
--- /dev/null
+<?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 ],
+ ];
+ }
+
+}
--- /dev/null
+<?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 ]
+ )
+ );
+ }
+
+}
--- /dev/null
+<?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 ) {
+ }
+ }
+
+}
--- /dev/null
+<?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() );
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}