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

Skip to content

[Validator] Add filenameCharset and filenameCountUnit options to File constraint #58485

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion src/Symfony/Component/Validator/Constraints/File.php
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand All @@ -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',
Expand All @@ -47,19 +59,25 @@ 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 }}.';
public string $mimeTypesMessage = 'The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}.';
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.';
Expand Down Expand Up @@ -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|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})
* @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
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down
32 changes: 29 additions & 3 deletions src/Symfony/Component/Validator/Constraints/FileValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand Down
8 changes: 7 additions & 1 deletion src/Symfony/Component/Validator/Constraints/Image.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -187,7 +190,10 @@ public function __construct(
$uploadExtensionErrorMessage,
$uploadErrorMessage,
$groups,
$payload
$payload,
$filenameCharset,
$filenameCountUnit,
$filenameCharsetMessage,
);

$this->minWidth = $minWidth ?? $this->minWidth;
Expand Down
31 changes: 30 additions & 1 deletion src/Symfony/Component/Validator/Tests/Constraints/FileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
}
Loading