Skip to content

Commit

Permalink
Merge branch refs/heads/1.11.x into 1.12.x
Browse files Browse the repository at this point in the history
  • Loading branch information
phpstan-bot authored Jul 23, 2024
2 parents a9fe836 + c4c0269 commit 67193b6
Show file tree
Hide file tree
Showing 8 changed files with 485 additions and 0 deletions.
1 change: 1 addition & 0 deletions conf/bleedingEdge.neon
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,6 @@ parameters:
uselessReturnValue: true
printfArrayParameters: true
preciseMissingReturn: true
validatePregQuote: true
stubFiles:
- ../stubs/bleedingEdge/Rule.stub
5 changes: 5 additions & 0 deletions conf/config.level0.neon
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ conditionalTags:
phpstan.rules.rule: %featureToggles.uselessReturnValue%
PHPStan\Rules\Functions\PrintfArrayParametersRule:
phpstan.rules.rule: %featureToggles.printfArrayParameters%
PHPStan\Rules\Regexp\RegularExpressionQuotingRule:
phpstan.rules.rule: %featureToggles.validatePregQuote%

rules:
- PHPStan\Rules\Api\ApiInstantiationRule
Expand Down Expand Up @@ -304,3 +306,6 @@ services:

-
class: PHPStan\Rules\Functions\PrintfArrayParametersRule

-
class: PHPStan\Rules\Regexp\RegularExpressionQuotingRule
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ parameters:
uselessReturnValue: false
printfArrayParameters: false
preciseMissingReturn: false
validatePregQuote: false
fileExtensions:
- php
checkAdvancedIsset: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ parametersSchema:
uselessReturnValue: bool()
printfArrayParameters: bool()
preciseMissingReturn: bool()
validatePregQuote: bool()
])
fileExtensions: listOf(string())
checkAdvancedIsset: bool()
Expand Down
274 changes: 274 additions & 0 deletions src/Rules/Regexp/RegularExpressionQuotingRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Regexp;

use PhpParser\Node;
use PhpParser\Node\Expr\BinaryOp\Concat;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Constant\ConstantStringType;
use function array_filter;
use function array_merge;
use function array_values;
use function count;
use function in_array;
use function sprintf;
use function strlen;
use function substr;

/**
* @implements Rule<Node\Expr\FuncCall>
*/
class RegularExpressionQuotingRule implements Rule
{

public function __construct(private ReflectionProvider $reflectionProvider)
{
}

public function getNodeType(): string
{
return FuncCall::class;
}

public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Name) {
return [];
}

if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
return [];
}

$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
if (
!in_array($functionReflection->getName(), [
'preg_match',
'preg_match_all',
'preg_filter',
'preg_grep',
'preg_replace',
'preg_replace_callback',
'preg_split',
], true)
) {
return [];
}

$normalizedArgs = $this->getNormalizedArgs($node, $scope, $functionReflection);
if ($normalizedArgs === null) {
return [];
}
if (!isset($normalizedArgs[0])) {
return [];
}
if (!$normalizedArgs[0]->value instanceof Concat) {
return [];
}

$patternDelimiters = $this->getDelimitersFromConcat($normalizedArgs[0]->value, $scope);
return $this->validateQuoteDelimiters($normalizedArgs[0]->value, $scope, $patternDelimiters);
}

/**
* @param string[] $patternDelimiters
*
* @return list<IdentifierRuleError>
*/
private function validateQuoteDelimiters(Concat $concat, Scope $scope, array $patternDelimiters): array
{
if ($patternDelimiters === []) {
return [];
}

$errors = [];
if (
$concat->left instanceof FuncCall
&& $concat->left->name instanceof Name
&& $concat->left->name->toLowerString() === 'preg_quote'
) {
$pregError = $this->validatePregQuote($concat->left, $scope, $patternDelimiters);
if ($pregError !== null) {
$errors[] = $pregError;
}
} elseif ($concat->left instanceof Concat) {
$errors = array_merge($errors, $this->validateQuoteDelimiters($concat->left, $scope, $patternDelimiters));
}

if (
$concat->right instanceof FuncCall
&& $concat->right->name instanceof Name
&& $concat->right->name->toLowerString() === 'preg_quote'
) {
$pregError = $this->validatePregQuote($concat->right, $scope, $patternDelimiters);
if ($pregError !== null) {
$errors[] = $pregError;
}
} elseif ($concat->right instanceof Concat) {
$errors = array_merge($errors, $this->validateQuoteDelimiters($concat->right, $scope, $patternDelimiters));
}

return $errors;
}

