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); }