Skip to content

Commit e35eae4

Browse files
authored
Bleeding edge: Check vprintf/vsprintf arguments against placeholder count
1 parent 3a784d6 commit e35eae4

File tree

7 files changed

+338
-0
lines changed

7 files changed

+338
-0
lines changed

conf/bleedingEdge.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,6 @@ parameters:
5656
checkParameterCastableToStringFunctions: true
5757
narrowPregMatches: true
5858
uselessReturnValue: true
59+
printfArrayParameters: true
5960
stubFiles:
6061
- ../stubs/bleedingEdge/Rule.stub

conf/config.level0.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ conditionalTags:
2626
phpstan.rules.rule: %featureToggles.magicConstantOutOfContext%
2727
PHPStan\Rules\Functions\UselessFunctionReturnValueRule:
2828
phpstan.rules.rule: %featureToggles.uselessReturnValue%
29+
PHPStan\Rules\Functions\PrintfArrayParametersRule:
30+
phpstan.rules.rule: %featureToggles.printfArrayParameters%
2931

3032
rules:
3133
- PHPStan\Rules\Api\ApiInstantiationRule
@@ -298,3 +300,6 @@ services:
298300

299301
-
300302
class: PHPStan\Rules\Functions\UselessFunctionReturnValueRule
303+
304+
-
305+
class: PHPStan\Rules\Functions\PrintfArrayParametersRule

conf/config.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ parameters:
9191
checkParameterCastableToStringFunctions: false
9292
narrowPregMatches: false
9393
uselessReturnValue: false
94+
printfArrayParameters: false
9495
fileExtensions:
9596
- php
9697
checkAdvancedIsset: false