/**
* @param string[] $patternDelimiters
*/
private function validatePregQuote(FuncCall $pregQuote, Scope $scope, array $patternDelimiters): ?IdentifierRuleError
{
if (!$pregQuote->name instanceof Node\Name) {
return null;
}

if (!$this->reflectionProvider->hasFunction($pregQuote->name, $scope)) {
return null;
}
$functionReflection = $this->reflectionProvider->getFunction($pregQuote->name, $scope);

$args = $this->getNormalizedArgs($pregQuote, $scope, $functionReflection);
if ($args === null) {
return null;
}

$patternDelimiters = $this->removeDefaultEscapedDelimiters($patternDelimiters);
if ($patternDelimiters === []) {
return null;
}

if (count($args) === 1) {
if (count($patternDelimiters) === 1) {
return RuleErrorBuilder::message(sprintf('Call to preg_quote() is missing delimiter %s to be effective.', $patternDelimiters[0]))
->line($pregQuote->getStartLine())
->identifier('argument.invalidPregQuote')
->build();
}

return RuleErrorBuilder::message('Call to preg_quote() is missing delimiter parameter to be effective.')
->line($pregQuote->getStartLine())
->identifier('argument.invalidPregQuote')
->build();
}

if (count($args) >= 2) {

foreach ($scope->getType($args[1]->value)->getConstantStrings() as $quoteDelimiterType) {
$quoteDelimiter = $quoteDelimiterType->getValue();

$quoteDelimiters = $this->removeDefaultEscapedDelimiters([$quoteDelimiter]);
if ($quoteDelimiters === []) {
continue;
}

if (count($quoteDelimiters) !== 1) {
throw new ShouldNotHappenException();
}
$quoteDelimiter = $quoteDelimiters[0];

if (!in_array($quoteDelimiter, $patternDelimiters, true)) {
if (count($patternDelimiters) === 1) {
return RuleErrorBuilder::message(sprintf('Call to preg_quote() uses invalid delimiter %s while pattern uses %s.', $quoteDelimiter, $patternDelimiters[0]))
->line($pregQuote->getStartLine())
->identifier('argument.invalidPregQuote')
->build();
}

return RuleErrorBuilder::message(sprintf('Call to preg_quote() uses invalid delimiter %s.', $quoteDelimiter))
->line($pregQuote->getStartLine())
->identifier('argument.invalidPregQuote')
->build();
}
}
}

return null;
}

/**
* Get delimiters from non-constant patterns, if possible.
*
* @return string[]
*/
private function getDelimitersFromConcat(Concat $concat, Scope $scope): array
{
if ($concat->left instanceof Concat) {
return $this->getDelimitersFromConcat($concat->left, $scope);
}

$left = $scope->getType($concat->left);

$delimiters = [];
foreach ($left->getConstantStrings() as $leftString) {
$delimiter = $this->getDelimiterFromString($leftString);
if ($delimiter === null) {
continue;
}

$delimiters[] = $delimiter;
}
return $delimiters;
}

private function getDelimiterFromString(ConstantStringType $string): ?string
{
if ($string->getValue() === '') {
return null;
}

return substr($string->getValue(), 0, 1);
}

/**
* @param string[] $delimiters
*
* @return list<string>
*/
private function removeDefaultEscapedDelimiters(array $delimiters): array
{
return array_values(array_filter($delimiters, fn (string $delimiter): bool => !$this->isDefaultEscaped($delimiter)));
}

private function isDefaultEscaped(string $delimiter): bool
{
if (strlen($delimiter) !== 1) {
return false;
}

return in_array(
$delimiter,
// these delimiters are escaped, no matter what preg_quote() 2nd arg looks like
['.', '\\', '+', '*', '?', '[', '^', ']', '$', '(', ')', '{', '}', '=', '!', '<', '>', '|', ':', '-', '#'],
true,
);
}

/**
* @return Node\Arg[]|null
*/
private function getNormalizedArgs(FuncCall $functionCall, Scope $scope, FunctionReflection $functionReflection): ?array
{
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
$scope,
$functionCall->getArgs(),
$functionReflection->getVariants(),
$functionReflection->getNamedArgumentsVariants(),
);

$normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $functionCall);
if ($normalizedFuncCall === null) {
return null;
}

return $normalizedFuncCall->getArgs();
}

}
Loading

0 comments on commit 67193b6

Please sign in to comment.