From 661ab45ea211126951a89fb91e2c9ab427182bd2 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 23 Apr 2026 21:53:17 +0300 Subject: [PATCH 1/5] Fix #591: Add `File` validator, add `SplFileInfo` support to `Image` validator --- CHANGELOG.md | 1 + docs/guide/en/built-in-rules.md | 1 + src/Rule/File.php | 411 ++++++++++++++++++++++++++++++ src/Rule/FileHandler.php | 352 +++++++++++++++++++++++++ src/Rule/Image/ImageHandler.php | 17 +- tests/Rule/File/README | 1 + tests/Rule/File/notes.txt | 1 + tests/Rule/FileTest.php | 439 ++++++++++++++++++++++++++++++++ tests/Rule/Image/ImageTest.php | 8 + 9 files changed, 1230 insertions(+), 1 deletion(-) create mode 100644 src/Rule/File.php create mode 100644 src/Rule/FileHandler.php create mode 100644 tests/Rule/File/README create mode 100644 tests/Rule/File/notes.txt create mode 100644 tests/Rule/FileTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae33117..0c1bbd47 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) +- Enh #591: Add `File` validator, add `SplFileInfo` support to `Image` validator (@samdark) ## 2.5.1 December 12, 2025 diff --git a/docs/guide/en/built-in-rules.md b/docs/guide/en/built-in-rules.md index d55d8e42..b0570b20 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 diff --git a/src/Rule/File.php b/src/Rule/File.php new file mode 100644 index 00000000..a71f5fc4 --- /dev/null +++ b/src/Rule/File.php @@ -0,0 +1,411 @@ +|null + */ + private ?array $extensions; + + /** + * @var list|null + */ + private ?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 falls back to client-provided metadata. 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. + * @param int|null $minSize Expected minimum size of the validated file in bytes. + * @param int|null $maxSize Expected maximum size of the validated file in bytes. + * @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. + * - `{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. + * - `{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. + * @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. + * - `{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. + * - `{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. + * - `{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. + * - `{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. + * - `{file}`: the validated file name when it is available. + * - `{limit}`: expected maximum size in bytes. + * @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 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}}.', + 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.'); + } + } + + $this->extensions = $this->normalizeList($extensions); + $this->mimeTypes = $this->normalizeList($mimeTypes); + $this->skipOnEmpty = $skipOnEmpty; + } + + public function getName(): string + { + return 'file'; + } + + /** + * 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; + } + + /** + * 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; + } + + public function getHandler(): string + { + return FileHandler::class; + } + + public function getOptions(): array + { + return [ + 'extensions' => $this->extensions, + 'mimeTypes' => $this->mimeTypes, + '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' => [], + ], + 'skipOnEmpty' => $this->getSkipOnEmptyOption(), + 'skipOnError' => $this->skipOnError, + ]; + } + + /** + * @psalm-param list|string|null $value + * + * @return list|null + */ + private function normalizeList(array|string|null $value): ?array + { + if ($value === null) { + return null; + } + + if (is_string($value)) { + $value = preg_split('/[\s,]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: []; + } + + $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; + } +} diff --git a/src/Rule/FileHandler.php b/src/Rule/FileHandler.php new file mode 100644 index 00000000..6e42e129 --- /dev/null +++ b/src/Rule/FileHandler.php @@ -0,0 +1,352 @@ +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())) { + $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); + $size = $value->getSize(); + $isFile = $path === null || is_file($path); + + if ($size === null && $path !== null && $isFile) { + $fileInfoSize = (new SplFileInfo($path))->getSize(); + $size = is_int($fileInfoSize) ? $fileInfoSize : null; + } + + 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 + { + if ($mimeTypes === null) { + return true; + } + + $mimeType = $this->detectMimeType($file); + 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) { + 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): ?string + { + if ($file['path'] !== null && is_file($file['path'])) { + if (function_exists('mime_content_type')) { + $mimeType = mime_content_type($file['path']); + } else { + return null; + } + + return is_string($mimeType) ? $mimeType : null; + } + + return $file['clientMediaType']; + } + + 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/src/Rule/Image/ImageHandler.php b/src/Rule/Image/ImageHandler.php index 6248f177..4b0b2a5b 100644 --- a/src/Rule/Image/ImageHandler.php +++ b/src/Rule/Image/ImageHandler.php @@ -5,6 +5,8 @@ namespace Yiisoft\Validator\Rule\Image; use Psr\Http\Message\UploadedFileInterface; +use RuntimeException; +use SplFileInfo; use Yiisoft\Validator\Exception\UnexpectedRuleException; use Yiisoft\Validator\Result; use Yiisoft\Validator\RuleHandlerInterface; @@ -152,8 +154,21 @@ private function isImageFile(string $filePath): bool private function getFilePath(mixed $value): ?string { if ($value instanceof UploadedFileInterface) { - $value = $value->getError() === UPLOAD_ERR_OK ? $value->getStream()->getMetadata('uri') : null; + if ($value->getError() !== UPLOAD_ERR_OK) { + return null; + } + + try { + $value = $value->getStream()->getMetadata('uri'); + } catch (RuntimeException) { + return null; + } } + + if ($value instanceof SplFileInfo) { + $value = $value->getPathname(); + } + return is_string($value) ? $value : null; } diff --git a/tests/Rule/File/README b/tests/Rule/File/README new file mode 100644 index 00000000..61d05c54 --- /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 00000000..a0507a8a --- /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 00000000..f456d2db --- /dev/null +++ b/tests/Rule/FileTest.php @@ -0,0 +1,439 @@ + [ + ['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.', + ], + '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, + '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' => [], + ], + 'skipOnEmpty' => false, + 'skipOnError' => false, + ], + ], + [ + new File( + extensions: ' JPG, jpg , png ', + mimeTypes: [' IMAGE/JPEG ', 'text/plain', 'TEXT/PLAIN'], + size: 921, + 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.', + skipOnEmpty: true, + skipOnError: true, + ), + [ + 'extensions' => ['jpg', 'png'], + 'mimeTypes' => ['image/jpeg', 'text/plain'], + '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' => [], + ], + '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 without client metadata' => [ + new UploadedFile(self::JPG_FILE, 999, UPLOAD_ERR_OK), + new File(extensions: ['jpg'], mimeTypes: ['image/jpeg'], size: 999), + ], + 'uploaded file from stream with client metadata' => [ + self::createStreamUpload('resume.txt', 'text/plain'), + new File(extensions: 'txt', mimeTypes: 'text/plain', size: 22), + ], + 'uploaded file from stream with uppercase client metadata' => [ + self::createStreamUpload('resume.txt', 'TEXT/PLAIN'), + new File(extensions: 'txt', mimeTypes: 'text/plain', size: 22), + ], + 'uploaded file from php stream uri without filename' => [ + self::createStreamUpload(null, 'text/plain', 22), + new File(mimeTypes: 'text/plain', size: 22), + ], + 'uploaded file from stream with unknown size' => [ + self::createStreamUpload('resume.txt', 'text/plain', null), + new File(extensions: 'txt', mimeTypes: 'text/plain'), + ], + 'uploaded file from stream with unknown size and size rule' => [ + self::createStreamUpload('resume.txt', 'text/plain', null), + new File(extensions: 'txt', mimeTypes: 'text/plain', size: 22), + ], + '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)], + '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.']], + ], + '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 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/*']), + ['' => ['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)); + } + + 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); + } +} diff --git a/tests/Rule/Image/ImageTest.php b/tests/Rule/Image/ImageTest.php index 1a93eff2..ba5d7d0d 100644 --- a/tests/Rule/Image/ImageTest.php +++ b/tests/Rule/Image/ImageTest.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\WithoutErrorHandler; +use SplFileInfo; use Yiisoft\Validator\Rule\Image\Image; use Yiisoft\Validator\Rule\Image\ImageAspectRatio; use Yiisoft\Validator\Rule\Image\ImageHandler; @@ -72,6 +73,7 @@ public static function dataValidationPassed(): array 'png' => [__DIR__ . '/16x18.png', new Image()], 'jpg' => [__DIR__ . '/16x18.jpg', new Image()], 'heic' => [__DIR__ . '/797x808.HEIC', new Image()], + 'spl-file-info' => [new SplFileInfo(__DIR__ . '/16x18.jpg'), new Image()], 'uploaded-file' => [new UploadedFile(__DIR__ . '/16x18.jpg', 0, UPLOAD_ERR_OK), new Image()], 'exactly' => [__DIR__ . '/16x18.jpg', new Image(width: 16, height: 18)], 'min-width' => [__DIR__ . '/16x18.jpg', new Image(minWidth: 12)], @@ -160,11 +162,17 @@ public static function dataValidationFailed(): array ['a' => new Image(notImageMessage: 'Value of "{property}" must be an image.')], ['a' => ['Value of "a" must be an image.']], ], + 'spl-file-info-not-image' => [new SplFileInfo(__DIR__ . '/ImageTest.php'), new Image(), $notImageResult], 'not-uploaded-file' => [ new UploadedFile(__DIR__ . '/16x18.jpg', 0, UPLOAD_ERR_NO_FILE), new Image(), $notImageResult, ], + 'uploaded-file-with-missing-temp-path' => [ + new UploadedFile('/definitely/missing/upload-image.tmp', null, UPLOAD_ERR_OK, 'avatar.jpg'), + new Image(), + $notImageResult, + ], 'not-exactly' => [ __DIR__ . '/16x18.jpg', new Image(width: 24, height: 32), From b04e1b9eda6c7be7dd54739592d93957246aa6ef Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 23 Apr 2026 22:08:42 +0300 Subject: [PATCH 2/5] Fix edge case --- src/Rule/File.php | 33 ++++++++++++++++++++++++++++++--- src/Rule/FileHandler.php | 7 +++++++ tests/Rule/FileTest.php | 28 ++++++++++++++++++++++++---- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/Rule/File.php b/src/Rule/File.php index a71f5fc4..6dbbfe41 100644 --- a/src/Rule/File.php +++ b/src/Rule/File.php @@ -67,9 +67,12 @@ final class File implements DumpedRuleInterface, SkipOnErrorInterface, WhenInter * 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 falls back to client-provided metadata. 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. - * @param int|null $minSize Expected minimum size of the validated file in bytes. - * @param int|null $maxSize Expected maximum size of the validated file in bytes. + * @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 string $message A message used when the validated value is not a valid file. * * You may use the following placeholders in the message: @@ -123,6 +126,13 @@ final class File implements DumpedRuleInterface, SkipOnErrorInterface, WhenInter * - `{property}`: the translated label of the property being validated. * - `{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. + * - `{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. @@ -148,6 +158,7 @@ public function __construct( 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, @@ -328,6 +339,18 @@ 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; @@ -373,6 +396,10 @@ public function getOptions(): array 'template' => $this->tooBigMessage, 'parameters' => [], ], + 'unableToDetermineSizeMessage' => [ + 'template' => $this->unableToDetermineSizeMessage, + 'parameters' => [], + ], 'skipOnEmpty' => $this->getSkipOnEmptyOption(), 'skipOnError' => $this->skipOnError, ]; diff --git a/src/Rule/FileHandler.php b/src/Rule/FileHandler.php index 6e42e129..22611cb1 100644 --- a/src/Rule/FileHandler.php +++ b/src/Rule/FileHandler.php @@ -264,6 +264,13 @@ private function validateSize(array $file, File $rule, ValidationContext $contex { $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; } diff --git a/tests/Rule/FileTest.php b/tests/Rule/FileTest.php index f456d2db..9f8c230a 100644 --- a/tests/Rule/FileTest.php +++ b/tests/Rule/FileTest.php @@ -145,6 +145,10 @@ public static function dataOptions(): array '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, ], @@ -162,6 +166,7 @@ public static function dataOptions(): array notExactSizeMessage: 'Custom exact size.', tooSmallMessage: 'Custom too small.', tooBigMessage: 'Custom too big.', + unableToDetermineSizeMessage: 'Custom unknown size.', skipOnEmpty: true, skipOnError: true, ), @@ -203,6 +208,10 @@ public static function dataOptions(): array 'template' => 'Custom too big.', 'parameters' => [], ], + 'unableToDetermineSizeMessage' => [ + 'template' => 'Custom unknown size.', + 'parameters' => [], + ], 'skipOnEmpty' => true, 'skipOnError' => true, ], @@ -245,10 +254,6 @@ public static function dataValidationPassed(): array self::createStreamUpload('resume.txt', 'text/plain', null), new File(extensions: 'txt', mimeTypes: 'text/plain'), ], - 'uploaded file from stream with unknown size and size rule' => [ - self::createStreamUpload('resume.txt', 'text/plain', null), - new File(extensions: 'txt', mimeTypes: 'text/plain', size: 22), - ], '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)], @@ -338,6 +343,21 @@ public static function dataValidationFailed(): array 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), + ['' => ['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), + ['' => ['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), + ['' => ['The size of value cannot be determined.']], + ], 'stream upload wrong extension' => [ self::createStreamUpload('resume.txt', 'text/plain'), new File(extensions: ['jpg']), From ff3c0522560099ac2a59849c37de5243d79df057 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 23 Apr 2026 22:14:00 +0300 Subject: [PATCH 3/5] Remove Image validator changes from file branch --- CHANGELOG.md | 2 +- src/Rule/Image/ImageHandler.php | 17 +---------------- tests/Rule/Image/ImageTest.php | 8 -------- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1bbd47..9188377b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +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) -- Enh #591: Add `File` validator, add `SplFileInfo` support to `Image` validator (@samdark) +- Enh #591: Add `File` validator (@samdark) ## 2.5.1 December 12, 2025 diff --git a/src/Rule/Image/ImageHandler.php b/src/Rule/Image/ImageHandler.php index 4b0b2a5b..6248f177 100644 --- a/src/Rule/Image/ImageHandler.php +++ b/src/Rule/Image/ImageHandler.php @@ -5,8 +5,6 @@ namespace Yiisoft\Validator\Rule\Image; use Psr\Http\Message\UploadedFileInterface; -use RuntimeException; -use SplFileInfo; use Yiisoft\Validator\Exception\UnexpectedRuleException; use Yiisoft\Validator\Result; use Yiisoft\Validator\RuleHandlerInterface; @@ -154,21 +152,8 @@ private function isImageFile(string $filePath): bool private function getFilePath(mixed $value): ?string { if ($value instanceof UploadedFileInterface) { - if ($value->getError() !== UPLOAD_ERR_OK) { - return null; - } - - try { - $value = $value->getStream()->getMetadata('uri'); - } catch (RuntimeException) { - return null; - } + $value = $value->getError() === UPLOAD_ERR_OK ? $value->getStream()->getMetadata('uri') : null; } - - if ($value instanceof SplFileInfo) { - $value = $value->getPathname(); - } - return is_string($value) ? $value : null; } diff --git a/tests/Rule/Image/ImageTest.php b/tests/Rule/Image/ImageTest.php index ba5d7d0d..1a93eff2 100644 --- a/tests/Rule/Image/ImageTest.php +++ b/tests/Rule/Image/ImageTest.php @@ -8,7 +8,6 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\WithoutErrorHandler; -use SplFileInfo; use Yiisoft\Validator\Rule\Image\Image; use Yiisoft\Validator\Rule\Image\ImageAspectRatio; use Yiisoft\Validator\Rule\Image\ImageHandler; @@ -73,7 +72,6 @@ public static function dataValidationPassed(): array 'png' => [__DIR__ . '/16x18.png', new Image()], 'jpg' => [__DIR__ . '/16x18.jpg', new Image()], 'heic' => [__DIR__ . '/797x808.HEIC', new Image()], - 'spl-file-info' => [new SplFileInfo(__DIR__ . '/16x18.jpg'), new Image()], 'uploaded-file' => [new UploadedFile(__DIR__ . '/16x18.jpg', 0, UPLOAD_ERR_OK), new Image()], 'exactly' => [__DIR__ . '/16x18.jpg', new Image(width: 16, height: 18)], 'min-width' => [__DIR__ . '/16x18.jpg', new Image(minWidth: 12)], @@ -162,17 +160,11 @@ public static function dataValidationFailed(): array ['a' => new Image(notImageMessage: 'Value of "{property}" must be an image.')], ['a' => ['Value of "a" must be an image.']], ], - 'spl-file-info-not-image' => [new SplFileInfo(__DIR__ . '/ImageTest.php'), new Image(), $notImageResult], 'not-uploaded-file' => [ new UploadedFile(__DIR__ . '/16x18.jpg', 0, UPLOAD_ERR_NO_FILE), new Image(), $notImageResult, ], - 'uploaded-file-with-missing-temp-path' => [ - new UploadedFile('/definitely/missing/upload-image.tmp', null, UPLOAD_ERR_OK, 'avatar.jpg'), - new Image(), - $notImageResult, - ], 'not-exactly' => [ __DIR__ . '/16x18.jpg', new Image(width: 24, height: 32), From 032b6e4a3f4584e86863fe1ffcc05c62cd73edcb Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 23 Apr 2026 22:15:20 +0300 Subject: [PATCH 4/5] Adjust README --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9188377b..90fcc0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +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) -- Enh #591: Add `File` validator (@samdark) +- New #591: Add `File` validator (@samdark) ## 2.5.1 December 12, 2025 From d492496a008fb7259ddf0201f2db87c44449dd66 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Fri, 24 Apr 2026 00:58:40 +0300 Subject: [PATCH 5/5] Fix possible warnings in `mime_content_type` --- src/Rule/FileHandler.php | 26 ++++++++++++++++++++---- tests/Rule/FileTest.php | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/Rule/FileHandler.php b/src/Rule/FileHandler.php index 22611cb1..833117b1 100644 --- a/src/Rule/FileHandler.php +++ b/src/Rule/FileHandler.php @@ -17,7 +17,9 @@ use function implode; use function in_array; use function is_file; +use function is_readable; use function is_string; +use function mime_content_type; use function pathinfo; use function str_contains; use function str_ends_with; @@ -26,6 +28,8 @@ use function strtolower; use function function_exists; use function is_int; +use function restore_error_handler; +use function set_error_handler; use const PATHINFO_EXTENSION; use const UPLOAD_ERR_NO_FILE; @@ -302,18 +306,32 @@ private function validateSize(array $file, File $rule, ValidationContext $contex private function detectMimeType(array $file): ?string { if ($file['path'] !== null && is_file($file['path'])) { - if (function_exists('mime_content_type')) { - $mimeType = mime_content_type($file['path']); - } else { + if (!is_readable($file['path'])) { return null; } - return is_string($mimeType) ? $mimeType : null; + if (!function_exists('mime_content_type')) { + return null; + } + + $mimeType = $this->detectMimeTypeFromPath($file['path']); + return $mimeType === false ? null : $mimeType; } return $file['clientMediaType']; } + 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'); diff --git a/tests/Rule/FileTest.php b/tests/Rule/FileTest.php index 9f8c230a..445935dc 100644 --- a/tests/Rule/FileTest.php +++ b/tests/Rule/FileTest.php @@ -21,10 +21,19 @@ use Yiisoft\Validator\Tests\Rule\Base\RuleWithOptionsTestTrait; use Yiisoft\Validator\Tests\Rule\Base\SkipOnErrorTestTrait; use Yiisoft\Validator\Tests\Rule\Base\WhenTestTrait; +use Yiisoft\Validator\Validator; +use function chmod; +use function file_put_contents; use function fopen; use function fwrite; +use function is_readable; use function rewind; +use function restore_error_handler; +use function set_error_handler; +use function sys_get_temp_dir; +use function tempnam; +use function unlink; use const UPLOAD_ERR_CANT_WRITE; use const UPLOAD_ERR_NO_FILE; @@ -435,6 +444,40 @@ public function testWhen(): void $this->testWhenInternal(new File(), new File(when: $when)); } + 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];