conf/parametersSchema.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ parametersSchema:
8686
checkParameterCastableToStringFunctions: bool()
8787
narrowPregMatches: bool()
8888
uselessReturnValue: bool()
89+
printfArrayParameters: bool()
8990
])
9091
fileExtensions: listOf(string())
9192
checkAdvancedIsset: bool()
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\ReflectionProvider;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use PHPStan\ShouldNotHappenException;
12+
use PHPStan\Type\Constant\ConstantIntegerType;
13+
use PHPStan\Type\IntegerRangeType;
14+
use PHPStan\Type\TypeCombinator;
15+
use function count;
16+
use function in_array;
17+
use function max;
18+
use function min;
19+
use function sprintf;
20+
21+
/**
22+
* @implements Rule<Node\Expr\FuncCall>
23+
*/
24+
class PrintfArrayParametersRule implements Rule
25+
{
26+
27+
public function __construct(
28+
private PrintfHelper $printfHelper,
29+
private ReflectionProvider $reflectionProvider,
30+
)
31+
{
32+
}
33+
34+
public function getNodeType(): string
35+
{
36+
return FuncCall::class;
37+
}
38+
39+
public function processNode(Node $node, Scope $scope): array
40+
{
41+
if (!($node->name instanceof Node\Name)) {
42+
return [];
43+
}
44+
45+
if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
46+
return [];
47+
}
48+
49+
$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
50+
$name = $functionReflection->getName();
51+
if (!in_array($name, ['vprintf', 'vsprintf'], true)) {
52+
return [];
53+
}
54+
55+
$args = $node->getArgs();
56+
$argsCount = count($args);
57+
if ($argsCount < 1) {
58+
return []; // caught by CallToFunctionParametersRule
59+
}
60+
61+
$formatArgType = $scope->getType($args[0]->value);
62+
$placeHoldersCounts = [];
63+
foreach ($formatArgType->getConstantStrings() as $formatString) {
64+
$format = $formatString->getValue();
65+
66+
$placeHoldersCounts[] = $this->printfHelper->getPrintfPlaceholdersCount($format);
67+
}
68+
69+
if ($placeHoldersCounts === []) {
70+
return [];
71+
}
72+
73+
$minCount = min($placeHoldersCounts);
74+
$maxCount = max($placeHoldersCounts);
75+
if ($minCount === $maxCount) {
76+
$placeHoldersCount = new ConstantIntegerType($minCount);
77+
} else {
78+
$placeHoldersCount = IntegerRangeType::fromInterval($minCount, $maxCount);
79+
80+
if (!$placeHoldersCount instanceof IntegerRangeType && !$placeHoldersCount instanceof ConstantIntegerType) {
81+
return [];
82+
}
83+
}
84+
85+
$formatArgsCounts = [];
86+
if (isset($args[1])) {
87+
$formatArgsType = $scope->getType($args[1]->value);
88+
89+
$constantArrays = $formatArgsType->getConstantArrays();
90+
foreach ($constantArrays as $constantArray) {
91+
$formatArgsCounts[] = $constantArray->getArraySize();
92+
}
93+
94+
if ($constantArrays === []) {
95+
$formatArgsCounts[] = $formatArgsType->getArraySize();
96+
}
97+
}
98+
99+
if ($formatArgsCounts === []) {
100+
$formatArgsCount = new ConstantIntegerType(0);
101+
} else {
102+
$formatArgsCount = TypeCombinator::union(...$formatArgsCounts);
103+
104+
if (!$formatArgsCount instanceof IntegerRangeType && !$formatArgsCount instanceof ConstantIntegerType) {
105+
return [];
106+
}
107+
}
108+
109+
if (!$this->placeholdersMatchesArgsCount($placeHoldersCount, $formatArgsCount)) {
110+
111+
if ($placeHoldersCount instanceof IntegerRangeType) {
112+
$placeholders = $this->getIntegerRangeAsString($placeHoldersCount);
113+
$singlePlaceholder = false;
114+
} else {
115+
$placeholders = $placeHoldersCount->getValue();
116+
$singlePlaceholder = $placeholders === 1;
117+
}
118+
119+
if ($formatArgsCount instanceof IntegerRangeType) {
120+
$values = $this->getIntegerRangeAsString($formatArgsCount);
121+
$singleValue = false;
122+
} else {
123+
$values = $formatArgsCount->getValue();
124+
$singleValue = $values === 1;
125+
}
126+
127+
return [
128+
RuleErrorBuilder::message(sprintf(
129+
sprintf(
130+
'%s, %s.',
131+
$singlePlaceholder ? 'Call to %s contains %d placeholder' : 'Call to %s contains %s placeholders',
132+
$singleValue ? '%d value given' : '%s values given',
133+
),
134+
$name,
135+
$placeholders,
136+
$values,
137+
))->identifier(sprintf('argument.%s', $name))->build(),
138+
];
139+
}
140+
141+
return [];
142+
}
143+
144+
private function placeholdersMatchesArgsCount(ConstantIntegerType|IntegerRangeType $placeHoldersCount, ConstantIntegerType|IntegerRangeType $formatArgsCount): bool
145+
{
146+
if ($placeHoldersCount instanceof ConstantIntegerType) {
147+
if ($formatArgsCount instanceof ConstantIntegerType) {
148+
return $placeHoldersCount->getValue() === $formatArgsCount->getValue();
149+
}
150+
151+
// Zero placeholders + array
152+
if ($placeHoldersCount->getValue() === 0) {
153+
return true;
154+
}
155+
156+
return false;
157+
}
158+
159+
if (
160+
$formatArgsCount instanceof IntegerRangeType
161+
&& IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($placeHoldersCount)->yes()
162+
) {
163+
if ($formatArgsCount->getMin() !== null && $formatArgsCount->getMax() !== null) {
164+
// constant array
165+
return $placeHoldersCount->isSuperTypeOf($formatArgsCount)->yes();
166+
}
167+
168+
// general array
169+
return IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($formatArgsCount)->yes();
170+
}
171+
172+
return false;
173+
}
174+
175+
private function getIntegerRangeAsString(IntegerRangeType $range): string
176+
{
177+
if ($range->getMin() !== null && $range->getMax() !== null) {
178+
return $range->getMin() . '-' . $range->getMax();
179+
} elseif ($range->getMin() !== null) {
180+
return $range->getMin() . ' or more';
181+
} elseif ($range->getMax() !== null) {
182+
return $range->getMax() . ' or less';
183+
}
184+
185+
throw new ShouldNotHappenException();
186+
}
187+
188+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PHPStan\Php\PhpVersion;
6+
use PHPStan\Rules\Rule;
7+
use PHPStan\Testing\RuleTestCase;
8+
use const PHP_VERSION_ID;
9+
10+
/**
11+
* @extends RuleTestCase<PrintfArrayParametersRule>
12+
*/
13+
class PrintfArrayParametersRuleTest extends RuleTestCase
14+
{
15+
16+
protected function getRule(): Rule
17+
{
18+
return new PrintfArrayParametersRule(
19+
new PrintfHelper(new PhpVersion(PHP_VERSION_ID)),
20+
$this->createReflectionProvider(),
21+
);
22+
}
23+
24+
public function testFile(): void
25+
{
26+
$this->analyse([__DIR__ . '/data/vprintf.php'], [
27+
[
28+
'Call to vsprintf contains 2 placeholders, 1 value given.',
29+
10,
30+
],
31+
[
32+
'Call to vsprintf contains 0 placeholders, 1 value given.',
33+
11,
34+
],
35+
[
36+
'Call to vsprintf contains 1 placeholder, 2 values given.',
37+
12,
38+
],
39+
[
40+
'Call to vsprintf contains 2 placeholders, 1 value given.',
41+
13,
42+
],
43+
[
44+
'Call to vsprintf contains 2 placeholders, 0 values given.',
45+
14,
46+
],
47+
[
48+
'Call to vsprintf contains 2 placeholders, 0 values given.',
49+
15,
50+
],
51+
[
52+
'Call to vsprintf contains 4 placeholders, 0 values given.',
53+
16,
54+
],
55+
[
56+
'Call to vsprintf contains 5 placeholders, 2 values given.',
57+
18,
58+
],
59+
[
60+
'Call to vsprintf contains 1 placeholder, 2 values given.',
61+
21,
62+
],
63+
[
64+
'Call to vsprintf contains 1 placeholder, 1-2 values given.',
65+
29,
66+
],
67+
[
68+
'Call to vprintf contains 2 placeholders, 1 value given.',
69+
34,
70+
],
71+
[
72+
'Call to vsprintf contains 1-2 placeholders, 0 or more values given.',
73+
53,
74+
],
75+
]);
76+
}
77+
78+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace PrintfArrayParametersRuleTest;
4+
5+
function doFoo($message, array $arr) {
6+
vsprintf($message, 'foo'); // skip - format not a literal string
7+
8+
vsprintf('%s', ['foo']); // ok
9+
vsprintf('%s %% %% %s', ['foo', 'bar']); // ok
10+
vsprintf('%s %s', ['foo']); // one parameter missing
11+
vsprintf('foo', ['foo']); // one parameter over
12+
vsprintf('foo %s', ['foo', 'bar']); // one parameter over
13+
vsprintf('%2$s %1$s %% %1$s %%%', ['one']); // one parameter missing
14+
vsprintf('%2$s %%'); // two parameters required
15+
vsprintf('%2$s %%', []); // two parameters required
16+
vsprintf('%2$s %1$s %1$s %s %s %s %s'); // four parameters required
17+
vsprintf('%2$s %1$s %% %s %s %s %s %%% %%%%', ['one', 'two', 'three', 'four']); // ok
18+
vsprintf("%'.9d %1$'.9d %0.3f %d %d %d", [123, 456]); // five parameters required
19+
20+
vsprintf('%-4s', ['foo']); // ok
21+
vsprintf('%%s %s', ['foo', 'bar']); // one parameter over
22+
23+
24+
if (rand(0,1)) {
25+
$args = ['foo'];
26+
} else {
27+
$args = ['foo', 'bar'];
28+
}
29+
vsprintf('%-4s', $args); // one path with wrong number of args
30+
31+
32+
vprintf('%s', ['foo']); // ok
33+
vprintf('%s %% %% %s', ['foo', 'bar']); // ok
34+
vprintf('%s %s', ['foo']); // one parameter missing
35+
vprintf('abc'); // caught by CallToFunctionParametersRule
36+
vsprintf('abc', []); // ok
37+
vsprintf('abc', $arr); // ok
38+
39+
if (rand(0,1)) {
40+
$format = '%s';
41+
$args = ['foo'];
42+
} else {
43+
$format = '%s%s';
44+
$args = ['foo', 'bar'];
45+
}
46+
vsprintf($format, $args); // ok
47+
48+
if (rand(0,1)) {
49+
$format = '%s';
50+
} else {
51+
$format = '%s%s';
52+
}
53+
vsprintf($format, $arr); // need at least non-empty-array
54+
55+
if (rand(0,1)) {
56+
$format = '%s';
57+
} else {
58+
$format = '%s%s';
59+
}
60+
if ($arr !== []) {
61+
vsprintf($format, $arr); // ok
62+
}
63+
64+
}

0 commit comments

Comments
 (0)