diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index e421f748ebbb0..1884db5898ee6 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 7.3 --- + * Add the `filenameCharset` and `filenameCountUnit` options to the `File` constraint * Deprecate defining custom constraints not supporting named arguments * Deprecate passing an array of options to the constructors of the constraint classes, pass each option as a dedicated argument instead * Add support for ratio checks for SVG files to the `Image` constraint diff --git a/src/Symfony/Component/Validator/Constraints/File.php b/src/Symfony/Component/Validator/Constraints/File.php index 8117339bada46..7d93a20848ba8 100644 --- a/src/Symfony/Component/Validator/Constraints/File.php +++ b/src/Symfony/Component/Validator/Constraints/File.php @@ -14,6 +14,7 @@ use Symfony\Component\Validator\Attribute\HasNamedArguments; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Exception\InvalidArgumentException; /** * Validates that a value is a valid "file". @@ -38,6 +39,17 @@ class File extends Constraint public const INVALID_MIME_TYPE_ERROR = '744f00bc-4389-4c74-92de-9a43cde55534'; public const INVALID_EXTENSION_ERROR = 'c8c7315c-6186-4719-8b71-5659e16bdcb7'; public const FILENAME_TOO_LONG = 'e5706483-91a8-49d8-9a59-5e81a3c634a8'; + public const FILENAME_INVALID_CHARACTERS = '04ee58e1-42b4-45c7-8423-8a4a145fedd9'; + + public const FILENAME_COUNT_BYTES = 'bytes'; + public const FILENAME_COUNT_CODEPOINTS = 'codepoints'; + public const FILENAME_COUNT_GRAPHEMES = 'graphemes'; + + private const FILENAME_VALID_COUNT_UNITS = [ + self::FILENAME_COUNT_BYTES, + self::FILENAME_COUNT_CODEPOINTS, + self::FILENAME_COUNT_GRAPHEMES, + ]; protected const ERROR_NAMES = [ self::NOT_FOUND_ERROR => 'NOT_FOUND_ERROR', @@ -47,12 +59,17 @@ class File extends Constraint self::INVALID_MIME_TYPE_ERROR => 'INVALID_MIME_TYPE_ERROR', self::INVALID_EXTENSION_ERROR => 'INVALID_EXTENSION_ERROR', self::FILENAME_TOO_LONG => 'FILENAME_TOO_LONG', + self::FILENAME_INVALID_CHARACTERS => 'FILENAME_INVALID_CHARACTERS', ]; public ?bool $binaryFormat = null; public array|string $mimeTypes = []; public ?int $filenameMaxLength = null; public array|string $extensions = []; + public ?string $filenameCharset = null; + /** @var self::FILENAME_COUNT_* */ + public string $filenameCountUnit = self::FILENAME_COUNT_BYTES; + public string $notFoundMessage = 'The file could not be found.'; public string $notReadableMessage = 'The file is not readable.'; public string $maxSizeMessage = 'The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}.'; @@ -60,6 +77,7 @@ class File extends Constraint public string $extensionsMessage = 'The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}.'; public string $disallowEmptyMessage = 'An empty file is not allowed.'; public string $filenameTooLongMessage = 'The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less.'; + public string $filenameCharsetMessage = 'This filename does not match the expected charset.'; public string $uploadIniSizeErrorMessage = 'The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}.'; public string $uploadFormSizeErrorMessage = 'The file is too large.'; @@ -87,6 +105,8 @@ class File extends Constraint * @param string|null $uploadErrorMessage Message if an unknown error occurred on upload * @param string[]|null $groups * @param array|string|null $extensions A list of valid extensions to check. Related media types are also enforced ({@see https://symfony.com/doc/current/reference/constraints/File.html#extensions}) + * @param string|null $filenameCharset The charset to be used when computing filename length (defaults to null) + * @param self::FILENAME_COUNT_*|null $filenameCountUnit The character count unit used for checking the filename length (defaults to {@see File::FILENAME_COUNT_BYTES}) * * @see https://www.iana.org/assignments/media-types/media-types.xhtml Existing media types */ @@ -114,9 +134,11 @@ public function __construct( ?string $uploadErrorMessage = null, ?array $groups = null, mixed $payload = null, - array|string|null $extensions = null, ?string $extensionsMessage = null, + ?string $filenameCharset = null, + ?string $filenameCountUnit = null, + ?string $filenameCharsetMessage = null, ) { if (\is_array($options)) { trigger_deprecation('symfony/validator', '7.3', 'Passing an array of options to configure the "%s" constraint is deprecated, use named arguments instead.', static::class); @@ -128,6 +150,8 @@ public function __construct( $this->binaryFormat = $binaryFormat ?? $this->binaryFormat; $this->mimeTypes = $mimeTypes ?? $this->mimeTypes; $this->filenameMaxLength = $filenameMaxLength ?? $this->filenameMaxLength; + $this->filenameCharset = $filenameCharset ?? $this->filenameCharset; + $this->filenameCountUnit = $filenameCountUnit ?? $this->filenameCountUnit; $this->extensions = $extensions ?? $this->extensions; $this->notFoundMessage = $notFoundMessage ?? $this->notFoundMessage; $this->notReadableMessage = $notReadableMessage ?? $this->notReadableMessage; @@ -136,6 +160,7 @@ public function __construct( $this->extensionsMessage = $extensionsMessage ?? $this->extensionsMessage; $this->disallowEmptyMessage = $disallowEmptyMessage ?? $this->disallowEmptyMessage; $this->filenameTooLongMessage = $filenameTooLongMessage ?? $this->filenameTooLongMessage; + $this->filenameCharsetMessage = $filenameCharsetMessage ?? $this->filenameCharsetMessage; $this->uploadIniSizeErrorMessage = $uploadIniSizeErrorMessage ?? $this->uploadIniSizeErrorMessage; $this->uploadFormSizeErrorMessage = $uploadFormSizeErrorMessage ?? $this->uploadFormSizeErrorMessage; $this->uploadPartialErrorMessage = $uploadPartialErrorMessage ?? $this->uploadPartialErrorMessage; @@ -148,6 +173,10 @@ public function __construct( if (null !== $this->maxSize) { $this->normalizeBinaryFormat($this->maxSize); } + + if (!\in_array($this->filenameCountUnit, self::FILENAME_VALID_COUNT_UNITS, true)) { + throw new InvalidArgumentException(\sprintf('The "filenameCountUnit" option must be one of the "%s::FILENAME_COUNT_*" constants ("%s" given).', __CLASS__, $this->filenameCountUnit)); + } } public function __set(string $option, mixed $value): void diff --git a/src/Symfony/Component/Validator/Constraints/FileValidator.php b/src/Symfony/Component/Validator/Constraints/FileValidator.php index 03c12b91effd3..2b8e334944852 100644 --- a/src/Symfony/Component/Validator/Constraints/FileValidator.php +++ b/src/Symfony/Component/Validator/Constraints/FileValidator.php @@ -137,10 +137,36 @@ public function validate(mixed $value, Constraint $constraint): void return; } - $sizeInBytes = filesize($path); $basename = $value instanceof UploadedFile ? $value->getClientOriginalName() : basename($path); + $filenameCharset = $constraint->filenameCharset ?? (File::FILENAME_COUNT_BYTES !== $constraint->filenameCountUnit ? 'UTF-8' : null); + + if ($invalidFilenameCharset = null !== $filenameCharset) { + try { + $invalidFilenameCharset = !@mb_check_encoding($basename, $constraint->filenameCharset); + } catch (\ValueError $e) { + if (!str_starts_with($e->getMessage(), 'mb_check_encoding(): Argument #2 ($encoding) must be a valid encoding')) { + throw $e; + } + } + } + + $filenameLength = $invalidFilenameCharset ? 0 : match ($constraint->filenameCountUnit) { + File::FILENAME_COUNT_BYTES => \strlen($basename), + File::FILENAME_COUNT_CODEPOINTS => mb_strlen($basename, $filenameCharset), + File::FILENAME_COUNT_GRAPHEMES => grapheme_strlen($basename), + }; + + if ($invalidFilenameCharset || false === ($filenameLength ?? false)) { + $this->context->buildViolation($constraint->filenameCharsetMessage) + ->setParameter('{{ name }}', $this->formatValue($basename)) + ->setParameter('{{ charset }}', $filenameCharset) + ->setCode(File::FILENAME_INVALID_CHARACTERS) + ->addViolation(); + + return; + } - if ($constraint->filenameMaxLength && $constraint->filenameMaxLength < $filenameLength = \strlen($basename)) { + if ($constraint->filenameMaxLength && $constraint->filenameMaxLength < $filenameLength) { $this->context->buildViolation($constraint->filenameTooLongMessage) ->setParameter('{{ filename_max_length }}', $this->formatValue($constraint->filenameMaxLength)) ->setCode(File::FILENAME_TOO_LONG) @@ -150,7 +176,7 @@ public function validate(mixed $value, Constraint $constraint): void return; } - if (0 === $sizeInBytes) { + if (!$sizeInBytes = filesize($path)) { $this->context->buildViolation($constraint->disallowEmptyMessage) ->setParameter('{{ file }}', $this->formatValue($path)) ->setParameter('{{ name }}', $this->formatValue($basename)) diff --git a/src/Symfony/Component/Validator/Constraints/Image.php b/src/Symfony/Component/Validator/Constraints/Image.php index 0f045d9f91f14..59cd6ba883a32 100644 --- a/src/Symfony/Component/Validator/Constraints/Image.php +++ b/src/Symfony/Component/Validator/Constraints/Image.php @@ -165,6 +165,9 @@ public function __construct( ?string $corruptedMessage = null, ?array $groups = null, mixed $payload = null, + ?string $filenameCharset = null, + ?string $filenameCountUnit = null, + ?string $filenameCharsetMessage = null, ) { parent::__construct( $options, @@ -187,7 +190,10 @@ public function __construct( $uploadExtensionErrorMessage, $uploadErrorMessage, $groups, - $payload + $payload, + $filenameCharset, + $filenameCountUnit, + $filenameCharsetMessage, ); $this->minWidth = $minWidth ?? $this->minWidth; diff --git a/src/Symfony/Component/Validator/Tests/Constraints/FileTest.php b/src/Symfony/Component/Validator/Tests/Constraints/FileTest.php index e4e30a5816446..3e03f7881b490 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/FileTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/FileTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\File; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; @@ -79,6 +80,31 @@ public function testMaxSizeCannotBeSetToInvalidValueAfterInitialization($maxSize $this->assertSame(1000, $file->maxSize); } + public function testFilenameMaxLength() + { + $file = new File(filenameMaxLength: 30); + $this->assertSame(30, $file->filenameMaxLength); + } + + public function testDefaultFilenameCountUnitIsUsed() + { + $file = new File(); + self::assertSame(File::FILENAME_COUNT_BYTES, $file->filenameCountUnit); + } + + public function testFilenameCharsetDefaultsToNull() + { + $file = new File(); + self::assertNull($file->filenameCharset); + } + + public function testInvalidFilenameCountUnitThrowsException() + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage(\sprintf('The "filenameCountUnit" option must be one of the "%s::FILENAME_COUNT_*" constants ("%s" given).', File::class, 'nonExistentCountUnit')); + $file = new File(filenameCountUnit: 'nonExistentCountUnit'); + } + /** * @dataProvider provideInValidSizes */ @@ -162,6 +188,9 @@ public function testAttributes() self::assertSame(100000, $cConstraint->maxSize); self::assertSame(['my_group'], $cConstraint->groups); self::assertSame('some attached data', $cConstraint->payload); + self::assertSame(30, $cConstraint->filenameMaxLength); + self::assertSame('ISO-8859-15', $cConstraint->filenameCharset); + self::assertSame(File::FILENAME_COUNT_CODEPOINTS, $cConstraint->filenameCountUnit); } } @@ -173,6 +202,6 @@ class FileDummy #[File(maxSize: 100, notFoundMessage: 'myMessage')] private $b; - #[File(maxSize: '100K', groups: ['my_group'], payload: 'some attached data')] + #[File(maxSize: '100K', filenameMaxLength: 30, filenameCharset: 'ISO-8859-15', filenameCountUnit: File::FILENAME_COUNT_CODEPOINTS, groups: ['my_group'], payload: 'some attached data')] private $c; } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/FileValidatorTestCase.php b/src/Symfony/Component/Validator/Tests/Constraints/FileValidatorTestCase.php index 81e833b275828..b1ebf530e196e 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/FileValidatorTestCase.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/FileValidatorTestCase.php @@ -675,11 +675,11 @@ public function testUploadedFileExtensions() /** * @dataProvider provideFilenameMaxLengthIsTooLong */ - public function testFilenameMaxLengthIsTooLong(File $constraintFile, string $messageViolation) + public function testFilenameMaxLengthIsTooLong(File $constraintFile, string $filename, string $messageViolation) { file_put_contents($this->path, '1'); - $file = new UploadedFile($this->path, 'myFileWithATooLongOriginalFileName', null, null, true); + $file = new UploadedFile($this->path, $filename, null, null, true); $this->validator->validate($file, $constraintFile); $this->buildViolation($messageViolation) @@ -693,26 +693,83 @@ public function testFilenameMaxLengthIsTooLong(File $constraintFile, string $mes public static function provideFilenameMaxLengthIsTooLong(): \Generator { - yield 'Simple case with only the parameter "filenameMaxLength" ' => [ + yield 'Codepoints and UTF-8 : default' => [ new File(filenameMaxLength: 30), + 'myFileWithATooLongOriginalFileName', 'The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less.', ]; - yield 'Case with the parameter "filenameMaxLength" and a custom error message' => [ - new File(filenameMaxLength: 20, filenameTooLongMessage: 'Your filename is too long. Please use at maximum {{ filename_max_length }} characters'), - 'Your filename is too long. Please use at maximum {{ filename_max_length }} characters', + yield 'Codepoints and UTF-8: custom error message' => [ + new File(filenameMaxLength: 20, filenameTooLongMessage: 'myMessage'), + 'myFileWithATooLongOriginalFileName', + 'myMessage', + ]; + + yield 'Graphemes' => [ + new File(filenameMaxLength: 1, filenameCountUnit: File::FILENAME_COUNT_GRAPHEMES, filenameTooLongMessage: 'myMessage'), + "A\u{0300}A\u{0300}", + 'myMessage', + ]; + + yield 'Bytes' => [ + new File(filenameMaxLength: 5, filenameCountUnit: File::FILENAME_COUNT_BYTES, filenameTooLongMessage: 'myMessage'), + "A\u{0300}A\u{0300}", + 'myMessage', ]; } - public function testFilenameMaxLength() + /** + * @dataProvider provideFilenameCountUnit + */ + public function testValidCountUnitFilenameMaxLength(int $maxLength, string $countUnit) { file_put_contents($this->path, '1'); - $file = new UploadedFile($this->path, 'tinyOriginalFileName', null, null, true); - $this->validator->validate($file, new File(filenameMaxLength: 20)); + $file = new UploadedFile($this->path, "A\u{0300}", null, null, true); + $this->validator->validate($file, new File(filenameMaxLength: $maxLength, filenameCountUnit: $countUnit)); $this->assertNoViolation(); } + /** + * @dataProvider provideFilenameCharset + */ + public function testFilenameCharset(string $filename, string $charset, bool $isValid) + { + file_put_contents($this->path, '1'); + + $file = new UploadedFile($this->path, $filename, null, null, true); + $this->validator->validate($file, new File(filenameCharset: $charset, filenameCharsetMessage: 'myMessage')); + + if ($isValid) { + $this->assertNoViolation(); + } else { + $this->buildViolation('myMessage') + ->setParameter('{{ name }}', '"'.$filename.'"') + ->setParameter('{{ charset }}', $charset) + ->setCode(File::FILENAME_INVALID_CHARACTERS) + ->assertRaised(); + } + } + + public static function provideFilenameCountUnit(): array + { + return [ + 'graphemes' => [1, File::FILENAME_COUNT_GRAPHEMES], + 'codepoints' => [2, File::FILENAME_COUNT_CODEPOINTS], + 'bytes' => [3, File::FILENAME_COUNT_BYTES], + ]; + } + + public static function provideFilenameCharset(): array + { + return [ + ['é', 'utf8', true], + ["\xE9", 'CP1252', true], + ["\xE9", 'XXX', false], + ["\xE9", 'utf8', false], + ]; + } + abstract protected function getFile($filename); } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy