From 9fa70599df2f1faedafef6c03f165026b5836449 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Tue, 2 Jan 2024 17:07:48 +0100 Subject: [PATCH] [Validator] Add the `Base64` constraint --- src/Symfony/Component/Validator/CHANGELOG.md | 1 + .../Validator/Constraints/Base64.php | 40 +++++ .../Validator/Constraints/Base64Validator.php | 63 ++++++++ .../Tests/Constraints/Base64Test.php | 63 ++++++++ .../Tests/Constraints/Base64ValidatorTest.php | 137 ++++++++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 src/Symfony/Component/Validator/Constraints/Base64.php create mode 100644 src/Symfony/Component/Validator/Constraints/Base64Validator.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Base64Test.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Base64ValidatorTest.php diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index cf1358e94de9e..3d4c51e810bea 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add `MacAddress` constraint * Add `list` and `associative_array` types to `Type` constraint * Add the `Charset` constraint + * Add the `Base64` constraint 7.0 --- diff --git a/src/Symfony/Component/Validator/Constraints/Base64.php b/src/Symfony/Component/Validator/Constraints/Base64.php new file mode 100644 index 0000000000000..018ed47d8e5b9 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Base64.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; + +/** + * @author Alexandre Daubois + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class Base64 extends Constraint +{ + public const INVALID_STRING_ERROR = '5699d0ba-3cea-4676-9aab-d3303ebd6934'; + public const MISSING_DATA_URI_ERROR = '9380f49b-582a-49ce-a0b9-e68ff009e80f'; + + protected const ERROR_NAMES = [ + self::INVALID_STRING_ERROR => 'INVALID_STRING_ERROR', + self::MISSING_DATA_URI_ERROR => 'MISSING_DATA_URI_ERROR', + ]; + + public string $messageInvalidString = 'The given string is not a valid Base64 encoded string.'; + public string $messageMissingDataUri = 'The given string is missing a data URI.'; + + public function __construct(public bool $requiresDataUri = false, public bool $urlEncoded = false, string $messageInvalidString = null, string $messageMissingDataUri = null, array $groups = null, mixed $payload = null, array $options = null) + { + parent::__construct($options, $groups, $payload); + + $this->messageInvalidString = $messageInvalidString ?? $this->messageInvalidString; + $this->messageMissingDataUri = $messageMissingDataUri ?? $this->messageMissingDataUri; + } +} diff --git a/src/Symfony/Component/Validator/Constraints/Base64Validator.php b/src/Symfony/Component/Validator/Constraints/Base64Validator.php new file mode 100644 index 0000000000000..e02e3cad09b59 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Base64Validator.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +/** + * @author Alexandre Daubois + */ +final class Base64Validator extends ConstraintValidator +{ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof Base64) { + throw new UnexpectedTypeException($constraint, Base64::class); + } + + if (null === $value) { + return; + } + + if (!\is_string($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException($value, 'string'); + } + + if ($constraint->urlEncoded) { + $value = rawurldecode($value); + } + + if ($constraint->requiresDataUri) { + preg_match('/^data:.+;base64,/', $value, $matches); + + if (0 === \count($matches)) { + $this->context->buildViolation($constraint->messageMissingDataUri) + ->setCode(Base64::MISSING_DATA_URI_ERROR) + ->addViolation(); + + return; + } + + $value = str_replace($matches[0], '', $value); + } + + preg_match('/^[a-zA-Z0-9\/\r\n+]*(==)?$/', $value, $matches); + if (0 === \count($matches)) { + $this->context->buildViolation($constraint->messageInvalidString) + ->setCode(Base64::INVALID_STRING_ERROR) + ->addViolation(); + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Base64Test.php b/src/Symfony/Component/Validator/Tests/Constraints/Base64Test.php new file mode 100644 index 0000000000000..5d9b24f53310f --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Base64Test.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraints\Base64; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +class Base64Test extends TestCase +{ + public function testAllowDataUri() + { + $encoding = new Base64(true); + + $this->assertTrue($encoding->requiresDataUri); + } + + public function testDenyDataUriByDefault() + { + $encoding = new Base64(); + + $this->assertFalse($encoding->requiresDataUri); + } + + public function testAttributes() + { + $metadata = new ClassMetadata(Base64Dummy::class); + $loader = new AttributeLoader(); + $this->assertTrue($loader->loadClassMetadata($metadata)); + + [$aConstraint] = $metadata->properties['a']->getConstraints(); + $this->assertFalse($aConstraint->requiresDataUri); + + [$bConstraint] = $metadata->properties['b']->getConstraints(); + $this->assertTrue($bConstraint->requiresDataUri); + + [$cConstraint] = $metadata->properties['c']->getConstraints(); + $this->assertFalse($cConstraint->requiresDataUri); + $this->assertTrue($cConstraint->urlEncoded); + } +} + +class Base64Dummy +{ + #[Base64] + private string $a; + + #[Base64(true)] + private string $b; + + #[Base64(urlEncoded: true)] + private string $c; +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Base64ValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/Base64ValidatorTest.php new file mode 100644 index 0000000000000..b206baf55ce60 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Base64ValidatorTest.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Symfony\Component\Validator\Constraints\Base64; +use Symfony\Component\Validator\Constraints\Base64Validator; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +class Base64ValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): Base64Validator + { + return new Base64Validator(); + } + + /** + * @dataProvider provideValidValues + */ + public function testValidBase64(string|\Stringable $value) + { + $this->validator->validate($value, new Base64()); + + $this->assertNoViolation(); + } + + /** + * @dataProvider provideUrlEncodedValidValues + */ + public function testUrlEncodedValidBase64(string|\Stringable $value) + { + $this->validator->validate($value, new Base64(urlEncoded: true)); + + $this->assertNoViolation(); + } + + /** + * @dataProvider provideInvalidValues + */ + public function testInvalidValues(string $value) + { + $this->validator->validate($value, new Base64()); + + $this->buildViolation('The given string is not a valid Base64 encoded string.') + ->setCode(Base64::INVALID_STRING_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider provideInvalidTypes + */ + public function testNonStringValues(mixed $value) + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/Expected argument of type "string", ".*" given/'); + + $this->validator->validate($value, new Base64()); + } + + public function testItRequiresDataUri() + { + $this->validator->validate('data:image/png;base64,dGVzdA==', new Base64(true)); + + $this->assertNoViolation(); + } + + public function testItRequiresDataUriButNoneIsGiven() + { + $this->validator->validate('dGVzdA==', new Base64(true)); + + $this->buildViolation('The given string is missing a data URI.') + ->setCode(Base64::MISSING_DATA_URI_ERROR) + ->assertRaised(); + } + + public function testItDoesntRequireDataUriButOneIsGiven() + { + $this->validator->validate('data:image/png;base64,dGVzdA==', new Base64()); + + $this->buildViolation('The given string is not a valid Base64 encoded string.') + ->setCode(Base64::INVALID_STRING_ERROR) + ->assertRaised(); + } + + public static function provideValidValues() + { + yield ['']; + yield ['dGVzdA==']; + yield ['dGVzdA']; + yield [new class() implements \Stringable { + public function __toString(): string + { + return 'dGVzdA=='; + } + }]; + } + + public static function provideUrlEncodedValidValues() + { + yield ['dGVzd%2B%2FA%3D%3D']; + yield ['dGVzdA%3D%3D']; + yield ['dGVzdA']; + yield [new class() implements \Stringable { + public function __toString(): string + { + return 'dGVzdA%3D%3D'; + } + }]; + } + + public static function provideInvalidValues() + { + yield 'missing equal sign' => ['dGVzdA=']; + yield 'illegal chars' => ['é"']; + yield 'emoji' => ['😊']; + yield 'url encoded' => ['dGVzdA%3D%3D']; + } + + public static function provideInvalidTypes() + { + yield [true]; + yield [false]; + yield [1]; + yield [1.1]; + yield [[]]; + yield [new \stdClass()]; + } +}