Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 46e00d5

Browse files
committed
feature #58485 [Validator] Add filenameCharset and filenameCountUnit options to File constraint (IssamRaouf)
This PR was merged into the 7.3 branch. Discussion ---------- [Validator] Add `filenameCharset` and `filenameCountUnit` options to `File` constraint | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #58482 | License | MIT Commits ------- abee6ae [Validator] Add `filenameCharset` and `filenameCountUnit` options to `File` constraint
2 parents cb93a4f + abee6ae commit 46e00d5

File tree

6 files changed

+163
-15
lines changed

6 files changed

+163
-15
lines changed

src/Symfony/Component/Validator/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.3
55
---
66

7+
* Add the `filenameCharset` and `filenameCountUnit` options to the `File` constraint
78
* Deprecate defining custom constraints not supporting named arguments
89
* Deprecate passing an array of options to the constructors of the constraint classes, pass each option as a dedicated argument instead
910
* Add support for ratio checks for SVG files to the `Image` constraint

src/Symfony/Component/Validator/Constraints/File.php

+30-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\Validator\Attribute\HasNamedArguments;
1515
use Symfony\Component\Validator\Constraint;
1616
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
17+
use Symfony\Component\Validator\Exception\InvalidArgumentException;
1718

1819
/**
1920
* Validates that a value is a valid "file".
@@ -38,6 +39,17 @@ class File extends Constraint
3839
public const INVALID_MIME_TYPE_ERROR = '744f00bc-4389-4c74-92de-9a43cde55534';
3940
public const INVALID_EXTENSION_ERROR = 'c8c7315c-6186-4719-8b71-5659e16bdcb7';
4041
public const FILENAME_TOO_LONG = 'e5706483-91a8-49d8-9a59-5e81a3c634a8';
42+
public const FILENAME_INVALID_CHARACTERS = '04ee58e1-42b4-45c7-8423-8a4a145fedd9';
43+
44+
public const FILENAME_COUNT_BYTES = 'bytes';
45+
public const FILENAME_COUNT_CODEPOINTS = 'codepoints';
46+
public const FILENAME_COUNT_GRAPHEMES = 'graphemes';
47+
48+
private const FILENAME_VALID_COUNT_UNITS = [
49+
self::FILENAME_COUNT_BYTES,
50+
self::FILENAME_COUNT_CODEPOINTS,
51+
self::FILENAME_COUNT_GRAPHEMES,
52+
];
4153

4254
protected const ERROR_NAMES = [
4355
self::NOT_FOUND_ERROR => 'NOT_FOUND_ERROR',
@@ -47,19 +59,25 @@ class File extends Constraint
4759
self::INVALID_MIME_TYPE_ERROR => 'INVALID_MIME_TYPE_ERROR',
4860
self::INVALID_EXTENSION_ERROR => 'INVALID_EXTENSION_ERROR',
4961
self::FILENAME_TOO_LONG => 'FILENAME_TOO_LONG',
62+
self::FILENAME_INVALID_CHARACTERS => 'FILENAME_INVALID_CHARACTERS',
5063
];
5164

5265
public ?bool $binaryFormat = null;
5366
public array|string $mimeTypes = [];
5467
public ?int $filenameMaxLength = null;
5568
public array|string $extensions = [];
69+
public ?string $filenameCharset = null;
70+
/** @var self::FILENAME_COUNT_* */
71+
public string $filenameCountUnit = self::FILENAME_COUNT_BYTES;
72+
5673
public string $notFoundMessage = 'The file could not be found.';
5774
public string $notReadableMessage = 'The file is not readable.';
5875
public string $maxSizeMessage = 'The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}.';
5976
public string $mimeTypesMessage = 'The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}.';
6077
public string $extensionsMessage = 'The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}.';
6178
public string $disallowEmptyMessage = 'An empty file is not allowed.';
6279
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.';
80+
public string $filenameCharsetMessage = 'This filename does not match the expected charset.';
6381

