Skip to content

Commit bd3aec3

Browse files
authored
Add a PHPStan rule to detect invalid patterns passed to composer/pcre methods (#30)
1 parent 5b103b3 commit bd3aec3

File tree

8 files changed

+340
-13
lines changed

8 files changed

+340
-13
lines changed

extension.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ services:
1919

2020
rules:
2121
- Composer\Pcre\PHPStan\UnsafeStrictGroupsCallRule
22+
- Composer\Pcre\PHPStan\InvalidRegexPatternRule

phpstan-baseline.neon

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,93 @@ parameters:
2424
message: "#^Parameter &\\$matches @param\\-out type of method Composer\\\\Pcre\\\\Preg\\:\\:matchWithOffsets\\(\\) expects array\\<int\\|string, array\\{string\\|null, int\\<\\-1, max\\>\\}\\>, array\\<int\\|string, string\\|null\\> given\\.$#"
2525
count: 1
2626
path: src/Preg.php
27+
28+
-
29+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
30+
count: 2
31+
path: tests/PregTests/GrepTest.php
32+
33+
-
34+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
35+
count: 2
36+
path: tests/PregTests/IsMatchAllTest.php
37+
38+
-
39+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
40+
count: 2
41+
path: tests/PregTests/IsMatchAllWithOffsetsTest.php
42+
43+
-
44+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
45+
count: 2
46+
path: tests/PregTests/IsMatchTest.php
47+
48+
-
49+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
50+
count: 2
51+
path: tests/PregTests/IsMatchWithOffsetsTest.php
52+
53+
-
54+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
55+
count: 2
56+
path: tests/PregTests/MatchAllTest.php
57+
58+
-
59+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
60+
count: 2
61+
path: tests/PregTests/MatchTest.php
62+
63+
-
64+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
65+
count: 2
66+
path: tests/PregTests/ReplaceCallbackArrayTest.php
67+
68+
-
69+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
70+
count: 2
71+
path: tests/PregTests/ReplaceCallbackTest.php
72+
73+
-
74+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
75+
count: 2
76+
path: tests/PregTests/ReplaceTest.php
77+
78+
-
79+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
80+
count: 2
81+
path: tests/PregTests/SplitTest.php
82+
83+
-
84+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
85+
count: 2
86+
path: tests/PregTests/SplitWithOffsetsTest.php
87+
88+
-
89+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
90+
count: 2
91+
path: tests/RegexTests/IsMatchTest.php
92+
93+
-
94+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
95+
count: 2
96+
path: tests/RegexTests/MatchAllTest.php
97+
98+
-
99+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
100+
count: 2
101+
path: tests/RegexTests/MatchTest.php
102+
103+
-
104+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
105+
count: 2
106+
path: tests/RegexTests/ReplaceCallbackArrayTest.php
107+
108+
-
109+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
110+
count: 2
111+
path: tests/RegexTests/ReplaceCallbackTest.php
112+
113+
-
114+
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
115+
count: 2
116+
path: tests/RegexTests/ReplaceTest.php

phpstan.neon.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ parameters:
1212

1313
excludePaths:
1414
- tests/PHPStanTests/nsrt/*
15+
- tests/PHPStanTests/fixtures/*
1516

1617
includes:
1718
- extension.neon
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Composer\Pcre\PHPStan;
4+
5+
use Composer\Pcre\Preg;
6+
use Composer\Pcre\Regex;
7+
use Composer\Pcre\PcreException;
8+
use Nette\Utils\RegexpException;
9+
use Nette\Utils\Strings;
10+
use PhpParser\Node;
11+
use PhpParser\Node\Expr\StaticCall;
12+
use PhpParser\Node\Name\FullyQualified;
13+
use PHPStan\Analyser\Scope;
14+
use PHPStan\Rules\Rule;
15+
use PHPStan\Rules\RuleErrorBuilder;
16+
use function in_array;
17+
use function sprintf;
18+
19+
/**
20+
* Copy of PHPStan's RegularExpressionPatternRule
21+
*
22+
* @implements Rule<StaticCall>
23+
*/
24+
class InvalidRegexPatternRule implements Rule
25+
{
26+
public function getNodeType(): string
27+
{
28+
return StaticCall::class;
29+
}
30+
31+
public function processNode(Node $node, Scope $scope): array
32+
{
33+
$patterns = $this->extractPatterns($node, $scope);
34+
35+
$errors = [];
36+
foreach ($patterns as $pattern) {
37+
$errorMessage = $this->validatePattern($pattern);
38+
if ($errorMessage === null) {
39+
continue;
40+
}
41+
42+
$errors[] = RuleErrorBuilder::message(sprintf('Regex pattern is invalid: %s', $errorMessage))->identifier('regexp.pattern')->build();
43+
}
44+
45+
return $errors;
46+
}
47+
48+
/**
49+
* @return string[]
50+
*/
51+
private function extractPatterns(StaticCall $node, Scope $scope): array
52+
{
53+
if (!$node->class instanceof FullyQualified) {
54+
return [];
55+
}
56+
$isRegex = $node->class->toString() === Regex::class;
57+
$isPreg = $node->class->toString() === Preg::class;
58+
if (!$isRegex && !$isPreg) {
59+
return [];
60+
}
61+
if (!$node->name instanceof Node\Identifier || !Preg::isMatch('{^(match|isMatch|grep|replace|split)}', $node->name->name)) {
62+
return [];
63+
}
64+
65+
$functionName = $node->name->name;
66+
if (!isset($node->getArgs()[0])) {
67+
return [];
68+
}
69+
70+
$patternNode = $node->getArgs()[0]->value;
71+
$patternType = $scope->getType($patternNode);
72+
73+
$patternStrings = [];
74+
75+
foreach ($patternType->getConstantStrings() as $constantStringType) {
76+
if ($functionName === 'replaceCallbackArray') {
77+
continue;
78+
}
79+
80+
$patternStrings[] = $constantStringType->getValue();
81+
}
82+
83+
foreach ($patternType->getConstantArrays() as $constantArrayType) {
84+
if (
85+
in_array($functionName, [
86+
'replace',
87+
'replaceCallback',
88+
], true)
89+
) {
90+
foreach ($constantArrayType->getValueTypes() as $arrayKeyType) {
91+
foreach ($arrayKeyType->getConstantStrings() as $constantString) {
92+
$patternStrings[] = $constantString->getValue();
93+
}
94+
}
95+
}
96+
97+
if ($functionName !== 'replaceCallbackArray') {
98+
continue;
99+
}
100+
101+
foreach ($constantArrayType->getKeyTypes() as $arrayKeyType) {
102+
foreach ($arrayKeyType->getConstantStrings() as $constantString) {
103+
$patternStrings[] = $constantString->getValue();
104+
}
105+
}
106+
}
107+
108+
return $patternStrings;
109+
}
110+
111+
private function validatePattern(string $pattern): ?string
112+
{
113+
try {
114+
$msg = null;
115+
$prev = set_error_handler(function (int $severity, string $message, string $file) use (&$msg): bool {
116+
$msg = preg_replace("#^preg_match(_all)?\\(.*?\\): #", '', $message);
117+
118+
return true;
119+
});
120+
121+
if ($pattern === '') {
122+
return 'Empty string is not a valid regular expression';
123+
}
124+
125+
Preg::match($pattern, '');
126+
if ($msg !== null) {
127+
return $msg;
128+
}
129+
} catch (PcreException $e) {
130+
if ($e->getCode() === PREG_INTERNAL_ERROR && $msg !== null) {
131+
return $msg;
132+
}
133+
134+
return preg_replace('{.*? failed executing ".*": }', '', $e->getMessage());
135+
} finally {
136+
restore_error_handler();
137+
}
138+
139+
return null;
140+
}
141+
142+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/*
4+
* This file is part of composer/pcre.
5+
*
6+
* (c) Composer <https://github.com/composer>
7+
*
8+
* For the full copyright and license information, please view
9+
* the LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace Composer\Pcre\PHPStanTests;
13+
14+
use PHPStan\Testing\RuleTestCase;
15+
use Composer\Pcre\PHPStan\InvalidRegexPatternRule;
16+
use PHPStan\Type\Php\RegexArrayShapeMatcher;
17+
18+
/**
19+
* Run with "vendor/bin/phpunit --testsuite phpstan"
20+
*
21+
* This is excluded by default to avoid side effects with the library tests
22+
*
23+
* @extends RuleTestCase<InvalidRegexPatternRule>
24+
*/
25+
class InvalidRegexPatternRuleTest extends RuleTestCase
26+
{
27+
protected function getRule(): \PHPStan\Rules\Rule
28+
{
29+
return new InvalidRegexPatternRule();
30+
}
31+
32+
public function testRule(): void
33+
{
34+
$missing = PHP_VERSION_ID < 70300 ? ')' : 'closing parenthesis';
35+
36+
$this->analyse([__DIR__ . '/fixtures/invalid-patterns.php'], [
37+
[
38+
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
39+
11,
40+
],
41+
[
42+
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
43+
13,
44+
],
45+
[
46+
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
47+
15,
48+
],
49+
[
50+
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
51+
17,
52+
],
53+
[
54+
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
55+
19,
56+
],
57+
[
58+
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
59+
21,
60+
],
61+
]);
62+
}
63+
64+
public static function getAdditionalConfigFiles(): array
65+
{
66+
return [
67+
'phar://' . __DIR__ . '/../../vendor/phpstan/phpstan/phpstan.phar/conf/bleedingEdge.neon',
68+
__DIR__ . '/../../extension.neon',
69+
];
70+
}
71+
}

tests/PHPStanTests/UnsafeStrictGroupsCallRuleTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
*
2323
* @extends RuleTestCase<UnsafeStrictGroupsCallRule>
2424
*/
25-
class UnsafeStrictGruopsCallRuleTest extends RuleTestCase
25+
class UnsafeStrictGroupsCallRuleTest extends RuleTestCase
2626
{
2727
protected function getRule(): \PHPStan\Rules\Rule
2828
{
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace PregMatchShapes;
4+
5+
use Composer\Pcre\Preg;
6+
use Composer\Pcre\Regex;
7+
use function PHPStan\Testing\assertType;
8+
9+
function doMatch(string $s): void
10+
{
11+
Preg::match('/(/i', $s, $matches);
12+
13+
Regex::isMatch('/(/i', $s);
14+
15+
Preg::grep('/(/i', [$s]);
16+
17+
Preg::replaceCallback('/(/i', function ($match) { return ''; }, $s);
18+
19+
Preg::replaceCallback(['/(/i', '{}'], function ($match) { return ''; }, $s);
20+
21+
Preg::replaceCallbackArray(['/(/i' => function ($match) { return ''; }], $s);
22+
}

0 commit comments

Comments
 (0)