diff --git a/src/Rule/ForbidCustomFunctionsRule.php b/src/Rule/ForbidCustomFunctionsRule.php index 13a0980..42ffa5f 100644 --- a/src/Rule/ForbidCustomFunctionsRule.php +++ b/src/Rule/ForbidCustomFunctionsRule.php @@ -4,6 +4,7 @@ use LogicException; use PhpParser\Node; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\CallLike; use PhpParser\Node\Expr\FuncCall; @@ -12,14 +13,25 @@ use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Identifier; use PhpParser\Node\Name; +use PhpParser\Node\Stmt\Class_; +use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\Scope; +use PHPStan\Node\FunctionCallableNode; +use PHPStan\Node\MethodCallableNode; +use PHPStan\Node\StaticMethodCallableNode; +use PHPStan\Reflection\ExtendedMethodReflection; +use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function array_map; -use function array_merge; use function count; use function explode; use function gettype; @@ -27,7 +39,7 @@ use function sprintf; /** - * @implements Rule + * @implements Rule */ class ForbidCustomFunctionsRule implements Rule { @@ -80,85 +92,84 @@ public function __construct(array $forbiddenFunctions, ReflectionProvider $refle public function getNodeType(): string { - return CallLike::class; + return Node::class; } /** - * @param CallLike $node * @return list */ public function processNode(Node $node, Scope $scope): array { - if ($node instanceof MethodCall) { - $methodNames = $this->getMethodNames($node->name, $scope); + if ($this->isFirstClassCallableNode($node)) { + $node = $node->getOriginalNode(); // @phpstan-ignore shipmonk.variableTypeOverwritten + } - return $this->validateCallOverExpr($methodNames, $node->var, $scope); + if ($node instanceof FuncCall) { + return $this->validateFunctionCall($node, $scope); } - if ($node instanceof StaticCall) { + if ($node instanceof MethodCall) { + $caller = $scope->getType($node->var); $methodNames = $this->getMethodNames($node->name, $scope); + } elseif ($node instanceof StaticCall) { $classNode = $node->class; + $caller = $classNode instanceof Name ? $scope->resolveTypeByName($classNode) : $scope->getType($classNode); + $methodNames = $this->getMethodNames($node->name, $scope); - if ($classNode instanceof Name) { - return $this->validateMethod($methodNames, $scope->resolveName($classNode)); - } - - return $this->validateCallOverExpr($methodNames, $classNode, $scope); - } + } elseif ($node instanceof New_) { + $caller = $this->getNewCaller($node, $scope); + $methodNames = ['__construct']; - if ($node instanceof FuncCall) { - $methodNames = $this->getFunctionNames($node->name, $scope); - return $this->validateFunction($methodNames); + } else { + return []; } - if ($node instanceof New_) { - $classNode = $node->class; - - if ($classNode instanceof Name) { - return $this->validateMethod(['__construct'], $scope->resolveName($classNode)); - } - - if ($classNode instanceof Expr) { - return $this->validateConstructorWithDynamicString($classNode, $scope); - } + $errors = []; - return []; + foreach ($methodNames as $methodName) { + $errors = [ + ...$errors, + ...$this->validateCallOverExpr($methodName, $caller), + ...$this->validateCallLikeArguments($caller, $methodName, $node, $scope), + ]; } - return []; + return $errors; } /** * @return list */ - private function validateConstructorWithDynamicString(Expr $expr, Scope $scope): array + private function validateFunctionCall(FuncCall $node, Scope $scope): array { - $type = $scope->getType($expr); + $functionNames = $this->getFunctionNames($node->name, $scope); $errors = []; - foreach ($type->getConstantStrings() as $constantStringType) { - $errors = array_merge($errors, $this->validateMethod(['__construct'], $constantStringType->getValue())); + foreach ($functionNames as $functionName) { + $errors = [ + ...$errors, + ...$this->validateFunction($functionName), + ...$this->validateFunctionArguments($functionName, $node, $scope), + ]; } return $errors; } /** - * @param list $methodNames * @return list */ - private function validateCallOverExpr(array $methodNames, Expr $expr, Scope $scope): array + private function validateCallOverExpr(string $methodName, Type $caller): array { - $classType = $scope->getType($expr); - $classNames = $classType->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); + $classNames = $caller->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); $errors = []; foreach ($classNames as $className) { $errors = [ ...$errors, - ...$this->validateMethod($methodNames, $className), + ...$this->validateMethod($methodName, $className), ]; } @@ -166,10 +177,9 @@ private function validateCallOverExpr(array $methodNames, Expr $expr, Scope $sco } /** - * @param list $methodNames * @return list */ - private function validateMethod(array $methodNames, string $className): array + private function validateMethod(string $methodName, string $className): array { if (!$this->reflectionProvider->hasClass($className)) { return []; @@ -187,13 +197,11 @@ private function validateMethod(array $methodNames, string $className): array ->build(); } - foreach ($methodNames as $methodName) { - if (isset($this->forbiddenFunctions[$ancestorClassName][$methodName])) { - $errorMessage = sprintf('Method %s::%s() is forbidden. %s', $ancestorClassName, $methodName, $this->forbiddenFunctions[$ancestorClassName][$methodName]); - $errors[] = RuleErrorBuilder::message($errorMessage) - ->identifier('shipmonk.methodCallDenied') - ->build(); - } + if (isset($this->forbiddenFunctions[$ancestorClassName][$methodName])) { + $errorMessage = sprintf('Method %s::%s() is forbidden. %s', $ancestorClassName, $methodName, $this->forbiddenFunctions[$ancestorClassName][$methodName]); + $errors[] = RuleErrorBuilder::message($errorMessage) + ->identifier('shipmonk.methodCallDenied') + ->build(); } } @@ -201,20 +209,17 @@ private function validateMethod(array $methodNames, string $className): array } /** - * @param list $functionNames * @return list */ - private function validateFunction(array $functionNames): array + private function validateFunction(string $functionName): array { $errors = []; - foreach ($functionNames as $functionName) { - if (isset($this->forbiddenFunctions[self::FUNCTION][$functionName])) { - $errorMessage = sprintf('Function %s() is forbidden. %s', $functionName, $this->forbiddenFunctions[self::FUNCTION][$functionName]); - $errors[] = RuleErrorBuilder::message($errorMessage) - ->identifier('shipmonk.functionCallDenied') - ->build(); - } + if (isset($this->forbiddenFunctions[self::FUNCTION][$functionName])) { + $errorMessage = sprintf('Function %s() is forbidden. %s', $functionName, $this->forbiddenFunctions[self::FUNCTION][$functionName]); + $errors[] = RuleErrorBuilder::message($errorMessage) + ->identifier('shipmonk.functionCallDenied') + ->build(); } return $errors; @@ -261,4 +266,177 @@ private function getMethodNames(Node $name, Scope $scope): array ); } + /** + * @return list + */ + private function validateCallable( + Expr $callable, + Scope $scope + ): array + { + $callableType = $scope->getType($callable); + + if (!$callableType->isCallable()->yes()) { + return []; + } + + $errors = []; + + foreach ($callableType->getConstantStrings() as $constantString) { + $errors = [ + ...$errors, + ...$this->validateFunction($constantString->getValue()), + ]; + } + + foreach ($callableType->getConstantArrays() as $constantArray) { + $callableTypeAndNames = $constantArray->findTypeAndMethodNames(); + + foreach ($callableTypeAndNames as $typeAndName) { + if ($typeAndName->isUnknown()) { + continue; + } + + $classNames = $typeAndName->getType()->getObjectClassNames(); + $methodName = $typeAndName->getMethod(); + + foreach ($classNames as $className) { + $errors = [ + ...$errors, + ...$this->validateMethod($methodName, $className), + ]; + } + } + } + + return $errors; + } + + /** + * @return list + */ + private function validateCallLikeArguments(Type $caller, string $methodName, CallLike $node, Scope $scope): array + { + if ($node->isFirstClassCallable()) { + return []; + } + + $errors = []; + + foreach ($caller->getObjectTypeOrClassStringObjectType()->getObjectClassNames() as $className) { + $methodReflection = $this->getMethodReflection($className, $methodName, $scope); + + if ($methodReflection === null) { + continue; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $node->getArgs(), $methodReflection->getVariants()); + $reorderedArgs = ArgumentsNormalizer::reorderArgs($parametersAcceptor, $node->getArgs()) ?? $node->getArgs(); + + $errors = [ + ...$errors, + ...$this->validateCallableArguments($reorderedArgs, $parametersAcceptor, $scope), + ]; + } + + return $errors; + } + + /** + * @param array $reorderedArgs + * @return list + */ + private function validateCallableArguments( + array $reorderedArgs, + ParametersAcceptor $parametersAcceptor, + Scope $scope + ): array + { + $errors = []; + + foreach ($parametersAcceptor->getParameters() as $index => $parameter) { + if (TypeCombinator::removeNull($parameter->getType())->isCallable()->yes() && isset($reorderedArgs[$index])) { + $errors = [ + ...$errors, + ...$this->validateCallable($reorderedArgs[$index]->value, $scope), + ]; + } + } + + return $errors; + } + + /** + * @return list + */ + private function validateFunctionArguments(string $functionName, FuncCall $node, Scope $scope): array + { + if ($node->isFirstClassCallable()) { + return []; + } + + $functionReflection = $this->getFunctionReflection(new Name($functionName), $scope); + + if ($functionReflection === null) { + return []; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $node->getArgs(), $functionReflection->getVariants()); + $funcCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + + if ($funcCall === null) { + $funcCall = $node; + } + + $orderedArgs = $funcCall->getArgs(); + + return $this->validateCallableArguments($orderedArgs, $parametersAcceptor, $scope); + } + + private function getMethodReflection(string $className, string $methodName, Scope $scope): ?ExtendedMethodReflection + { + if (!$this->reflectionProvider->hasClass($className)) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($className); + + if (!$classReflection->hasMethod($methodName)) { + return null; + } + + return $classReflection->getMethod($methodName, $scope); + } + + private function getFunctionReflection(Name $functionName, Scope $scope): ?FunctionReflection + { + return $this->reflectionProvider->hasFunction($functionName, $scope) + ? $this->reflectionProvider->getFunction($functionName, $scope) + : null; + } + + private function getNewCaller(New_ $new, Scope $scope): Type + { + if ($new->class instanceof Class_) { + $anonymousClassReflection = $this->reflectionProvider->getAnonymousClassReflection($new->class, $scope); + return new ObjectType($anonymousClassReflection->getName()); + } + + if ($new->class instanceof Name) { + return $scope->resolveTypeByName($new->class); + } + + return $scope->getType($new->class); + } + + /** + * @phpstan-assert-if-true FunctionCallableNode|MethodCallableNode|StaticMethodCallableNode $node + */ + private function isFirstClassCallableNode(Node $node): bool + { + return $node instanceof FunctionCallableNode + || $node instanceof MethodCallableNode + || $node instanceof StaticMethodCallableNode; + } + } diff --git a/tests/Rule/data/ForbidCustomFunctionsRule/code.php b/tests/Rule/data/ForbidCustomFunctionsRule/code.php index 520ebdc..4d1fae8 100644 --- a/tests/Rule/data/ForbidCustomFunctionsRule/code.php +++ b/tests/Rule/data/ForbidCustomFunctionsRule/code.php @@ -58,16 +58,80 @@ public function test( $classStringOrTheClass, string $classString, SomeClass $class, + array $array, SomeClass|AnotherClass $union, ClassWithForbiddenAllMethods $forbiddenClass, ClassWithForbiddenConstructor $forbiddenConstructor, ChildOfClassWithForbiddenAllMethods $forbiddenClassChild, SomeInterface $interface ) { + sleep(...); // error: Function sleep() is forbidden. Description 0 sleep(0); // error: Function sleep() is forbidden. Description 0 + array_map('sleep', $array); // error: Function sleep() is forbidden. Description 0 + array_map(array: $array, callback: 'sleep'); // error: Function sleep() is forbidden. Description 0 + array_map([$class, 'forbiddenMethod'], $array); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + array_map([$class, 'forbiddenStaticMethod'], $array); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenStaticMethod() is forbidden. Description 5 + + strlen('sleep'); // not used in callable context + [$class, 'forbiddenMethod']; // not used in callable context + [$class, 'forbiddenStaticMethod']; // not used in callable context + + $this->acceptCallable('sleep', [], 'x'); + $this->acceptCallable('x', [], 'sleep'); // error: Function sleep() is forbidden. Description 0 + $this->acceptCallable(callable: 'sleep'); // error: Function sleep() is forbidden. Description 0 + $this->acceptCallable(string: 'sleep'); + $this->acceptCallable(callable: 'strlen', array: [], string: 'sleep'); + $this->acceptCallable('x', [], [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + $this->acceptCallable(callable: [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + + self::acceptCallableStatic('sleep', [], 'x'); + self::acceptCallableStatic('x', [], 'sleep'); // error: Function sleep() is forbidden. Description 0 + self::acceptCallableStatic(callable: 'sleep'); // error: Function sleep() is forbidden. Description 0 + self::acceptCallableStatic(string: 'sleep'); + self::acceptCallableStatic(callable: 'strlen', array: [], string: 'sleep'); + self::acceptCallableStatic('x', [], [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + self::acceptCallableStatic(callable: [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + + $self = self::class; + + $self::acceptCallableStatic('sleep', [], 'x'); + $self::acceptCallableStatic('x', [], 'sleep'); // error: Function sleep() is forbidden. Description 0 + $self::acceptCallableStatic(callable: 'sleep'); // error: Function sleep() is forbidden. Description 0 + $self::acceptCallableStatic(string: 'sleep'); + $self::acceptCallableStatic(callable: 'strlen', array: [], string: 'sleep'); + $self::acceptCallableStatic('x', [], [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + $self::acceptCallableStatic(callable: [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + + new AcceptCallable('sleep', [], 'x'); + new AcceptCallable('x', [], 'sleep'); // error: Function sleep() is forbidden. Description 0 + new AcceptCallable(callable: 'sleep'); // error: Function sleep() is forbidden. Description 0 + new AcceptCallable(string: 'sleep'); + new AcceptCallable(callable: 'strlen', array: [], string: 'sleep'); + new AcceptCallable('x', [], [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + new AcceptCallable(callable: [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + + $acceptCallableClass = AcceptCallable::class; + + new $acceptCallableClass('sleep', [], 'x'); + new $acceptCallableClass('x', [], 'sleep'); // error: Function sleep() is forbidden. Description 0 + new $acceptCallableClass(callable: 'sleep'); // error: Function sleep() is forbidden. Description 0 + new $acceptCallableClass(string: 'sleep'); + new $acceptCallableClass(callable: 'strlen', array: [], string: 'sleep'); + new $acceptCallableClass('x', [], [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + new $acceptCallableClass(callable: [$class, 'forbiddenMethod']); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + + new class ('sleep', [], 'x') extends AcceptCallable {}; + new class ('x', [], 'sleep') extends AcceptCallable {}; // error: Function sleep() is forbidden. Description 0 + new class (callable: 'sleep') extends AcceptCallable {}; // error: Function sleep() is forbidden. Description 0 + new class (string: 'sleep') extends AcceptCallable {}; + new class (callable: 'strlen', array: [], string: 'sleep') extends AcceptCallable {}; + new class ('x', [], [$class, 'forbiddenMethod']) extends AcceptCallable {}; // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + new class (callable: [$class, 'forbiddenMethod']) extends AcceptCallable {}; // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 $class->allowedMethod(); $class->forbiddenMethod(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + $class?->forbiddenMethod(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + $class->forbiddenMethod(...); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 $class->allowedInterfaceMethod(); $class->forbiddenInterfaceMethod(); // error: Method ForbidCustomFunctionsRule\SomeInterface::forbiddenInterfaceMethod() is forbidden. Description 6 $class->forbiddenMethodOfParent(); // error: Method ForbidCustomFunctionsRule\SomeParent::forbiddenMethodOfParent() is forbidden. Description 8 @@ -87,8 +151,10 @@ public function test( SomeClass::forbiddenInterfaceStaticMethod(); // error: Method ForbidCustomFunctionsRule\SomeInterface::forbiddenInterfaceStaticMethod() is forbidden. Description 7 SomeClass::forbiddenStaticMethod(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenStaticMethod() is forbidden. Description 5 + SomeClass::forbiddenStaticMethod(...); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenStaticMethod() is forbidden. Description 5 forbidden_namespaced_function(); // error: Function ForbidCustomFunctionsRule\forbidden_namespaced_function() is forbidden. Description 1 + forbidden_namespaced_function(...); // error: Function ForbidCustomFunctionsRule\forbidden_namespaced_function() is forbidden. Description 1 $forbiddenClassName = 'ForbidCustomFunctionsRule\ClassWithForbiddenConstructor'; $forbiddenMethodName = 'forbiddenMethod'; @@ -106,4 +172,12 @@ public function test( $classStringOrTheClass::$forbiddenStaticMethodName(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenStaticMethod() is forbidden. Description 5 new $forbiddenClassName(); // error: Method ForbidCustomFunctionsRule\ClassWithForbiddenConstructor::__construct() is forbidden. Description 3 } + + private function acceptCallable(?string $string = null, ?array $array = null, ?callable $callable = null) {} + private static function acceptCallableStatic(?string $string = null, ?array $array = null, ?callable $callable = null) {} + +} + +class AcceptCallable { + public function __construct(?string $string = null, ?array $array = null, ?callable $callable = null) {} }