diff --git a/README.md b/README.md index b50d239..ea89b25 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,29 @@ enum MyEnum: string { // missing @implements tag } ``` +### ForbidCustomFunctionsRule +- Allows you to easily deny some approaches within your codebase by denying classes, methods and functions +- Configuration syntax is array where key is method name and value is reason used in error message +- Works even with interfaces, constructors and some dynamic class/method names like `$fn = 'sleep'; $fn();` +```neon +parametersSchema: + forbiddenFunctions: arrayOf(string()) +parameters: + forbiddenFunctions: + 'Namespace\SomeClass::*': 'Please use different class' # deny all methods by using * (including constructor) + 'Namespace\AnotherClass::someMethod': 'Please use anotherMethod' # deny single method + 'sleep': 'Plese use usleep only' # deny function +services: + - + factory: ShipMonk\PHPStan\Rule\ForbidCustomFunctionsRule(%forbiddenFunctions%) + tags: + - phpstan.rules.rule +``` +```php +new SomeClass(); // Class SomeClass is forbidden. Please use different class +(new AnotherClass())->someMethod(); // Method AnotherClass::someMethod() is forbidden. Please use anotherMethod +``` + ### ForbidEnumInFunctionArgumentsRule - Guards passing native enums to native functions where it fails / produces warning or does unexpected behaviour - Most of the array manipulation functions does not work with enums as they do implicit __toString conversion inside, but that is not possible to do with enums diff --git a/src/Rule/ForbidCustomFunctionsRule.php b/src/Rule/ForbidCustomFunctionsRule.php new file mode 100644 index 0000000..35f39fc --- /dev/null +++ b/src/Rule/ForbidCustomFunctionsRule.php @@ -0,0 +1,244 @@ + + */ +class ForbidCustomFunctionsRule implements Rule +{ + + private const ANY_METHOD = '*'; + private const FUNCTION = ''; + + /** + * @var array> + */ + private array $forbiddenFunctions = []; + + private ReflectionProvider $reflectionProvider; + + /** + * @param array $forbiddenFunctions + */ + public function __construct(array $forbiddenFunctions, ReflectionProvider $reflectionProvider) + { + $this->reflectionProvider = $reflectionProvider; + + foreach ($forbiddenFunctions as $forbiddenFunction => $description) { + if (!is_string($description)) { + throw new LogicException('Unexpected forbidden function description, string expected'); + } + + $parts = explode('::', $forbiddenFunction); + + if (count($parts) === 1) { + $className = self::FUNCTION; + $methodName = $parts[0]; + } elseif (count($parts) === 2) { + $className = $parts[0]; + $methodName = $parts[1]; + } else { + throw new LogicException("Unexpected format of forbidden function {$forbiddenFunction}, expected Namespace\Class::methodName"); + } + + $this->forbiddenFunctions[$className][$methodName] = $description; + } + } + + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * @param CallLike $node + * @return string[] + */ + public function processNode(Node $node, Scope $scope): array + { + if ($node instanceof MethodCall) { + $methodName = $this->getMethodName($node->name, $scope); + + if ($methodName === null) { + return []; + } + + return $this->validateCallOverExpr($methodName, $node->var, $scope); + } + + if ($node instanceof StaticCall) { + $methodName = $this->getMethodName($node->name, $scope); + + if ($methodName === null) { + return []; + } + + $classNode = $node->class; + + if ($classNode instanceof Name) { + return $this->validateMethod($methodName, $scope->resolveName($classNode)); + } + + return $this->validateCallOverExpr($methodName, $classNode, $scope); + } + + if ($node instanceof FuncCall) { + $methodName = $this->getFunctionName($node->name, $scope); + + if ($methodName === null) { + return []; + } + + return $this->validateFunction($methodName); + } + + 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); + } + + return []; + } + + return []; + } + + /** + * @return list + */ + private function validateConstructorWithDynamicString(Expr $expr, Scope $scope): array + { + $type = $scope->getType($expr); + + if ($type instanceof ConstantStringType) { + return $this->validateMethod('__construct', $type->getValue()); + } + + return []; + } + + /** + * @return list + */ + private function validateCallOverExpr(string $methodName, Expr $expr, Scope $scope): array + { + $classType = $scope->getType($expr); + + if ($classType instanceof GenericClassStringType) { + $classType = $classType->getGenericType(); + } + + $classNames = TypeUtils::getDirectClassNames($classType); + $errors = []; + + foreach ($classNames as $className) { + $errors = [ + ...$errors, + ...$this->validateMethod($methodName, $className), + ]; + } + + return $errors; + } + + /** + * @return list + */ + private function validateMethod(string $methodName, string $className): array + { + foreach ($this->reflectionProvider->getClass($className)->getAncestors() as $ancestor) { + $ancestorClassName = $ancestor->getName(); + + if (isset($this->forbiddenFunctions[$ancestorClassName][self::ANY_METHOD])) { + return [sprintf('Class %s is forbidden. %s', $ancestorClassName, $this->forbiddenFunctions[$ancestorClassName][self::ANY_METHOD])]; + } + + if (isset($this->forbiddenFunctions[$ancestorClassName][$methodName])) { + return [sprintf('Method %s::%s() is forbidden. %s', $ancestorClassName, $methodName, $this->forbiddenFunctions[$ancestorClassName][$methodName])]; + } + } + + return []; + } + + /** + * @return list + */ + private function validateFunction(string $functionName): array + { + if (isset($this->forbiddenFunctions[self::FUNCTION][$functionName])) { + return [sprintf('Function %s() is forbidden. %s', $functionName, $this->forbiddenFunctions[self::FUNCTION][$functionName])]; + } + + return []; + } + + /** + * @param Name|Expr $name + */ + private function getFunctionName(Node $name, Scope $scope): ?string + { + if ($name instanceof Name) { + return $this->reflectionProvider->resolveFunctionName($name, $scope); + } + + $nameType = $scope->getType($name); + + if ($nameType instanceof ConstantStringType) { + return $nameType->getValue(); + } + + return null; + } + + /** + * @param Name|Expr|Identifier $name + */ + private function getMethodName(Node $name, Scope $scope): ?string + { + if ($name instanceof Name) { + return $name->toString(); + } + + if ($name instanceof Identifier) { + return $name->toString(); + } + + $nameType = $scope->getType($name); + + if ($nameType instanceof ConstantStringType) { + return $nameType->getValue(); + } + + return null; + } + +} diff --git a/tests/Rule/ForbidCustomFunctionsRuleTest.php b/tests/Rule/ForbidCustomFunctionsRuleTest.php new file mode 100644 index 0000000..1f91633 --- /dev/null +++ b/tests/Rule/ForbidCustomFunctionsRuleTest.php @@ -0,0 +1,38 @@ + + */ +class ForbidCustomFunctionsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ForbidCustomFunctionsRule( + [ + 'sleep' => 'Description 0', + 'ForbidCustomFunctionsRule\forbidden_namespaced_function' => 'Description 1', + 'ForbidCustomFunctionsRule\ClassWithForbiddenAllMethods::*' => 'Description 2', + 'ForbidCustomFunctionsRule\ClassWithForbiddenConstructor::__construct' => 'Description 3', + 'ForbidCustomFunctionsRule\SomeClass::forbiddenMethod' => 'Description 4', + 'ForbidCustomFunctionsRule\SomeClass::forbiddenStaticMethod' => 'Description 5', + 'ForbidCustomFunctionsRule\SomeInterface::forbiddenInterfaceMethod' => 'Description 6', + 'ForbidCustomFunctionsRule\SomeInterface::forbiddenInterfaceStaticMethod' => 'Description 7', + 'ForbidCustomFunctionsRule\SomeParent::forbiddenMethodOfParent' => 'Description 8', + ], + self::getContainer()->getByType(ReflectionProvider::class), + ); + } + + public function testClass(): void + { + $this->analyseFile(__DIR__ . '/data/ForbidCustomFunctionsRule/code.php'); + } + +} diff --git a/tests/Rule/data/ForbidCustomFunctionsRule/code.php b/tests/Rule/data/ForbidCustomFunctionsRule/code.php new file mode 100644 index 0000000..f08872e --- /dev/null +++ b/tests/Rule/data/ForbidCustomFunctionsRule/code.php @@ -0,0 +1,106 @@ + $classString + */ + public function test( + string $classString, + SomeClass $class, + SomeClass|AnotherClass $union, + ClassWithForbiddenAllMethods $forbiddenClass, + ClassWithForbiddenConstructor $forbiddenConstructor, + ChildOfClassWithForbiddenAllMethods $forbiddenClassChild, + SomeInterface $interface + ) { + sleep(0); // error: Function sleep() is forbidden. Description 0 + + $class->allowedMethod(); + $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 + + $forbiddenClass->foo(); // error: Class ForbidCustomFunctionsRule\ClassWithForbiddenAllMethods is forbidden. Description 2 + $forbiddenClass->bar(); // error: Class ForbidCustomFunctionsRule\ClassWithForbiddenAllMethods is forbidden. Description 2 + $forbiddenClassChild->baz(); // error: Class ForbidCustomFunctionsRule\ClassWithForbiddenAllMethods is forbidden. Description 2 + + $forbiddenConstructor->foo(); + $union->forbiddenMethod(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + + new ClassWithForbiddenConstructor(); // error: Method ForbidCustomFunctionsRule\ClassWithForbiddenConstructor::__construct() is forbidden. Description 3 + new ClassWithForbiddenAllMethods(); // error: Class ForbidCustomFunctionsRule\ClassWithForbiddenAllMethods is forbidden. Description 2 + + $interface->allowedInterfaceMethod(); + $interface->forbiddenInterfaceMethod(); // error: Method ForbidCustomFunctionsRule\SomeInterface::forbiddenInterfaceMethod() is forbidden. Description 6 + + SomeClass::forbiddenInterfaceStaticMethod(); // error: Method ForbidCustomFunctionsRule\SomeInterface::forbiddenInterfaceStaticMethod() is forbidden. Description 7 + SomeClass::forbiddenStaticMethod(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenStaticMethod() is forbidden. Description 5 + + forbidden_namespaced_function(); // error: Function ForbidCustomFunctionsRule\forbidden_namespaced_function() is forbidden. Description 1 + + $forbiddenClassName = 'ForbidCustomFunctionsRule\ClassWithForbiddenConstructor'; + $forbiddenMethodName = 'forbiddenMethod'; + $forbiddenStaticMethodName = 'forbiddenStaticMethod'; + $forbiddenGlobalFunctionName = 'sleep'; + $forbiddenFunctionName = 'ForbidCustomFunctionsRule\forbidden_namespaced_function'; + + $forbiddenGlobalFunctionName(); // error: Function sleep() is forbidden. Description 0 + $forbiddenFunctionName(); // error: Function ForbidCustomFunctionsRule\forbidden_namespaced_function() is forbidden. Description 1 + $class->$forbiddenMethodName(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + $class::$forbiddenStaticMethodName(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenStaticMethod() is forbidden. Description 5 + $class->getSelf()->$forbiddenMethodName(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenMethod() is forbidden. Description 4 + $class->getSelf()::$forbiddenStaticMethodName(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenStaticMethod() is forbidden. Description 5 + $classString::$forbiddenStaticMethodName(); // error: Method ForbidCustomFunctionsRule\SomeClass::forbiddenStaticMethod() is forbidden. Description 5 + new $forbiddenClassName(); // error: Method ForbidCustomFunctionsRule\ClassWithForbiddenConstructor::__construct() is forbidden. Description 3 + } +}