Skip to content

Commit

Permalink
ForbidCustomFunctionsRule (#22)
Browse files Browse the repository at this point in the history
* ForbidCustomFunctionsRule

* fix phpdoc

* Fix forbidden parent class

* Improve dynamic calls

* readme improvement

* readme: explain *
  • Loading branch information
janedbal committed Aug 11, 2022
1 parent a478a23 commit 16fbdff
Show file tree
Hide file tree
Showing 4 changed files with 411 additions and 0 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
244 changes: 244 additions & 0 deletions src/Rule/ForbidCustomFunctionsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
<?php declare(strict_types = 1);

namespace ShipMonk\PHPStan\Rule;

use LogicException;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Generic\GenericClassStringType;
use PHPStan\Type\TypeUtils;
use function count;
use function explode;
use function is_string;
use function sprintf;

/**
* @implements Rule<CallLike>
*/
class ForbidCustomFunctionsRule implements Rule
{

private const ANY_METHOD = '*';
private const FUNCTION = '';

/**
* @var array<string, array<string, string>>
*/
private array $forbiddenFunctions = [];

private ReflectionProvider $reflectionProvider;

/**
* @param array<string, mixed> $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<string>
*/
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<string>
*/
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<string>
*/
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<string>
*/
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;
}

}
38 changes: 38 additions & 0 deletions tests/Rule/ForbidCustomFunctionsRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php declare(strict_types = 1);

namespace ShipMonk\PHPStan\Rule;

use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use ShipMonk\PHPStan\RuleTestCase;

/**
* @extends RuleTestCase<ForbidCustomFunctionsRule>
*/
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');
}

}
Loading

0 comments on commit 16fbdff

Please sign in to comment.