Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 46e00d5

Browse filesBrowse 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
Copy full SHA for 46e00d5

File tree

6 files changed

+163
-15
lines changed
Filter options

6 files changed

+163
-15
lines changed

‎src/Symfony/Component/Validator/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/Constraints/File.php
+30-1Lines changed: 30 additions & 1 deletion
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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/Constraints/FileValidator.php
+29-3Lines changed: 29 additions & 3 deletions
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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/Constraints/Image.php
+7-1Lines changed: 7 additions & 1 deletion
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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/Tests/Constraints/FileTest.php
+30-1Lines changed: 30 additions & 1 deletion
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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/Tests/Constraints/FileValidatorTestCase.php
+66-9Lines changed: 66 additions & 9 deletions
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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.