diff --git a/CHANGELOG.md b/CHANGELOG.md index 7655772c9..612f323d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Enh #787: Explicitly import classes, functions, and constants in "use" section (@mspirkov) - Bug #793: Fix translations, broken link in contributing guide, incorrect imports and grammar in documentation (@evilkarter) - Chg #795: Update Polish translations (@rbrzezinski) +- New #591: Add `File` validator (@samdark) - New #798: Add `SplFileInfo` value support to `Image` validator (@samdark) - Bug #798: Fix `Image` validator when unable to read from stream (@samdark) diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index bfa47aa65..b311d2a94 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -14,6 +14,7 @@ - [Compare](built-in-rules-compare.md) - [Composite](built-in-rules-composite.md) - [Each](built-in-rules-each.md) + - [File](built-in-rules-file.md) - [Nested](built-in-rules-nested.md) - [Required](built-in-rules-required.md) - [StopOnError](built-in-rules-stop-on-error.md) diff --git a/docs/guide/en/built-in-rules-file.md b/docs/guide/en/built-in-rules-file.md new file mode 100644 index 000000000..a8931b9aa --- /dev/null +++ b/docs/guide/en/built-in-rules-file.md @@ -0,0 +1,91 @@ +# File + +`File` checks that a value is a file and can validate its extension, MIME type, and size. + +Supported values: + +- string file paths; +- `SplFileInfo` instances; +- [PSR-7] `UploadedFileInterface` instances. + +Use `Each` for multiple files: + +```php +use Yiisoft\Validator\Rule\Each; +use Yiisoft\Validator\Rule\File; + +$rules = [ + 'attachments' => new Each(new File(extensions: ['pdf', 'txt'])), +]; +``` + +For example, with a PSR-7 request: + +```php +use Yiisoft\Validator\Rule\Each; +use Yiisoft\Validator\Rule\File; +use Yiisoft\Validator\Validator; + +$uploadedFiles = $request->getUploadedFiles()['attachments'] ?? []; + +$result = (new Validator())->validate( + ['attachments' => $uploadedFiles], + ['attachments' => new Each(new File(maxSize: 5_000_000))], +); +``` + +If your application works with native PHP `$_FILES` data directly, convert it to supported values such as PSR-7 uploaded +file objects before passing it to the validator. + +## Uploaded Files + +`File` handles PSR-7 upload error codes. `UPLOAD_ERR_NO_FILE` is treated as a missing value, so optional upload fields +can use `skipOnEmpty`: + +```php +use Yiisoft\Validator\Rule\File; + +$rule = new File(skipOnEmpty: true); +``` + +Other upload error codes fail validation with `uploadFailedMessage`. + +The rule does not prove that arbitrary string paths or `SplFileInfo` values came from PHP's HTTP upload mechanism. Do +not pass user-submitted paths directly. Use PSR-7 uploaded file objects from a trusted request implementation, or perform +[upload provenance checks] before validating filesystem paths. + +## MIME Types + +For filesystem-backed files, MIME type validation uses PHP's file information facilities through +`mime_content_type()`. If the MIME type can't be determined, MIME validation fails. + +For pathless PSR-7 uploads backed only by an in-memory stream, `File` doesn't trust client-provided media type by +default. If your application has already decided that the client metadata is acceptable for this field, enable it +explicitly: + +```php +use Yiisoft\Validator\Rule\File; + +$rule = new File( + mimeTypes: ['text/plain'], + trustClientMediaType: true, +); +``` + +This option should be used with care because the client can send any media type value. + +## Size + +For filesystem-backed uploads, size checks use the actual file size on disk. For pathless streams, size checks use the +PSR-7 upload size when available. If a size constraint is configured and the size can't be determined, validation fails. + +`size` is mutually exclusive with `minSize` and `maxSize`. When both `minSize` and `maxSize` are set, `minSize` must be +less than or equal to `maxSize`. + +## Request Body Streams + +`File` doesn't validate generic request body streams such as data read from `php://input` for PUT requests. Convert such +input to a supported value first, or write a custom rule that validates your stream format and storage flow. + +[PSR-7]: https://www.php-fig.org/psr/psr-7/ +[upload provenance checks]: https://www.php.net/manual/en/features.file-upload.post-method.php diff --git a/docs/guide/en/built-in-rules.md b/docs/guide/en/built-in-rules.md index d55d8e424..143cfa314 100644 --- a/docs/guide/en/built-in-rules.md +++ b/docs/guide/en/built-in-rules.md @@ -57,6 +57,7 @@ Here is a list of all available built-in rules, divided by category. ### File rules +- [File](../../../src/Rule/File.php) - [Image](../../../src/Rule/Image/Image.php) ### Date rules @@ -86,6 +87,7 @@ Some rules also have guides in addition to PHPDoc: - [Compare](built-in-rules-compare.md) - [Composite](built-in-rules-composite.md) - [Each](built-in-rules-each.md) +- [File](built-in-rules-file.md) - [Nested](built-in-rules-nested.md) - [Required](built-in-rules-required.md) - [StopOnError](built-in-rules-stop-on-error.md) diff --git a/src/Rule/File.php b/src/Rule/File.php new file mode 100644 index 000000000..158e20bcd --- /dev/null +++ b/src/Rule/File.php @@ -0,0 +1,492 @@ +|null + */ + private readonly ?array $extensions; + + /** + * Allowed MIME types. + * + * @var string[]|null + * @psalm-var list|null + */ + private readonly ?array $mimeTypes; + + /** + * @param array|string|null $extensions Allowed file extensions without a leading dot. Values are case-insensitive + * and may be provided either as an array or as a comma / space separated string. Files without extension will not + * pass validation if it is configured. + * @param array|string|null $mimeTypes Allowed MIME types. Values are case-insensitive and may be provided either + * as an array or as a comma / space separated string. Wildcards like `image/*` are supported. For in-memory + * stream uploads without a real file path, MIME validation fails unless {@see $trustClientMediaType} is enabled. + * If `mime_content_type()` is unavailable, MIME checks for filesystem-backed files will fail validation. + * @param int|null $size Expected exact size of the validated file in bytes. Validation fails if size cannot be + * determined. + * @param int|null $minSize Expected minimum size of the validated file in bytes. Validation fails if size cannot + * be determined. + * @param int|null $maxSize Expected maximum size of the validated file in bytes. Validation fails if size cannot + * be determined. + * @param bool $trustClientMediaType Whether to use client-provided media type for MIME validation when the + * uploaded file has no real filesystem path. Client-provided media type is not trusted by default. + * @param string $message A message used when the validated value is not a valid file. + * + * You may use the following placeholders in the message: + * + * - `{property}`: the translated label of the property being validated. + * - `{Property}`: the translated label of the property being validated, capitalized. + * - `{file}`: the validated file name when it is available. + * @param string $uploadFailedMessage A message used when uploaded file contains an upload error. + * + * You may use the following placeholders in the message: + * + * - `{property}`: the translated label of the property being validated. + * - `{Property}`: the translated label of the property being validated, capitalized. + * - `{file}`: the validated file name when it is available. + * - `{error}`: the upload error code. + * @param string $uploadRequiredMessage A message used when no file was provided. + * + * You may use the following placeholders in the message: + * + * - `{property}`: the translated label of the property being validated. + * - `{Property}`: the translated label of the property being validated, capitalized. + * @param string $wrongExtensionMessage A message used when the file extension is not allowed. + * + * You may use the following placeholders in the message: + * + * - `{property}`: the translated label of the property being validated. + * - `{Property}`: the translated label of the property being validated, capitalized. + * - `{file}`: the validated file name when it is available. + * - `{extensions}`: the list of allowed extensions. + * @param string $wrongMimeTypeMessage A message used when the file MIME type is not allowed. + * + * You may use the following placeholders in the message: + * + * - `{property}`: the translated label of the property being validated. + * - `{Property}`: the translated label of the property being validated, capitalized. + * - `{file}`: the validated file name when it is available. + * - `{mimeTypes}`: the list of allowed MIME types. + * @param string $notExactSizeMessage A message used when the file size doesn't exactly equal {@see $size}. + * + * You may use the following placeholders in the message: + * + * - `{property}`: the translated label of the property being validated. + * - `{Property}`: the translated label of the property being validated, capitalized. + * - `{file}`: the validated file name when it is available. + * - `{exactly}`: expected exact size in bytes. + * @param string $tooSmallMessage A message used when the file size is less than {@see $minSize}. + * + * You may use the following placeholders in the message: + * + * - `{property}`: the translated label of the property being validated. + * - `{Property}`: the translated label of the property being validated, capitalized. + * - `{file}`: the validated file name when it is available. + * - `{limit}`: expected minimum size in bytes. + * @param string $tooBigMessage A message used when the file size is greater than {@see $maxSize}. + * + * You may use the following placeholders in the message: + * + * - `{property}`: the translated label of the property being validated. + * - `{Property}`: the translated label of the property being validated, capitalized. + * - `{file}`: the validated file name when it is available. + * - `{limit}`: expected maximum size in bytes. + * @param string $unableToDetermineSizeMessage A message used when file size constraints are configured, but the + * file size can't be determined. + * + * You may use the following placeholders in the message: + * + * - `{property}`: the translated label of the property being validated. + * - `{Property}`: the translated label of the property being validated, capitalized. + * - `{file}`: the validated file name when it is available. + * @param bool|callable|null $skipOnEmpty Whether to skip this rule if the validated value is empty. + * See {@see SkipOnEmptyInterface}. + * @param bool $skipOnError Whether to skip this rule if any of the previous rules gave an error. + * See {@see SkipOnErrorInterface}. + * @param Closure|null $when A callable to define a condition for applying the rule. See {@see WhenInterface}. + * + * @psalm-param list|string|null $extensions + * @psalm-param list|string|null $mimeTypes + * @psalm-param SkipOnEmptyValue $skipOnEmpty + * @psalm-param WhenType $when + */ + public function __construct( + array|string|null $extensions = null, + array|string|null $mimeTypes = null, + private ?int $size = null, + private ?int $minSize = null, + private ?int $maxSize = null, + private bool $trustClientMediaType = false, + private string $message = '{Property} must be a file.', + private string $uploadFailedMessage = 'Failed to upload {property}. Error code: {error, number}.', + private string $uploadRequiredMessage = 'Please upload a file.', + private string $wrongExtensionMessage = 'Only files with these extensions are allowed: {extensions}.', + private string $wrongMimeTypeMessage = 'Only files with these MIME types are allowed: {mimeTypes}.', + private string $notExactSizeMessage = 'The size of {property} must be exactly {exactly, number} {exactly, plural, one{byte} other{bytes}}.', + private string $tooSmallMessage = 'The size of {property} cannot be smaller than {limit, number} {limit, plural, one{byte} other{bytes}}.', + private string $tooBigMessage = 'The size of {property} cannot be larger than {limit, number} {limit, plural, one{byte} other{bytes}}.', + private string $unableToDetermineSizeMessage = 'The size of {property} cannot be determined.', + bool|callable|null $skipOnEmpty = null, + private bool $skipOnError = false, + private ?Closure $when = null, + ) { + if ($this->size !== null && ($this->minSize !== null || $this->maxSize !== null)) { + throw new InvalidArgumentException('Exact size and min / max size can\'t be specified together.'); + } + + foreach (['size' => $this->size, 'minSize' => $this->minSize, 'maxSize' => $this->maxSize] as $name => $value) { + if ($value !== null && $value < 0) { + throw new InvalidArgumentException(ucfirst($name) . ' must be greater than or equal to 0.'); + } + } + + if ($this->minSize !== null && $this->maxSize !== null && $this->minSize > $this->maxSize) { + throw new InvalidArgumentException('Min size must be less than or equal to max size.'); + } + + $this->extensions = $this->normalizeList($extensions); + $this->mimeTypes = $this->normalizeList($mimeTypes); + $this->skipOnEmpty = $skipOnEmpty; + } + + public function getName(): string + { + return 'file'; + } + + /** + * @psalm-return SkipOnEmptyValue + */ + public function getSkipOnEmpty(): bool|callable|null + { + if ($this->skipOnEmpty === true || $this->skipOnEmpty instanceof WhenEmpty) { + $emptyCondition = $this->skipOnEmpty === true ? new WhenEmpty() : $this->skipOnEmpty; + return static fn(mixed $value, bool $isPropertyMissing): bool => self::isUploadMissing($value) + || $emptyCondition($value, $isPropertyMissing); + } + + return $this->skipOnEmpty; + } + + /** + * Get allowed file extensions. + * + * @return list|null + * + * @see $extensions + */ + public function getExtensions(): ?array + { + return $this->extensions; + } + + /** + * Get allowed file MIME types. + * + * @return list|null + * + * @see $mimeTypes + */ + public function getMimeTypes(): ?array + { + return $this->mimeTypes; + } + + /** + * Whether to use client-provided media type for MIME validation when a validated upload has no real filesystem path. + */ + public function getTrustClientMediaType(): bool + { + return $this->trustClientMediaType; + } + + /** + * Get expected exact file size in bytes. + * + * @return int|null Expected exact file size in bytes. + * + * @see $size + */ + public function getSize(): ?int + { + return $this->size; + } + + /** + * Get expected minimum file size in bytes. + * + * @return int|null Expected minimum file size in bytes. + * + * @see $minSize + */ + public function getMinSize(): ?int + { + return $this->minSize; + } + + /** + * Get expected maximum file size in bytes. + * + * @return int|null Expected maximum file size in bytes. + * + * @see $maxSize + */ + public function getMaxSize(): ?int + { + return $this->maxSize; + } + + /** + * Get error message used when the validated value is not a file. + * + * @return string Error message. + * + * @see $message + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Get error message used when an uploaded file contains an upload error. + * + * @return string Error message. + * + * @see $uploadFailedMessage + */ + public function getUploadFailedMessage(): string + { + return $this->uploadFailedMessage; + } + + /** + * Get error message used when no file was provided. + * + * @return string Error message. + * + * @see $uploadRequiredMessage + */ + public function getUploadRequiredMessage(): string + { + return $this->uploadRequiredMessage; + } + + /** + * Get error message used when the file extension is not allowed. + * + * @return string Error message. + * + * @see $wrongExtensionMessage + */ + public function getWrongExtensionMessage(): string + { + return $this->wrongExtensionMessage; + } + + /** + * Get error message used when the file MIME type is not allowed. + * + * @return string Error message. + * + * @see $wrongMimeTypeMessage + */ + public function getWrongMimeTypeMessage(): string + { + return $this->wrongMimeTypeMessage; + } + + /** + * Get error message used when the file size doesn't exactly equal {@see $size}. + * + * @return string Error message. + * + * @see $notExactSizeMessage + */ + public function getNotExactSizeMessage(): string + { + return $this->notExactSizeMessage; + } + + /** + * Get error message used when the file size is less than {@see $minSize}. + * + * @return string Error message. + * + * @see $tooSmallMessage + */ + public function getTooSmallMessage(): string + { + return $this->tooSmallMessage; + } + + /** + * Get error message used when the file size is greater than {@see $maxSize}. + * + * @return string Error message. + * + * @see $tooBigMessage + */ + public function getTooBigMessage(): string + { + return $this->tooBigMessage; + } + + /** + * Get error message used when the file size cannot be determined for configured size constraints. + * + * @return string Error message. + * + * @see $unableToDetermineSizeMessage + */ + public function getUnableToDetermineSizeMessage(): string + { + return $this->unableToDetermineSizeMessage; + } + + public function getHandler(): string + { + return FileHandler::class; + } + + public function getOptions(): array + { + return [ + 'extensions' => $this->extensions, + 'mimeTypes' => $this->mimeTypes, + 'trustClientMediaType' => $this->trustClientMediaType, + 'size' => $this->size, + 'minSize' => $this->minSize, + 'maxSize' => $this->maxSize, + 'message' => [ + 'template' => $this->message, + 'parameters' => [], + ], + 'uploadFailedMessage' => [ + 'template' => $this->uploadFailedMessage, + 'parameters' => [], + ], + 'uploadRequiredMessage' => [ + 'template' => $this->uploadRequiredMessage, + 'parameters' => [], + ], + 'wrongExtensionMessage' => [ + 'template' => $this->wrongExtensionMessage, + 'parameters' => [], + ], + 'wrongMimeTypeMessage' => [ + 'template' => $this->wrongMimeTypeMessage, + 'parameters' => [], + ], + 'notExactSizeMessage' => [ + 'template' => $this->notExactSizeMessage, + 'parameters' => [], + ], + 'tooSmallMessage' => [ + 'template' => $this->tooSmallMessage, + 'parameters' => [], + ], + 'tooBigMessage' => [ + 'template' => $this->tooBigMessage, + 'parameters' => [], + ], + 'unableToDetermineSizeMessage' => [ + 'template' => $this->unableToDetermineSizeMessage, + 'parameters' => [], + ], + 'skipOnEmpty' => $this->getSkipOnEmptyOption(), + 'skipOnError' => $this->skipOnError, + ]; + } + + /** + * @psalm-param list|string|null $value + * + * @return string[]|null + * @psalm-return list|null + */ + private function normalizeList(array|string|null $value): ?array + { + if ($value === null) { + return null; + } + + if (is_string($value)) { + $items = preg_split('/[\s,]+/', $value, -1, PREG_SPLIT_NO_EMPTY); + $value = $items === false ? [] : $items; + } + + $value = array_values( + array_unique( + array_map( + static fn(string $item): string => strtolower(trim($item)), + $value, + ), + ), + ); + + if ($value === []) { + throw new InvalidArgumentException('List of allowed values cannot be empty.'); + } + + return $value; + } + + private static function isUploadMissing(mixed $value): bool + { + return $value instanceof UploadedFileInterface && $value->getError() === UPLOAD_ERR_NO_FILE; + } +} diff --git a/src/Rule/FileHandler.php b/src/Rule/FileHandler.php new file mode 100644 index 000000000..77d354926 --- /dev/null +++ b/src/Rule/FileHandler.php @@ -0,0 +1,377 @@ +getFileData($value); + + if ($file['status'] === 'missing') { + $result->addError($rule->getUploadRequiredMessage(), $this->getParameters($context)); + return $result; + } + + if ($file['status'] === 'upload-error') { + $result->addError( + $rule->getUploadFailedMessage(), + $this->getParameters($context, $file, ['error' => $file['error']]), + ); + return $result; + } + + if ($file['status'] !== 'ok') { + $result->addError($rule->getMessage(), $this->getParameters($context, $file)); + return $result; + } + + if (!$this->isExtensionValid($file['name'], $rule->getExtensions())) { + $result->addError( + $rule->getWrongExtensionMessage(), + $this->getParameters($context, $file, ['extensions' => implode(', ', $rule->getExtensions() ?? [])]), + ); + } + + if (!$this->isMimeTypeValid($file, $rule->getMimeTypes(), $rule->getTrustClientMediaType())) { + $result->addError( + $rule->getWrongMimeTypeMessage(), + $this->getParameters($context, $file, ['mimeTypes' => implode(', ', $rule->getMimeTypes() ?? [])]), + ); + } + + $this->validateSize($file, $rule, $context, $result); + + return $result; + } + + /** + * @psalm-return FileData + */ + private function getFileData(mixed $value): array + { + if ($value instanceof UploadedFileInterface) { + $error = $value->getError(); + $name = $this->normalizeFileName($value->getClientFilename()); + + if ($error === UPLOAD_ERR_NO_FILE) { + return [ + 'status' => 'missing', + 'name' => $name, + 'size' => null, + 'path' => null, + 'error' => $error, + 'clientMediaType' => null, + ]; + } + + if ($error !== UPLOAD_ERR_OK) { + return [ + 'status' => 'upload-error', + 'name' => $name, + 'size' => null, + 'path' => null, + 'error' => $error, + 'clientMediaType' => null, + ]; + } + + try { + $path = $this->getUploadedFilePath($value); + } catch (RuntimeException) { + return [ + 'status' => 'invalid', + 'name' => $name, + 'size' => null, + 'path' => null, + 'error' => null, + 'clientMediaType' => $value->getClientMediaType(), + ]; + } + + $name = $name !== '' ? $name : $this->normalizeFileName($path); + $isFile = $path === null || is_file($path); + if ($path !== null && $isFile) { + $fileInfoSize = (new SplFileInfo($path))->getSize(); + $size = is_int($fileInfoSize) ? $fileInfoSize : null; + } else { + $size = $value->getSize(); + } + + return [ + 'status' => $isFile ? 'ok' : 'invalid', + 'name' => $name, + 'size' => $size, + 'path' => $path, + 'error' => null, + 'clientMediaType' => $value->getClientMediaType(), + ]; + } + + if ($value instanceof SplFileInfo) { + $isFile = $value->isFile(); + $size = null; + + if ($isFile) { + $fileInfoSize = $value->getSize(); + $size = is_int($fileInfoSize) ? $fileInfoSize : null; + } + + return [ + 'status' => $isFile ? 'ok' : 'invalid', + 'name' => $this->normalizeFileName($value->getFilename()), + 'size' => $size, + 'path' => $isFile ? $value->getPathname() : null, + 'error' => null, + 'clientMediaType' => null, + ]; + } + + if ($value === null || $value === '') { + return [ + 'status' => 'missing', + 'name' => '', + 'size' => null, + 'path' => null, + 'error' => null, + 'clientMediaType' => null, + ]; + } + + if (is_string($value)) { + $isFile = is_file($value); + $size = null; + + if ($isFile) { + $fileInfoSize = (new SplFileInfo($value))->getSize(); + $size = is_int($fileInfoSize) ? $fileInfoSize : null; + } + + return [ + 'status' => $isFile ? 'ok' : 'invalid', + 'name' => $this->normalizeFileName($value), + 'size' => $size, + 'path' => $isFile ? $value : null, + 'error' => null, + 'clientMediaType' => null, + ]; + } + + return [ + 'status' => 'invalid', + 'name' => '', + 'size' => null, + 'path' => null, + 'error' => null, + 'clientMediaType' => null, + ]; + } + + /** + * @psalm-param list|null $extensions + */ + private function isExtensionValid(string $fileName, ?array $extensions): bool + { + if ($extensions === null) { + return true; + } + + $extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); + return $extension !== '' && in_array($extension, $extensions, true); + } + + /** + * @psalm-param FileData $file + * @psalm-param list|null $mimeTypes + */ + private function isMimeTypeValid(array $file, ?array $mimeTypes, bool $trustClientMediaType): bool + { + if ($mimeTypes === null) { + return true; + } + + $mimeType = $this->detectMimeType($file, $trustClientMediaType); + if ($mimeType === null) { + return false; + } + $mimeType = strtolower($mimeType); + + foreach ($mimeTypes as $allowedMimeType) { + if ($allowedMimeType === $mimeType) { + return true; + } + + if (str_ends_with($allowedMimeType, '/*') && str_starts_with($mimeType, substr($allowedMimeType, 0, -1))) { + return true; + } + } + + return false; + } + + /** + * @psalm-param FileData $file + */ + private function validateSize(array $file, File $rule, ValidationContext $context, Result $result): void + { + $size = $file['size']; + if ($size === null) { + if ($rule->getSize() !== null || $rule->getMinSize() !== null || $rule->getMaxSize() !== null) { + $result->addError( + $rule->getUnableToDetermineSizeMessage(), + $this->getParameters($context, $file), + ); + } + + return; + } + + if ($rule->getSize() !== null && $size !== $rule->getSize()) { + $result->addError( + $rule->getNotExactSizeMessage(), + $this->getParameters($context, $file, ['exactly' => $rule->getSize()]), + ); + } + + if ($rule->getMinSize() !== null && $size < $rule->getMinSize()) { + $result->addError( + $rule->getTooSmallMessage(), + $this->getParameters($context, $file, ['limit' => $rule->getMinSize()]), + ); + } + + if ($rule->getMaxSize() !== null && $size > $rule->getMaxSize()) { + $result->addError( + $rule->getTooBigMessage(), + $this->getParameters($context, $file, ['limit' => $rule->getMaxSize()]), + ); + } + } + + /** + * @psalm-param FileData $file + */ + private function detectMimeType(array $file, bool $trustClientMediaType): ?string + { + if ($file['path'] !== null && is_file($file['path'])) { + if (!is_readable($file['path'])) { + return null; + } + + if (!function_exists('mime_content_type')) { + return null; + } + + $mimeType = $this->detectMimeTypeFromPath($file['path']); + return $mimeType === false ? null : $mimeType; + } + + return $trustClientMediaType ? $file['clientMediaType'] : null; + } + + private function detectMimeTypeFromPath(string $path): string|false + { + set_error_handler(static fn(int $severity, string $message): bool => true); + + try { + return mime_content_type($path); + } finally { + restore_error_handler(); + } + } + + private function getUploadedFilePath(UploadedFileInterface $value): ?string + { + $uri = $value->getStream()->getMetadata('uri'); + if (!is_string($uri)) { + return null; + } + + if ($uri === '') { + return null; + } + + if (str_starts_with($uri, 'php://')) { + return null; + } + + return $uri; + } + + private function normalizeFileName(?string $name): string + { + if ($name === null || $name === '') { + return ''; + } + + return str_contains($name, '\\') ? basename(str_replace('\\', '/', $name)) : basename($name); + } + + /** + * @psalm-param FileData|null $file + * @psalm-param array $extra + * + * @psalm-return array + */ + private function getParameters(ValidationContext $context, ?array $file = null, array $extra = []): array + { + return [ + 'property' => $context->getTranslatedProperty(), + 'Property' => $context->getCapitalizedTranslatedProperty(), + 'file' => $file['name'] ?? '', + ...$extra, + ]; + } +} diff --git a/tests/Rule/File/README b/tests/Rule/File/README new file mode 100644 index 000000000..61d05c547 --- /dev/null +++ b/tests/Rule/File/README @@ -0,0 +1 @@ +File without extension. diff --git a/tests/Rule/File/notes.txt b/tests/Rule/File/notes.txt new file mode 100644 index 000000000..a0507a8af --- /dev/null +++ b/tests/Rule/File/notes.txt @@ -0,0 +1 @@ +I love cats very much diff --git a/tests/Rule/FileTest.php b/tests/Rule/FileTest.php new file mode 100644 index 000000000..ecc9bb820 --- /dev/null +++ b/tests/Rule/FileTest.php @@ -0,0 +1,527 @@ + [ + ['size' => 100, 'minSize' => 100], + 'Exact size and min / max size can\'t be specified together.', + ], + 'size and max size' => [ + ['size' => 100, 'maxSize' => 100], + 'Exact size and min / max size can\'t be specified together.', + ], + 'min size greater than max size' => [ + ['minSize' => 100, 'maxSize' => 50], + 'Min size must be less than or equal to max size.', + ], + 'negative size' => [ + ['size' => -1], + 'Size must be greater than or equal to 0.', + ], + 'negative min size' => [ + ['minSize' => -1], + 'MinSize must be greater than or equal to 0.', + ], + 'negative max size' => [ + ['maxSize' => -1], + 'MaxSize must be greater than or equal to 0.', + ], + 'empty extensions list' => [ + ['extensions' => ' , '], + 'List of allowed values cannot be empty.', + ], + 'empty mime types list' => [ + ['mimeTypes' => []], + 'List of allowed values cannot be empty.', + ], + ]; + } + + #[DataProvider('dataConfigurationError')] + public function testConfigurationError(array $arguments, string $expectedExceptionMessage): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + new File(...$arguments); + } + + public function testGetName(): void + { + $rule = new File(); + $this->assertSame('file', $rule->getName()); + } + + public function testAllowedValuesAreNormalizedFromArrayInput(): void + { + $rule = new File( + extensions: [' JPG ', 'jpg', 'Png '], + mimeTypes: [' TEXT/PLAIN ', 'text/plain', 'IMAGE/JPEG '], + ); + + $this->assertSame(['jpg', 'png'], $rule->getExtensions()); + $this->assertSame(['text/plain', 'image/jpeg'], $rule->getMimeTypes()); + } + + public static function dataOptions(): array + { + return [ + [ + new File(), + [ + 'extensions' => null, + 'mimeTypes' => null, + 'trustClientMediaType' => false, + 'size' => null, + 'minSize' => null, + 'maxSize' => null, + 'message' => [ + 'template' => '{Property} must be a file.', + 'parameters' => [], + ], + 'uploadFailedMessage' => [ + 'template' => 'Failed to upload {property}. Error code: {error, number}.', + 'parameters' => [], + ], + 'uploadRequiredMessage' => [ + 'template' => 'Please upload a file.', + 'parameters' => [], + ], + 'wrongExtensionMessage' => [ + 'template' => 'Only files with these extensions are allowed: {extensions}.', + 'parameters' => [], + ], + 'wrongMimeTypeMessage' => [ + 'template' => 'Only files with these MIME types are allowed: {mimeTypes}.', + 'parameters' => [], + ], + 'notExactSizeMessage' => [ + 'template' => 'The size of {property} must be exactly {exactly, number} {exactly, plural, one{byte} other{bytes}}.', + 'parameters' => [], + ], + 'tooSmallMessage' => [ + 'template' => 'The size of {property} cannot be smaller than {limit, number} {limit, plural, one{byte} other{bytes}}.', + 'parameters' => [], + ], + 'tooBigMessage' => [ + 'template' => 'The size of {property} cannot be larger than {limit, number} {limit, plural, one{byte} other{bytes}}.', + 'parameters' => [], + ], + 'unableToDetermineSizeMessage' => [ + 'template' => 'The size of {property} cannot be determined.', + 'parameters' => [], + ], + 'skipOnEmpty' => false, + 'skipOnError' => false, + ], + ], + [ + new File( + extensions: ' JPG, jpg , png ', + mimeTypes: [' IMAGE/JPEG ', 'text/plain', 'TEXT/PLAIN'], + size: 921, + trustClientMediaType: true, + message: 'Custom file message.', + uploadFailedMessage: 'Custom upload failed.', + uploadRequiredMessage: 'Custom upload required.', + wrongExtensionMessage: 'Custom extension.', + wrongMimeTypeMessage: 'Custom mime.', + notExactSizeMessage: 'Custom exact size.', + tooSmallMessage: 'Custom too small.', + tooBigMessage: 'Custom too big.', + unableToDetermineSizeMessage: 'Custom unknown size.', + skipOnEmpty: true, + skipOnError: true, + ), + [ + 'extensions' => ['jpg', 'png'], + 'mimeTypes' => ['image/jpeg', 'text/plain'], + 'trustClientMediaType' => true, + 'size' => 921, + 'minSize' => null, + 'maxSize' => null, + 'message' => [ + 'template' => 'Custom file message.', + 'parameters' => [], + ], + 'uploadFailedMessage' => [ + 'template' => 'Custom upload failed.', + 'parameters' => [], + ], + 'uploadRequiredMessage' => [ + 'template' => 'Custom upload required.', + 'parameters' => [], + ], + 'wrongExtensionMessage' => [ + 'template' => 'Custom extension.', + 'parameters' => [], + ], + 'wrongMimeTypeMessage' => [ + 'template' => 'Custom mime.', + 'parameters' => [], + ], + 'notExactSizeMessage' => [ + 'template' => 'Custom exact size.', + 'parameters' => [], + ], + 'tooSmallMessage' => [ + 'template' => 'Custom too small.', + 'parameters' => [], + ], + 'tooBigMessage' => [ + 'template' => 'Custom too big.', + 'parameters' => [], + ], + 'unableToDetermineSizeMessage' => [ + 'template' => 'Custom unknown size.', + 'parameters' => [], + ], + 'skipOnEmpty' => true, + 'skipOnError' => true, + ], + ], + ]; + } + + public static function dataValidationPassed(): array + { + return [ + 'exact zero size' => [self::EMPTY_JPG_FILE, new File(size: 0)], + 'file path' => [self::JPG_FILE, new File()], + 'spl file info' => [new SplFileInfo(self::JPG_FILE), new File()], + 'spl file info with constraints' => [ + new SplFileInfo(self::TEXT_FILE), + new File(mimeTypes: ['text/plain'], size: 22), + ], + 'min size zero' => [self::EMPTY_JPG_FILE, new File(minSize: 0)], + 'uploaded file from path' => [ + new UploadedFile(self::JPG_FILE, 921, UPLOAD_ERR_OK, 'avatar.JPG', 'image/jpeg'), + new File(extensions: ['jpg'], mimeTypes: ['image/jpeg'], size: 921), + ], + 'uploaded file from path with inaccurate size metadata' => [ + new UploadedFile(self::JPG_FILE, 999, UPLOAD_ERR_OK), + new File(extensions: ['jpg'], mimeTypes: ['image/jpeg'], size: 921), + ], + 'uploaded file from stream with client metadata' => [ + self::createStreamUpload('resume.txt', 'text/plain'), + new File(extensions: 'txt', mimeTypes: 'text/plain', size: 22, trustClientMediaType: true), + ], + 'uploaded file from stream with uppercase client metadata' => [ + self::createStreamUpload('resume.txt', 'TEXT/PLAIN'), + new File(extensions: 'txt', mimeTypes: 'text/plain', size: 22, trustClientMediaType: true), + ], + 'uploaded file from php stream uri without filename' => [ + self::createStreamUpload(null, 'text/plain'), + new File(mimeTypes: 'text/plain', size: 22, trustClientMediaType: true), + ], + 'uploaded file from stream with unknown size' => [ + self::createStreamUpload('resume.txt', 'text/plain', null), + new File(extensions: 'txt', mimeTypes: 'text/plain', trustClientMediaType: true), + ], + 'mime wildcard' => [self::PNG_FILE, new File(mimeTypes: ['image/*'])], + 'min size boundary' => [self::TEXT_FILE, new File(minSize: 22)], + 'max size boundary' => [self::TEXT_FILE, new File(maxSize: 22)], + 'multiple files via each rule' => [ + [self::JPG_FILE, new SplFileInfo(self::TEXT_FILE)], + new Each(new File()), + ], + 'null with skipOnEmpty' => [null, new File(skipOnEmpty: true)], + 'uploaded file missing with skipOnEmpty' => [ + new UploadedFile(self::JPG_FILE, 921, UPLOAD_ERR_NO_FILE), + new File(skipOnEmpty: true), + ], + 'null with when returning false' => [ + null, + new File(when: static fn(mixed $value): bool => $value !== null), + ], + 'object providing rules and valid data' => [ + new class { + #[File(extensions: 'txt', mimeTypes: 'text/plain', size: 22)] + private string $file = FileTest::TEXT_FILE; + }, + null, + ], + ]; + } + + public static function dataValidationFailed(): array + { + return [ + 'missing string value' => [null, new File(), ['' => ['Please upload a file.']]], + 'empty string value' => ['', new File(), ['' => ['Please upload a file.']]], + 'uploaded file missing' => [ + new UploadedFile(self::JPG_FILE, 921, UPLOAD_ERR_NO_FILE), + new File(), + ['' => ['Please upload a file.']], + ], + 'uploaded file error' => [ + new UploadedFile(self::JPG_FILE, 921, UPLOAD_ERR_CANT_WRITE, 'avatar.jpg'), + new File(), + ['' => ['Failed to upload value. Error code: 7.']], + ], + 'uploaded file with missing temp path and unknown size' => [ + new UploadedFile('/definitely/missing/upload.tmp', null, UPLOAD_ERR_OK, 'avatar.jpg'), + new File(), + ['' => ['Value must be a file.']], + ], + 'non file path' => ['missing.txt', new File(), ['' => ['Value must be a file.']]], + 'invalid value type' => [new stdClass(), new File(), ['' => ['Value must be a file.']]], + 'invalid value type with constraints' => [ + new stdClass(), + new File(extensions: ['jpg'], mimeTypes: ['image/jpeg'], size: 1), + ['' => ['Value must be a file.']], + ], + 'spl file info directory' => [ + new SplFileInfo(__DIR__ . '/File'), + new File(), + ['' => ['Value must be a file.']], + ], + 'wrong extension' => [ + self::TEXT_FILE, + new File(extensions: ['jpg']), + ['' => ['Only files with these extensions are allowed: jpg.']], + ], + 'extensionless file with extensions constraint' => [ + self::EXTENSIONLESS_FILE, + new File(extensions: ['txt']), + ['' => ['Only files with these extensions are allowed: txt.']], + ], + 'wrong mime type' => [ + self::TEXT_FILE, + new File(mimeTypes: ['image/jpeg']), + ['' => ['Only files with these MIME types are allowed: image/jpeg.']], + ], + 'spl file info exact size mismatch' => [ + new SplFileInfo(self::TEXT_FILE), + new File(size: 21), + ['' => ['The size of value must be exactly 21 bytes.']], + ], + 'exact size mismatch' => [ + self::JPG_FILE, + new File(size: 920), + ['' => ['The size of value must be exactly 920 bytes.']], + ], + 'uploaded file exact size mismatch uses actual file size' => [ + new UploadedFile(self::JPG_FILE, 921, UPLOAD_ERR_OK), + new File(size: 999), + ['' => ['The size of value must be exactly 999 bytes.']], + ], + 'too small' => [ + self::EMPTY_JPG_FILE, + new File(minSize: 1), + ['' => ['The size of value cannot be smaller than 1 byte.']], + ], + 'too big' => [ + self::JPG_FILE, + new File(maxSize: 920), + ['' => ['The size of value cannot be larger than 920 bytes.']], + ], + 'stream upload unknown exact size' => [ + self::createStreamUpload('resume.txt', 'text/plain', null), + new File(extensions: 'txt', mimeTypes: 'text/plain', size: 22, trustClientMediaType: true), + ['' => ['The size of value cannot be determined.']], + ], + 'stream upload unknown minimum size' => [ + self::createStreamUpload('resume.txt', 'text/plain', null), + new File(extensions: 'txt', mimeTypes: 'text/plain', minSize: 1, trustClientMediaType: true), + ['' => ['The size of value cannot be determined.']], + ], + 'stream upload unknown maximum size' => [ + self::createStreamUpload('resume.txt', 'text/plain', null), + new File(extensions: 'txt', mimeTypes: 'text/plain', maxSize: 100, trustClientMediaType: true), + ['' => ['The size of value cannot be determined.']], + ], + 'stream upload client media type is not trusted by default' => [ + self::createStreamUpload('resume.txt', 'text/plain'), + new File(mimeTypes: ['text/plain']), + ['' => ['Only files with these MIME types are allowed: text/plain.']], + ], + 'stream upload wrong extension' => [ + self::createStreamUpload('resume.txt', 'text/plain'), + new File(extensions: ['jpg']), + ['' => ['Only files with these extensions are allowed: jpg.']], + ], + 'stream upload missing client media type' => [ + self::createStreamUpload('resume.txt', null), + new File(mimeTypes: ['text/plain']), + ['' => ['Only files with these MIME types are allowed: text/plain.']], + ], + 'stream upload missing client media type with wildcard rule' => [ + self::createStreamUpload('resume.txt', null), + new File(mimeTypes: ['image/*']), + ['' => ['Only files with these MIME types are allowed: image/*.']], + ], + 'stream upload wildcard mismatch' => [ + self::createStreamUpload('resume.txt', 'text/plain'), + new File(mimeTypes: ['image/*'], trustClientMediaType: true), + ['' => ['Only files with these MIME types are allowed: image/*.']], + ], + 'custom messages with parameters' => [ + ['attachment' => new UploadedFile(self::TEXT_FILE, 22, UPLOAD_ERR_CANT_WRITE, 'resume.txt')], + ['attachment' => new File(uploadFailedMessage: 'Property - {property}, file - {file}, error - {error}.')], + ['attachment' => ['Property - attachment, file - resume.txt, error - 7.']], + ], + 'windows style upload filename in error message' => [ + ['attachment' => new UploadedFile(self::TEXT_FILE, 22, UPLOAD_ERR_CANT_WRITE, 'C:\\temp\\resume.txt')], + ['attachment' => new File(uploadFailedMessage: 'Property - {property}, file - {file}, error - {error}.')], + ['attachment' => ['Property - attachment, file - resume.txt, error - 7.']], + ], + 'object providing rules, property labels and wrong data' => [ + new class implements RulesProviderInterface, PropertyTranslatorProviderInterface { + public function __construct( + public string $file = FileTest::TEXT_FILE, + ) {} + + public function getPropertyLabels(): array + { + return [ + 'file' => 'Файл', + ]; + } + + public function getPropertyTranslator(): ?PropertyTranslatorInterface + { + return new ArrayPropertyTranslator($this->getPropertyLabels()); + } + + public function getRules(): array + { + return [ + 'file' => [ + new File( + mimeTypes: ['image/jpeg'], + wrongMimeTypeMessage: '{property} имеет неверный MIME-тип: {mimeTypes}.', + ), + ], + ]; + } + }, + null, + ['file' => ['Файл имеет неверный MIME-тип: image/jpeg.']], + ], + ]; + } + + public function testSkipOnError(): void + { + $this->testSkipOnErrorInternal(new File(), new File(skipOnError: true)); + } + + public function testWhen(): void + { + $when = static fn(mixed $value): bool => $value !== null; + $this->testWhenInternal(new File(), new File(when: $when)); + } + + /** + * @throws ReflectionException + */ + public function testUnreadableFileMimeValidationDoesNotEmitWarning(): void + { + $path = tempnam(sys_get_temp_dir(), 'yii-validator-file-'); + $this->assertIsString($path); + file_put_contents($path, "Unreadable notes\n"); + chmod($path, 0000); + + if (is_readable($path)) { + chmod($path, 0644); + unlink($path); + $this->markTestSkipped('The current environment cannot create an unreadable file.'); + } + + $warnings = []; + set_error_handler(static function (int $severity, string $message) use (&$warnings): bool { + $warnings[] = $message; + return true; + }); + + try { + $result = (new Validator())->validate($path, new File(mimeTypes: ['text/plain'])); + } finally { + restore_error_handler(); + chmod($path, 0644); + unlink($path); + } + + $this->assertSame([], $warnings); + $this->assertSame( + ['' => ['Only files with these MIME types are allowed: text/plain.']], + $result->getErrorMessagesIndexedByPath(), + ); + } + + protected function getDifferentRuleInHandlerItems(): array + { + return [File::class, FileHandler::class]; + } + + protected function getRuleClass(): string + { + return File::class; + } + + private static function createStreamUpload( + ?string $fileName, + ?string $clientMediaType, + ?int $size = 22, + ): UploadedFile { + $resource = fopen('php://temp', 'rb+'); + fwrite($resource, "Quarterly notes draft\n"); + rewind($resource); + + return new UploadedFile($resource, $size, UPLOAD_ERR_OK, $fileName, $clientMediaType); + } +}