diff --git a/lib/PhpParser/ConstExprEvaluator.php b/lib/PhpParser/ConstExprEvaluator.php index 9526787142..b10578088f 100644 --- a/lib/PhpParser/ConstExprEvaluator.php +++ b/lib/PhpParser/ConstExprEvaluator.php @@ -17,8 +17,6 @@ * following node types: * * * All Scalar\MagicConst\* nodes. - * * Expr\ConstFetch nodes. Only null/false/true are already handled by this class. - * * Expr\ClassConstFetch nodes. * * The fallback evaluator should throw ConstExprEvaluationException for nodes it cannot evaluate. * @@ -28,7 +26,7 @@ */ class ConstExprEvaluator { /** @var callable|null */ - private $fallbackEvaluator; + protected $fallbackEvaluator; /** * Create a constant expression evaluator. @@ -103,7 +101,7 @@ public function evaluateDirectly(Expr $expr) { } /** @return mixed */ - private function evaluate(Expr $expr) { + protected function evaluate(Expr $expr) { if ($expr instanceof Scalar\Int_ || $expr instanceof Scalar\Float_ || $expr instanceof Scalar\String_ @@ -145,6 +143,14 @@ private function evaluate(Expr $expr) { return $this->evaluateConstFetch($expr); } + if ($expr instanceof Expr\ClassConstFetch) { + return $this->evaluateClassConstFetch($expr); + } + + if ($expr instanceof Expr\Cast) { + return $this->evaluateCast($expr); + } + return ($this->fallbackEvaluator)($expr); } @@ -225,11 +231,77 @@ private function evaluateBinaryOp(Expr\BinaryOp $expr) { /** @return mixed */ private function evaluateConstFetch(Expr\ConstFetch $expr) { - $name = $expr->name->toLowerString(); - switch ($name) { - case 'null': return null; - case 'false': return false; - case 'true': return true; + try { + $name = $expr->name->name; + + if (defined($name)) { + return constant($name); + } + } catch (\Throwable $t) { + } + + return ($this->fallbackEvaluator)($expr); + } + + /** @return mixed */ + private function evaluateClassConstFetch(Expr\ClassConstFetch $expr) { + try { + $classname = $expr->class->name; + $property = $expr->name->name; + + if ('class' === $property) { + return $classname; + } + + if (class_exists($classname)) { + $class = new \ReflectionClass($classname); + if (array_key_exists($property, $class->getConstants())) { + $oReflectionConstant = $class->getReflectionConstant($property); + if ($oReflectionConstant->isPublic()) { + return $class->getConstant($property); + } + } + } + } catch (\Throwable $t) { + } + + return ($this->fallbackEvaluator)($expr); + } + + /** @return mixed */ + private function evaluateCast(Expr\Cast $expr) { + try { + $subexpr = $this->evaluate($expr->expr); + $type = get_class($expr); + switch ($type) { + case Expr\Cast\Array_::class: + return (array) $subexpr; + + case Expr\Cast\Bool_::class: + return (bool) $subexpr; + + case Expr\Cast\Double::class: + switch ($expr->getAttribute("kind")) { + case Expr\Cast\Double::KIND_DOUBLE: + return (float) $subexpr; + + case Expr\Cast\Double::KIND_FLOAT: + case Expr\Cast\Double::KIND_REAL: + return (float) $subexpr; + } + + break; + + case Expr\Cast\Int_::class: + return (int) $subexpr; + + case Expr\Cast\Object_::class: + return (object) $subexpr; + + case Expr\Cast\String_::class: + return (string) $subexpr; + } + } catch (\Throwable $t) { } return ($this->fallbackEvaluator)($expr); diff --git a/lib/PhpParser/ExprEvaluationException.php b/lib/PhpParser/ExprEvaluationException.php new file mode 100644 index 0000000000..25a6290bd1 --- /dev/null +++ b/lib/PhpParser/ExprEvaluationException.php @@ -0,0 +1,6 @@ + */ + private array $functionsWhiteList = []; + + /** @var array */ + private array $staticCallsWhitelist = []; + + /** + * Create a constant expression evaluator. + * + * The provided fallback evaluator is invoked whenever a subexpression cannot be evaluated. See + * class doc comment for more information. + * + * @param callable|null $fallbackEvaluator To call if subexpression cannot be evaluated + */ + public function __construct(?callable $fallbackEvaluator = null) { + parent::__construct($fallbackEvaluator); + + $this->fallbackEvaluator = $fallbackEvaluator ?? function (Expr $expr) { + throw new ExprEvaluationException( + "Expression of type {$expr->getType()} cannot be evaluated" + ); + }; + } + + /** + * @param array $functionsWhiteList + */ + public function setFunctionsWhitelist(array $functionsWhiteList): void { + $this->functionsWhiteList = $functionsWhiteList; + } + + /** + * @param array $staticCallsWhitelist + */ + public function setStaticCallsWhitelist(array $staticCallsWhitelist): void { + $this->staticCallsWhitelist = $staticCallsWhitelist; + } + + /** + * Silently evaluates a constant expression into a PHP value. + * + * Thrown Errors, warnings or notices will be converted into a ConstExprEvaluationException. + * The original source of the exception is available through getPrevious(). + * + * If some part of the expression cannot be evaluated, the fallback evaluator passed to the + * constructor will be invoked. By default, if no fallback is provided, an exception of type + * ConstExprEvaluationException is thrown. + * + * See class doc comment for caveats and limitations. + * + * @param Expr $expr Constant expression to evaluate + * + * @return mixed Result of evaluation + * + * @throws ExprEvaluationException if the expression cannot be evaluated or an error occurred + */ + public function evaluateSilently(Expr $expr) { + set_error_handler(function ($num, $str, $file, $line) { + throw new \ErrorException($str, 0, $num, $file, $line); + }); + + try { + return $this->evaluate($expr); + } catch (\Throwable $e) { + if (!$e instanceof ExprEvaluationException) { + $e = new ExprEvaluationException( + "An error occurred during expression evaluation", 0, $e); + } + throw $e; + } finally { + restore_error_handler(); + } + } + + /** + * Directly evaluates a constant expression into a PHP value. + * + * May generate Error exceptions, warnings or notices. Use evaluateSilently() to convert these + * into a ConstExprEvaluationException. + * + * If some part of the expression cannot be evaluated, the fallback evaluator passed to the + * constructor will be invoked. By default, if no fallback is provided, an exception of type + * ConstExprEvaluationException is thrown. + * + * See class doc comment for caveats and limitations. + * + * @param Expr $expr Constant expression to evaluate + * + * @return mixed Result of evaluation + * + * @throws ExprEvaluationException if the expression cannot be evaluated + */ + public function evaluateDirectly(Expr $expr) { + return $this->evaluate($expr); + } + + /** @return mixed */ + protected function evaluate(Expr $expr) { + try { + return parent::evaluate($expr); + } catch (\Throwable $t) { + } + + if ($expr instanceof Expr\Variable) { + return $this->evaluateVariable($expr); + } + + if ($expr instanceof Expr\BinaryOp\Coalesce) { + try { + $var = $this->evaluate($expr->left); + } catch (\Throwable $t) { + //left expression cannot be evaluated (! isset for exeample) + return $this->evaluate($expr->right); + } + + return $var ?? $this->evaluate($expr->right); + } + + if ($expr instanceof Expr\Isset_) { + return $this->evaluateIsset($expr); + } + + if ($expr instanceof Expr\StaticPropertyFetch) { + return $this->evaluateStaticPropertyFetch($expr); + } + + if ($expr instanceof Expr\FuncCall) { + return $this->evaluateFuncCall($expr); + } + + if ($expr instanceof Expr\StaticCall) { + return $this->evaluateStaticCall($expr); + } + + if ($expr instanceof Expr\NullsafePropertyFetch || $expr instanceof Expr\PropertyFetch) { + return $this->evaluatePropertyFetch($expr); + } + + if ($expr instanceof Expr\NullsafeMethodCall || $expr instanceof Expr\MethodCall) { + return $this->evaluateMethodCall($expr); + } + + return ($this->fallbackEvaluator)($expr); + } + + /** @return bool */ + private function evaluateIsset(Expr\Isset_ $expr) { + try { + foreach ($expr->vars as $var) { + $var = $this->evaluate($var); + if (! isset($var)) { + return false; + } + } + + return true; + } catch (\Throwable $t) { + return false; + } + } + + /** @return mixed */ + private function evaluateStaticPropertyFetch(Expr\StaticPropertyFetch $expr) { + try { + $classname = $expr->class->name; + if ($expr->name instanceof Identifier) { + $property = $expr->name->name; + } else { + $property = $this->evaluate($expr->name); + } + + if (class_exists($classname)) { + $class = new \ReflectionClass($classname); + if (array_key_exists($property, $class->getStaticProperties())) { + $oReflectionProperty = $class->getProperty($property); + if ($oReflectionProperty->isPublic()) { + return $class->getStaticPropertyValue($property); + } + } + } + } catch (\Throwable $t) { + } + + return ($this->fallbackEvaluator)($expr); + } + + /** @return mixed */ + private function evaluateFuncCall(Expr\FuncCall $expr) { + try { + $name = $expr->name; + if ($name instanceof Name) { + $function = $name->name; + } else { + $function = $this->evaluate($name); + } + + if (! in_array($function, $this->functionsWhiteList)) { + throw new Exception("FuncCall $function not supported"); + } + + $args = []; + foreach ($expr->args as $arg) { + /** @var \PhpParser\Node\Arg $arg */ + $args[] = $this->evaluate($arg->value); + } + + $reflection_function = new \ReflectionFunction($function); + return $reflection_function->invoke(...$args); + } catch (\Throwable $t) { + } + + return ($this->fallbackEvaluator)($expr); + } + + /** @return mixed */ + private function evaluateVariable(Expr\Variable $expr) { + try { + $name = $expr->name; + if (array_key_exists($name, get_defined_vars())) { + return $$name; + } + + if (array_key_exists($name, $GLOBALS)) { + global $$name; + return $$name; + } + } catch (\Throwable $t) { + } + + return ($this->fallbackEvaluator)($expr); + } + + /** @return mixed */ + private function evaluateStaticCall(Expr\StaticCall $expr) { + try { + $class = $expr->class->name; + if ($expr->name instanceof Identifier) { + $method = $expr->name->name; + } else { + $method = $this->evaluate($expr->name); + } + + $static_call_description = "$class::$method"; + if (! in_array($static_call_description, $this->staticCallsWhitelist)) { + throw new Exception("StaticCall $static_call_description not supported"); + } + + $args = []; + foreach ($expr->args as $arg) { + /** @var \PhpParser\Node\Arg $arg */ + $args[] = $this->evaluate($arg->value); + } + + $class = new \ReflectionClass($class); + $method = $class->getMethod($method); + if ($method->isPublic()) { + return $method->invokeArgs(null, $args); + } + } catch (\Throwable $t) { + } + + return ($this->fallbackEvaluator)($expr); + } + + /** + * @param \PhpParser\Node\Expr\NullsafePropertyFetch|\PhpParser\Node\Expr\PropertyFetch $expr + * + * @return mixed + */ + private function evaluatePropertyFetch($expr) { + try { + $var = $this->evaluate($expr->var); + } catch (\Throwable $t) { + $var = null; + } + + if (! is_null($var)) { + try { + if ($expr->name instanceof Identifier) { + $name = $expr->name->name; + } else { + $name = $this->evaluate($expr->name); + } + + $reflectionClass = new \ReflectionClass(get_class($var)); + $property = $reflectionClass->getProperty($name); + if ($property->isPublic()) { + return $property->getValue($var); + } + } catch (\Throwable $t) { + } + } elseif ($expr instanceof Expr\NullsafePropertyFetch) { + return null; + } + + return ($this->fallbackEvaluator)($expr); + } + + /** + * @param \PhpParser\Node\Expr\MethodCall|\PhpParser\Node\Expr\NullsafeMethodCall $expr + * + * @return mixed + */ + private function evaluateMethodCall($expr) { + try { + $var = $this->evaluate($expr->var); + } catch (\Throwable $t) { + $var = null; + } + + if (! is_null($var)) { + try { + $args = []; + foreach ($expr->args as $arg) { + /** @var \PhpParser\Node\Arg $arg */ + $args[] = $this->evaluate($arg->value); + } + + if ($expr->name instanceof Identifier) { + $name = $expr->name->name; + } else { + $name = $this->evaluate($expr->name); + } + + $reflectionClass = new \ReflectionClass(get_class($var)); + $method = $reflectionClass->getMethod($name); + if ($method->isPublic()) { + return $method->invokeArgs($var, $args); + } + } catch (\Throwable $t) { + } + } elseif ($expr instanceof Expr\NullsafeMethodCall) { + return null; + } + + return ($this->fallbackEvaluator)($expr); + } +} diff --git a/test/PhpParser/ConstExprEvaluatorTest.php b/test/PhpParser/ConstExprEvaluatorTest.php index 513918e56d..72d5a5c0b8 100644 --- a/test/PhpParser/ConstExprEvaluatorTest.php +++ b/test/PhpParser/ConstExprEvaluatorTest.php @@ -26,7 +26,9 @@ public static function provideTestEvaluate() { ['["a", "b" => "b", ...["b" => "bb", "c"]]', ["a", "b" => "bb", "c"]], ['NULL', null], ['False', false], + ['True', true], ['true', true], + ['PHP_VERSION_ID', PHP_VERSION_ID], ['+1', 1], ['-1', -1], ['~0', -1], @@ -72,6 +74,16 @@ public static function provideTestEvaluate() { ['true or (1/0)', true], ['true xor false', true], ['"foo" |> "strlen"', 3], + + //Cast + ['(int)true', 1], + ['(string)1', "1"], + ['(bool)1', true], + ['(double)1', 1.0], + ['(float)1', 1.0], + + ['PhpParser\ConstEvaluationFakeClass::CONST_4TEST', 456], + ['UnexistingClass::class', "UnexistingClass"], ]; } @@ -133,3 +145,7 @@ public static function provideTestEvaluateSilently() { ]; } } + +class ConstEvaluationFakeClass { + public const CONST_4TEST = 456; +} diff --git a/test/PhpParser/ExprEvaluatorTest.php b/test/PhpParser/ExprEvaluatorTest.php new file mode 100644 index 0000000000..0ca515db9f --- /dev/null +++ b/test/PhpParser/ExprEvaluatorTest.php @@ -0,0 +1,202 @@ + "zomeu"]; + + global $globalNullVar; + $globalNullVar = null; + + global $globalEvaluationFakeClass; + $globalEvaluationFakeClass = new EvaluationFakeClass(); + + $oNonNullVar2 = "a"; + $oNullVar2 = null; + + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $expr = $parser->parse('expr; + $evaluator = new ExprEvaluator(); + $evaluator->setStaticCallsWhitelist(["PhpParser\EvaluationFakeClass::GetStaticValue"]); + $evaluator->setFunctionsWhitelist(["class_exists"]); + $this->assertSame($expected, $evaluator->evaluateDirectly($expr)); + } + + public static function provideTestEvaluate() { + return [ + ['1', 1], + ['1.0', 1.0], + ['"foo"', "foo"], + ['[0, 1]', [0, 1]], + ['["foo" => "bar"]', ["foo" => "bar"]], + ['[...["bar"]]', ["bar"]], + ['[...["foo" => "bar"]]', ["foo" => "bar"]], + ['["a", "b" => "b", ...["b" => "bb", "c"]]', ["a", "b" => "bb", "c"]], + ['NULL', null], + ['False', false], + ['True', true], + ['true', true], + ['PHP_VERSION_ID', PHP_VERSION_ID], + ['+1', 1], + ['-1', -1], + ['~0', -1], + ['!true', false], + ['[0][0]', 0], + ['"a"[0]', "a"], + ['true ? 1 : (1/0)', 1], + ['false ? (1/0) : 1', 1], + ['42 ?: (1/0)', 42], + ['false ?: 42', 42], + ['false ?? 42', false], + ['null ?? 42', 42], + ['[0][0] ?? 42', 0], + ['[][0] ?? 42', 42], + ['0b11 & 0b10', 0b10], + ['0b11 | 0b10', 0b11], + ['0b11 ^ 0b10', 0b01], + ['1 << 2', 4], + ['4 >> 2', 1], + ['"a" . "b"', "ab"], + ['4 + 2', 6], + ['4 - 2', 2], + ['4 * 2', 8], + ['4 / 2', 2], + ['4 % 2', 0], + ['4 ** 2', 16], + ['1 == 1.0', true], + ['1 != 1.0', false], + ['1 < 2.0', true], + ['1 <= 2.0', true], + ['1 > 2.0', false], + ['1 >= 2.0', false], + ['1 <=> 2.0', -1], + ['1 === 1.0', false], + ['1 !== 1.0', true], + ['true && true', true], + ['true and true', true], + ['false && (1/0)', false], + ['false and (1/0)', false], + ['false || false', false], + ['false or false', false], + ['true || (1/0)', true], + ['true or (1/0)', true], + ['true xor false', true], + ['"foo" |> "strlen"', 3], + + //Variable + ['$globalNonNullVar', "a"], + ['$globalNotDeclaredVar', null], + ['$globalArray', ["gabu" => "zomeu"]], + ['$globalNullVar', null], + ['$globalNonNullVar', "a"], + //Isset + ['isset($globalNotDeclaredVar)', false], + ['isset($globalNonNullVar)', true], + ['isset($globalArray)', true], + ['isset($globalNullVar)', false], + ['isset($globalNonNullVar)', true], + ['isset($oNonNullVar)', false], + ['isset($oNullVar)', false], + ['isset($eee)', false], + //Cast + ['(int)true', 1], + ['(string)1', "1"], + ['(bool)1', true], + ['(double)1', 1.0], + ['(float)1', 1.0], + ['(string) $globalEvaluationFakeClass', "toString"], + ['PhpParser\EvaluationFakeClass::CONST_4TEST', 456], + ['UnexistingClass::class', "UnexistingClass"], + ['PhpParser\EvaluationFakeClass::$STATICPROPERTY_4TEST', 123], + ['PhpParser\EvaluationFakeClass::GetStaticValue()', "shadok"], + ['class_exists("PhpParser\EvaluationFakeClass")', true], + ['$globalEvaluationFakeClass->iIsOk', 'IsOkValue'], + ['$globalNullVar?->iIsOk', null], + ['$globalEvaluationFakeClass->GetName()', 'gabuzomeu'], + ['$globalNullVar?->GetName()', null], + ['$globalEvaluationFakeClass->GetLongName("aa")', 'gabuzomeu_aa'], + ['$globalNullVar??1', 1], + ['$globalNotDeclaredVar??1', 1], + ['$globalNotDeclaredVar["a"]??1', 1], + ['$globalArray["gabu"]??1', "zomeu"], + ['$globalNonNullVar??1', "a"], + ]; + } + + public function testEvaluateFails(): void { + $this->expectException(ExprEvaluationException::class); + $this->expectExceptionMessage('Expression of type Expr_Variable cannot be evaluated'); + $evaluator = new ExprEvaluator(); + $evaluator->evaluateDirectly(new Expr\Variable('a')); + } + + public function testEvaluateStaticCallOutsideWhitelistFails(): void { + $this->expectException(ExprEvaluationException::class); + $this->expectExceptionMessage('Expression of type Expr_StaticCall cannot be evaluated'); + + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $exprString = "PhpParser\EvaluationFakeClass::GetStaticValue()"; + $expr = $parser->parse('expr; + $evaluator = new ExprEvaluator(); + $evaluator->evaluateDirectly($expr); + } + + public function testEvaluateFuncCallOutsideWhitelistFails(): void { + $this->expectException(ExprEvaluationException::class); + $this->expectExceptionMessage('Expression of type Expr_FuncCall cannot be evaluated'); + + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $exprString = 'class_exists("PhpParser\EvaluationFakeClass")'; + $expr = $parser->parse('expr; + $evaluator = new ExprEvaluator(); + $evaluator->evaluateDirectly($expr); + } + + public function testEvaluateFallback(): void { + $evaluator = new ExprEvaluator(function (Expr $expr) { + if ($expr instanceof Scalar\MagicConst\Line) { + return 42; + } + throw new ExprEvaluationException(); + }); + $expr = new Expr\BinaryOp\Plus( + new Scalar\Int_(8), + new Scalar\MagicConst\Line() + ); + $this->assertSame(50, $evaluator->evaluateDirectly($expr)); + } +} + +class EvaluationFakeClass { + public static $STATICPROPERTY_4TEST = 123; + public const CONST_4TEST = 456; + + public string $iIsOk = "IsOkValue"; + + public static function GetStaticValue() { + return "shadok"; + } + + public function GetName() { + return "gabuzomeu"; + } + + public function GetLongName($suffix) { + return "gabuzomeu_".$suffix; + } + + public function __toString(): string { + return "toString"; + } +}