From a5cd965494461ba65aaedc78cae596d6804ca764 Mon Sep 17 00:00:00 2001 From: Andrey Sevastianov Date: Fri, 21 Feb 2020 13:17:04 +0200 Subject: [PATCH] [ExpressionLanguage] Added expression language syntax validator --- .../Component/ExpressionLanguage/CHANGELOG.md | 6 ++ .../ExpressionLanguage/ExpressionLanguage.php | 17 ++++ .../Component/ExpressionLanguage/Parser.php | 41 ++++++-- .../ExpressionLanguage/Tests/ParserTest.php | 95 +++++++++++++++++++ src/Symfony/Component/Validator/CHANGELOG.md | 1 + .../Constraints/ExpressionLanguageSyntax.php | 42 ++++++++ .../ExpressionLanguageSyntaxValidator.php | 55 +++++++++++ .../ExpressionLanguageSyntaxTest.php | 88 +++++++++++++++++ src/Symfony/Component/Validator/composer.json | 5 +- 9 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 src/Symfony/Component/Validator/Constraints/ExpressionLanguageSyntax.php create mode 100644 src/Symfony/Component/Validator/Constraints/ExpressionLanguageSyntaxValidator.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/ExpressionLanguageSyntaxTest.php diff --git a/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md b/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md index 6c50b2ea42..f5c1f6de15 100644 --- a/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md +++ b/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.1.0 +----- + + * added `lint` method to `ExpressionLanguage` class + * added `lint` method to `Parser` class + 4.0.0 ----- diff --git a/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php b/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php index e9e36e9f64..b7c5a7ec43 100644 --- a/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php +++ b/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php @@ -97,6 +97,23 @@ class ExpressionLanguage return $parsedExpression; } + /** + * Validates the syntax of an expression. + * + * @param Expression|string $expression The expression to validate + * @param array|null $names The list of acceptable variable names in the expression, or null to accept any names + * + * @throws SyntaxError When the passed expression is invalid + */ + public function lint($expression, ?array $names): void + { + if ($expression instanceof ParsedExpression) { + return; + } + + $this->getParser()->lint($this->getLexer()->tokenize((string) $expression), $names); + } + /** * Registers a function. * diff --git a/src/Symfony/Component/ExpressionLanguage/Parser.php b/src/Symfony/Component/ExpressionLanguage/Parser.php index 4642ea3141..34658b97c0 100644 --- a/src/Symfony/Component/ExpressionLanguage/Parser.php +++ b/src/Symfony/Component/ExpressionLanguage/Parser.php @@ -31,6 +31,7 @@ class Parser private $binaryOperators; private $functions; private $names; + private $lint; public function __construct(array $functions) { @@ -90,6 +91,30 @@ class Parser * @throws SyntaxError */ public function parse(TokenStream $stream, array $names = []) + { + $this->lint = false; + + return $this->doParse($stream, $names); + } + + /** + * Validates the syntax of an expression. + * + * The syntax of the passed expression will be checked, but not parsed. + * If you want to skip checking dynamic variable names, pass `null` instead of the array. + * + * @throws SyntaxError When the passed expression is invalid + */ + public function lint(TokenStream $stream, ?array $names = []): void + { + $this->lint = true; + $this->doParse($stream, $names); + } + + /** + * @throws SyntaxError + */ + private function doParse(TokenStream $stream, ?array $names = []): Node\Node { $this->stream = $stream; $this->names = $names; @@ -197,13 +222,17 @@ class Parser $node = new Node\FunctionNode($token->value, $this->parseArguments()); } else { - if (!\in_array($token->value, $this->names, true)) { - throw new SyntaxError(sprintf('Variable "%s" is not valid.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, $this->names); - } + if (!$this->lint || \is_array($this->names)) { + if (!\in_array($token->value, $this->names, true)) { + throw new SyntaxError(sprintf('Variable "%s" is not valid.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, $this->names); + } - // is the name used in the compiled code different - // from the name used in the expression? - if (\is_int($name = array_search($token->value, $this->names))) { + // is the name used in the compiled code different + // from the name used in the expression? + if (\is_int($name = array_search($token->value, $this->names))) { + $name = $token->value; + } + } else { $name = $token->value; } diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php index 2d5a0a6c8c..99575f9f62 100644 --- a/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php +++ b/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\ExpressionLanguage\Lexer; use Symfony\Component\ExpressionLanguage\Node; use Symfony\Component\ExpressionLanguage\Parser; +use Symfony\Component\ExpressionLanguage\SyntaxError; class ParserTest extends TestCase { @@ -234,4 +235,98 @@ class ParserTest extends TestCase $parser->parse($lexer->tokenize('foo > bar'), ['foo', 'baz']); } + + /** + * @dataProvider getLintData + */ + public function testLint($expression, $names, ?string $exception = null) + { + if ($exception) { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage($exception); + } + + $lexer = new Lexer(); + $parser = new Parser([]); + $parser->lint($lexer->tokenize($expression), $names); + + // Parser does't return anything when the correct expression is passed + $this->expectNotToPerformAssertions(); + } + + public function getLintData(): array + { + return [ + 'valid expression' => [ + 'expression' => 'foo["some_key"].callFunction(a ? b)', + 'names' => ['foo', 'a', 'b'], + ], + 'allow expression without names' => [ + 'expression' => 'foo.bar', + 'names' => null, + ], + 'disallow expression without names' => [ + 'expression' => 'foo.bar', + 'names' => [], + 'exception' => 'Variable "foo" is not valid around position 1 for expression `foo.bar', + ], + 'operator collisions' => [ + 'expression' => 'foo.not in [bar]', + 'names' => ['foo', 'bar'], + ], + 'incorrect expression ending' => [ + 'expression' => 'foo["a"] foo["b"]', + 'names' => ['foo'], + 'exception' => 'Unexpected token "name" of value "foo" '. + 'around position 10 for expression `foo["a"] foo["b"]`.', + ], + 'incorrect operator' => [ + 'expression' => 'foo["some_key"] // 2', + 'names' => ['foo'], + 'exception' => 'Unexpected token "operator" of value "/" '. + 'around position 18 for expression `foo["some_key"] // 2`.', + ], + 'incorrect array' => [ + 'expression' => '[value1, value2 value3]', + 'names' => ['value1', 'value2', 'value3'], + 'exception' => 'An array element must be followed by a comma. '. + 'Unexpected token "name" of value "value3" ("punctuation" expected with value ",") '. + 'around position 17 for expression `[value1, value2 value3]`.', + ], + 'incorrect array element' => [ + 'expression' => 'foo["some_key")', + 'names' => ['foo'], + 'exception' => 'Unclosed "[" around position 3 for expression `foo["some_key")`.', + ], + 'missed array key' => [ + 'expression' => 'foo[]', + 'names' => ['foo'], + 'exception' => 'Unexpected token "punctuation" of value "]" around position 5 for expression `foo[]`.', + ], + 'missed closing bracket in sub expression' => [ + 'expression' => 'foo[(bar ? bar : "default"]', + 'names' => ['foo', 'bar'], + 'exception' => 'Unclosed "(" around position 4 for expression `foo[(bar ? bar : "default"]`.', + ], + 'incorrect hash following' => [ + 'expression' => '{key: foo key2: bar}', + 'names' => ['foo', 'bar'], + 'exception' => 'A hash value must be followed by a comma. '. + 'Unexpected token "name" of value "key2" ("punctuation" expected with value ",") '. + 'around position 11 for expression `{key: foo key2: bar}`.', + ], + 'incorrect hash assign' => [ + 'expression' => '{key => foo}', + 'names' => ['foo'], + 'exception' => 'Unexpected character "=" around position 5 for expression `{key => foo}`.', + ], + 'incorrect array as hash using' => [ + 'expression' => '[foo: foo]', + 'names' => ['foo'], + 'exception' => 'An array element must be followed by a comma. '. + 'Unexpected token "punctuation" of value ":" ("punctuation" expected with value ",") '. + 'around position 5 for expression `[foo: foo]`.', + ], + ]; + } } diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index d2eb00fc42..9921ef6d4b 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * allow to define a reusable set of constraints by extending the `Compound` constraint * added `Sequentially` constraint, to sequentially validate a set of constraints (any violation raised will prevent further validation of the nested constraints) * added the `divisibleBy` option to the `Count` constraint + * added the `ExpressionLanguageSyntax` constraint 5.0.0 ----- diff --git a/src/Symfony/Component/Validator/Constraints/ExpressionLanguageSyntax.php b/src/Symfony/Component/Validator/Constraints/ExpressionLanguageSyntax.php new file mode 100644 index 0000000000..7391554acf --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/ExpressionLanguageSyntax.php @@ -0,0 +1,42 @@ + + * + * 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; + +/** + * @Annotation + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) + * + * @author Andrey Sevastianov + */ +class ExpressionLanguageSyntax extends Constraint +{ + const EXPRESSION_LANGUAGE_SYNTAX_ERROR = '1766a3f3-ff03-40eb-b053-ab7aa23d988a'; + + protected static $errorNames = [ + self::EXPRESSION_LANGUAGE_SYNTAX_ERROR => 'EXPRESSION_LANGUAGE_SYNTAX_ERROR', + ]; + + public $message = 'This value should be a valid expression.'; + public $service; + public $validateNames = true; + public $names = []; + + /** + * {@inheritdoc} + */ + public function validatedBy() + { + return $this->service; + } +} diff --git a/src/Symfony/Component/Validator/Constraints/ExpressionLanguageSyntaxValidator.php b/src/Symfony/Component/Validator/Constraints/ExpressionLanguageSyntaxValidator.php new file mode 100644 index 0000000000..4a02d49a86 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/ExpressionLanguageSyntaxValidator.php @@ -0,0 +1,55 @@ + + * + * 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\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\SyntaxError; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * @author Andrey Sevastianov + */ +class ExpressionLanguageSyntaxValidator extends ConstraintValidator +{ + private $expressionLanguage; + + public function __construct(ExpressionLanguage $expressionLanguage) + { + $this->expressionLanguage = $expressionLanguage; + } + + /** + * {@inheritdoc} + */ + public function validate($expression, Constraint $constraint): void + { + if (!$constraint instanceof ExpressionLanguageSyntax) { + throw new UnexpectedTypeException($constraint, ExpressionLanguageSyntax::class); + } + + if (!\is_string($expression)) { + throw new UnexpectedTypeException($expression, 'string'); + } + + try { + $this->expressionLanguage->lint($expression, ($constraint->validateNames ? ($constraint->names ?? []) : null)); + } catch (SyntaxError $exception) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ syntax_error }}', $this->formatValue($exception->getMessage())) + ->setInvalidValue((string) $expression) + ->setCode(ExpressionLanguageSyntax::EXPRESSION_LANGUAGE_SYNTAX_ERROR) + ->addViolation(); + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ExpressionLanguageSyntaxTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionLanguageSyntaxTest.php new file mode 100644 index 0000000000..dc80288c38 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionLanguageSyntaxTest.php @@ -0,0 +1,88 @@ + + * + * 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\MockObject\MockObject; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\SyntaxError; +use Symfony\Component\Validator\Constraints\ExpressionLanguageSyntax; +use Symfony\Component\Validator\Constraints\ExpressionLanguageSyntaxValidator; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +class ExpressionLanguageSyntaxTest extends ConstraintValidatorTestCase +{ + /** + * @var \PHPUnit\Framework\MockObject\MockObject|ExpressionLanguage + */ + protected $expressionLanguage; + + protected function createValidator() + { + return new ExpressionLanguageSyntaxValidator($this->expressionLanguage); + } + + protected function setUp(): void + { + $this->expressionLanguage = $this->createExpressionLanguage(); + + parent::setUp(); + } + + public function testExpressionValid(): void + { + $this->expressionLanguage->expects($this->once()) + ->method('lint') + ->with($this->value, []); + + $this->validator->validate($this->value, new ExpressionLanguageSyntax([ + 'message' => 'myMessage', + ])); + + $this->assertNoViolation(); + } + + public function testExpressionWithoutNames(): void + { + $this->expressionLanguage->expects($this->once()) + ->method('lint') + ->with($this->value, null); + + $this->validator->validate($this->value, new ExpressionLanguageSyntax([ + 'message' => 'myMessage', + 'validateNames' => false, + ])); + + $this->assertNoViolation(); + } + + public function testExpressionIsNotValid(): void + { + $this->expressionLanguage->expects($this->once()) + ->method('lint') + ->with($this->value, []) + ->willThrowException(new SyntaxError('Test exception', 42)); + + $this->validator->validate($this->value, new ExpressionLanguageSyntax([ + 'message' => 'myMessage', + ])); + + $this->buildViolation('myMessage') + ->setParameter('{{ syntax_error }}', '"Test exception around position 42."') + ->setCode(ExpressionLanguageSyntax::EXPRESSION_LANGUAGE_SYNTAX_ERROR) + ->assertRaised(); + } + + protected function createExpressionLanguage(): MockObject + { + return $this->getMockBuilder('\Symfony\Component\ExpressionLanguage\ExpressionLanguage')->getMock(); + } +} diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index a96e11fcc2..0ed2945d3c 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -30,7 +30,7 @@ "symfony/yaml": "^4.4|^5.0", "symfony/config": "^4.4|^5.0", "symfony/dependency-injection": "^4.4|^5.0", - "symfony/expression-language": "^4.4|^5.0", + "symfony/expression-language": "^5.1", "symfony/cache": "^4.4|^5.0", "symfony/mime": "^4.4|^5.0", "symfony/property-access": "^4.4|^5.0", @@ -44,6 +44,7 @@ "doctrine/lexer": "<1.0.2", "phpunit/phpunit": "<5.4.3", "symfony/dependency-injection": "<4.4", + "symfony/expression-language": "<5.1", "symfony/http-kernel": "<4.4", "symfony/intl": "<4.4", "symfony/translation": "<4.4", @@ -61,7 +62,7 @@ "egulias/email-validator": "Strict (RFC compliant) email validation", "symfony/property-access": "For accessing properties within comparison constraints", "symfony/property-info": "To automatically add NotNull and Type constraints", - "symfony/expression-language": "For using the Expression validator" + "symfony/expression-language": "For using the Expression validator and the ExpressionLanguageSyntax constraints" }, "autoload": { "psr-4": { "Symfony\\Component\\Validator\\": "" },