6482
public string $uploadIniSizeErrorMessage = 'The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}.';
6583
public string $uploadFormSizeErrorMessage = 'The file is too large.';
@@ -87,6 +105,8 @@ class File extends Constraint
87105
* @param string|null $uploadErrorMessage Message if an unknown error occurred on upload
88106
* @param string[]|null $groups
89107
* @param array<string|string[]>|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})
108+
* @param string|null $filenameCharset The charset to be used when computing filename length (defaults to null)
109+
* @param self::FILENAME_COUNT_*|null $filenameCountUnit The character count unit used for checking the filename length (defaults to {@see File::FILENAME_COUNT_BYTES})
90110
*
91111
* @see https://www.iana.org/assignments/media-types/media-types.xhtml Existing media types
92112
*/
@@ -114,9 +134,11 @@ public function __construct(
114134
?string $uploadErrorMessage = null,
115135
?array $groups = null,
116136
mixed $payload = null,
117-
118137
array|string|null $extensions = null,
119138
?string $extensionsMessage = null,
139+
?string $filenameCharset = null,
140+
?string $filenameCountUnit = null,
141+
?string $filenameCharsetMessage = null,
120142
) {
121143
if (\is_array($options)) {
122144
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(
128150
$this->binaryFormat = $binaryFormat ?? $this->binaryFormat;
129151
$this->mimeTypes = $mimeTypes ?? $this->mimeTypes;
130152
$this->filenameMaxLength = $filenameMaxLength ?? $this->filenameMaxLength;
153+
$this->filenameCharset = $filenameCharset ?? $this->filenameCharset;
154+
$this->filenameCountUnit = $filenameCountUnit ?? $this->filenameCountUnit;
131155
$this->extensions = $extensions ?? $this->extensions;
132156
$this->notFoundMessage = $notFoundMessage ?? $this->notFoundMessage;
133157
$this->notReadableMessage = $notReadableMessage ?? $this->notReadableMessage;
@@ -136,6 +160,7 @@ public function __construct(
136160
$this->extensionsMessage = $extensionsMessage ?? $this->extensionsMessage;
137161
$this->disallowEmptyMessage = $disallowEmptyMessage ?? $this->disallowEmptyMessage;
138162
$this->filenameTooLongMessage = $filenameTooLongMessage ?? $this->filenameTooLongMessage;
163+
$this->filenameCharsetMessage = $filenameCharsetMessage ?? $this->filenameCharsetMessage;
139164
$this->uploadIniSizeErrorMessage = $uploadIniSizeErrorMessage ?? $this->uploadIniSizeErrorMessage;
140165
$this->uploadFormSizeErrorMessage = $uploadFormSizeErrorMessage ?? $this->uploadFormSizeErrorMessage;
141166
$this->uploadPartialErrorMessage = $uploadPartialErrorMessage ?? $this->uploadPartialErrorMessage;
@@ -148,6 +173,10 @@ public function __construct(
148173
if (null !== $this->maxSize) {
149174
$this->normalizeBinaryFormat($this->maxSize);
150175
}
176+
177+
if (!\in_array($this->filenameCountUnit, self::FILENAME_VALID_COUNT_UNITS, true)) {
178+
throw new InvalidArgumentException(\sprintf('The "filenameCountUnit" option must be one of the "%s::FILENAME_COUNT_*" constants ("%s" given).', __CLASS__, $this->filenameCountUnit));
179+
}
151180
}
152181

153182
public function __set(string $option, mixed $value): void

src/Symfony/Component/Validator/Constraints/FileValidator.php

+29-3
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,36 @@ public function validate(mixed $value, Constraint $constraint): void
137137
return;
138138
}
139139

140-
$sizeInBytes = filesize($path);
141140
$basename = $value instanceof UploadedFile ? $value->getClientOriginalName() : basename($path);
141+
$filenameCharset = $constraint->filenameCharset ?? (File::FILENAME_COUNT_BYTES !== $constraint->filenameCountUnit ? 'UTF-8' : null);
142+
143+
if ($invalidFilenameCharset = null !== $filenameCharset) {
144+
try {
145+
$invalidFilenameCharset = !@mb_check_encoding($basename, $constraint->filenameCharset);
146+
} catch (\ValueError $e) {
147+
if (!str_starts_with($e->getMessage(), 'mb_check_encoding(): Argument #2 ($encoding) must be a valid encoding')) {
148+
throw $e;
149+
}
150+
}
151+
}
152+
153+
$filenameLength = $invalidFilenameCharset ? 0 : match ($constraint->filenameCountUnit) {
154+
File::FILENAME_COUNT_BYTES => \strlen($basename),
155+
File::FILENAME_COUNT_CODEPOINTS => mb_strlen($basename, $filenameCharset),
156+
File::FILENAME_COUNT_GRAPHEMES => grapheme_strlen($basename),
157+
};
158+
159+
if ($invalidFilenameCharset || false === ($filenameLength ?? false)) {
160+
$this->context->buildViolation($constraint->filenameCharsetMessage)
161+
->setParameter('{{ name }}', $this->formatValue($basename))
162+
->setParameter('{{ charset }}', $filenameCharset)
163+
->setCode(File::FILENAME_INVALID_CHARACTERS)
164+
->addViolation();
165+
166+
return;
167+
}
142168

143-
if ($constraint->filenameMaxLength && $constraint->filenameMaxLength < $filenameLength = \strlen($basename)) {
169+
if ($constraint->filenameMaxLength && $constraint->filenameMaxLength < $filenameLength) {
144170
$this->context->buildViolation($constraint->filenameTooLongMessage)
145171
->setParameter('{{ filename_max_length }}', $this->formatValue($constraint->filenameMaxLength))
146172
->setCode(File::FILENAME_TOO_LONG)
@@ -150,7 +176,7 @@ public function validate(mixed $value, Constraint $constraint): void
150176
return;
151177
}
152178

153-
if (0 === $sizeInBytes) {
179+
if (!$sizeInBytes = filesize($path)) {
154180
$this->context->buildViolation($constraint->disallowEmptyMessage)
155181
->setParameter('{{ file }}', $this->formatValue($path))
156182
->setParameter('{{ name }}', $this->formatValue($basename))

src/Symfony/Component/Validator/Constraints/Image.php

+7-1
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ public function __construct(
165165
?string $corruptedMessage = null,
166166
?array $groups = null,
167167
mixed $payload = null,
168+
?string $filenameCharset = null,
169+
?string $filenameCountUnit = null,
170+
?string $filenameCharsetMessage = null,
168171
) {
169172
parent::__construct(
170173
$options,
@@ -187,7 +190,10 @@ public function __construct(
187190
$uploadExtensionErrorMessage,
188191
$uploadErrorMessage,
189192
$groups,
190-
$payload
193+
$payload,
194+
$filenameCharset,
195+
$filenameCountUnit,
196+
$filenameCharsetMessage,
191197
);
192198

193199
$this->minWidth = $minWidth ?? $this->minWidth;

src/Symfony/Component/Validator/Tests/Constraints/FileTest.php

+30-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\Validator\Constraints\File;
1616
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
17+
use Symfony\Component\Validator\Exception\InvalidArgumentException;
1718
use Symfony\Component\Validator\Mapping\ClassMetadata;
1819
use Symfony\Component\Validator\Mapping\Loader\AttributeLoader;
1920

@@ -79,6 +80,31 @@ public function testMaxSizeCannotBeSetToInvalidValueAfterInitialization($maxSize
7980
$this->assertSame(1000, $file->maxSize);
8081
}
8182

83+
public function testFilenameMaxLength()
84+
{
85+
$file = new File(filenameMaxLength: 30);
86+
$this->assertSame(30, $file->filenameMaxLength);
87+
}
88+
89+
public function testDefaultFilenameCountUnitIsUsed()
90+
{
91+
$file = new File();
92+
self::assertSame(File::FILENAME_COUNT_BYTES, $file->filenameCountUnit);
93+
}
94+
95+
public function testFilenameCharsetDefaultsToNull()
96+
{
97+
$file = new File();
98+
self::assertNull($file->filenameCharset);
99+
}
100+
101+
public function testInvalidFilenameCountUnitThrowsException()
102+
{
103+
self::expectException(InvalidArgumentException::class);
104+
self::expectExceptionMessage(\sprintf('The "filenameCountUnit" option must be one of the "%s::FILENAME_COUNT_*" constants ("%s" given).', File::class, 'nonExistentCountUnit'));
105+
$file = new File(filenameCountUnit: 'nonExistentCountUnit');
106+
}
107+
82108
/**
83109
* @dataProvider provideInValidSizes
84110
*/
@@ -162,6 +188,9 @@ public function testAttributes()
162188
self::assertSame(100000, $cConstraint->maxSize);
163189
self::assertSame(['my_group'], $cConstraint->groups);
164190
self::assertSame('some attached data', $cConstraint->payload);
191+
self::assertSame(30, $cConstraint->filenameMaxLength);
192+
self::assertSame('ISO-8859-15', $cConstraint->filenameCharset);
193+
self::assertSame(File::FILENAME_COUNT_CODEPOINTS, $cConstraint->filenameCountUnit);
165194
}
166195
}
167196

@@ -173,6 +202,6 @@ class FileDummy
173202
#[File(maxSize: 100, notFoundMessage: 'myMessage')]
174203
private $b;
175204

176-
#[File(maxSize: '100K', groups: ['my_group'], payload: 'some attached data')]
205+
#[File(maxSize: '100K', filenameMaxLength: 30, filenameCharset: 'ISO-8859-15', filenameCountUnit: File::FILENAME_COUNT_CODEPOINTS, groups: ['my_group'], payload: 'some attached data')]
177206
private $c;
178207
}

src/Symfony/Component/Validator/Tests/Constraints/FileValidatorTestCase.php

+66-9
Original file line numberDiff line numberDiff line change
@@ -675,11 +675,11 @@ public function testUploadedFileExtensions()
675675
/**
676676
* @dataProvider provideFilenameMaxLengthIsTooLong
677677
*/
678-
public function testFilenameMaxLengthIsTooLong(File $constraintFile, string $messageViolation)
678+
public function testFilenameMaxLengthIsTooLong(File $constraintFile, string $filename, string $messageViolation)
679679
{
680680
file_put_contents($this->path, '1');
681681

682-
$file = new UploadedFile($this->path, 'myFileWithATooLongOriginalFileName', null, null, true);
682+
$file = new UploadedFile($this->path, $filename, null, null, true);
683683
$this->validator->validate($file, $constraintFile);
684684

685685
$this->buildViolation($messageViolation)
@@ -693,26 +693,83 @@ public function testFilenameMaxLengthIsTooLong(File $constraintFile, string $mes
693693

694694
public static function provideFilenameMaxLengthIsTooLong(): \Generator
695695
{
696-
yield 'Simple case with only the parameter "filenameMaxLength" ' => [
696+
yield 'Codepoints and UTF-8 : default' => [
697697
new File(filenameMaxLength: 30),
698+
'myFileWithATooLongOriginalFileName',
698699
'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.',
699700
];
700701

701-
yield 'Case with the parameter "filenameMaxLength" and a custom error message' => [
702-
new File(filenameMaxLength: 20, filenameTooLongMessage: 'Your filename is too long. Please use at maximum {{ filename_max_length }} characters'),
703-
'Your filename is too long. Please use at maximum {{ filename_max_length }} characters',
702+
yield 'Codepoints and UTF-8: custom error message' => [
703+
new File(filenameMaxLength: 20, filenameTooLongMessage: 'myMessage'),
704+
'myFileWithATooLongOriginalFileName',
705+
'myMessage',
706+
];
707+
708+
yield 'Graphemes' => [
709+
new File(filenameMaxLength: 1, filenameCountUnit: File::FILENAME_COUNT_GRAPHEMES, filenameTooLongMessage: 'myMessage'),
710+
"A\u{0300}A\u{0300}",
711+
'myMessage',
712+
];
713+
714+
yield 'Bytes' => [
715+
new File(filenameMaxLength: 5, filenameCountUnit: File::FILENAME_COUNT_BYTES, filenameTooLongMessage: 'myMessage'),
716+
"A\u{0300}A\u{0300}",
717+
'myMessage',
704718
];
705719
}
706720

707-
public function testFilenameMaxLength()
721+
/**
722+
* @dataProvider provideFilenameCountUnit
723+
*/
724+
public function testValidCountUnitFilenameMaxLength(int $maxLength, string $countUnit)
708725
{
709726
file_put_contents($this->path, '1');
710727

711-
$file = new UploadedFile($this->path, 'tinyOriginalFileName', null, null, true);
712-
$this->validator->validate($file, new File(filenameMaxLength: 20));
728+
$file = new UploadedFile($this->path, "A\u{0300}", null, null, true);
729+
$this->validator->validate($file, new File(filenameMaxLength: $maxLength, filenameCountUnit: $countUnit));
713730

714731
$this->assertNoViolation();
715732
}
716733

734+
/**
735+
* @dataProvider provideFilenameCharset
736+
*/
737+
public function testFilenameCharset(string $filename, string $charset, bool $isValid)
738+
{
739+
file_put_contents($this->path, '1');
740+
741+
$file = new UploadedFile($this->path, $filename, null, null, true);
742+
$this->validator->validate($file, new File(filenameCharset: $charset, filenameCharsetMessage: 'myMessage'));
743+
744+
if ($isValid) {
745+
$this->assertNoViolation();
746+
} else {
747+
$this->buildViolation('myMessage')
748+
->setParameter('{{ name }}', '"'.$filename.'"')
749+
->setParameter('{{ charset }}', $charset)
750+
->setCode(File::FILENAME_INVALID_CHARACTERS)
751+
->assertRaised();
752+
}
753+
}
754+
755+
public static function provideFilenameCountUnit(): array
756+
{
757+
return [
758+
'graphemes' => [1, File::FILENAME_COUNT_GRAPHEMES],
759+
'codepoints' => [2, File::FILENAME_COUNT_CODEPOINTS],
760+
'bytes' => [3, File::FILENAME_COUNT_BYTES],
761+
];
762+
}
763+
764+
public static function provideFilenameCharset(): array
765+
{
766+
return [
767+
['é', 'utf8', true],
768+
["\xE9", 'CP1252', true],
769+
["\xE9", 'XXX', false],
770+
["\xE9", 'utf8', false],
771+
];
772+
}
773+
717774
abstract protected function getFile($filename);
718775
}

0 commit comments

Comments
 (0)