Skip to content

Commit 2bbbdc6

Browse files
committed
Guard *scanf() return type extension by counter
Use the recently fixed `PrintfHelper::getScanfPlaceholdersCount()` to guard the return‑type extension against format‑induced imprecisions. The counter now guards the extension’s independent regex‑based counting, syncing it with the `PrintfParametersRule` for the first time. Invalid formats (uncountable) now return a precise error type (`NeverType`/`NullType`) instead of a false `array|null`. Valid formats (countable) that the old regex would miscount or ignore are now handled by a counter‑sized safe skeleton. The regex is now an optional precision layer, not the foundation for structural correctness. The counter overrides the regex wherever they disagree. This is the same approach as in dd63663 ("Fix counting `*scanf()` format string placeholders (#5594)", 2026-05-10) that eliminated the count regression, now applied to the return‑type logic. Removes the old bottom‑of‑method `return null` as a natural consequence. For example, an invalid format (mixing positional `%n$` with sequential `%`) now correctly returns `null` on 7.4. Gegenprobe: the counter doesn’t guess – it asks PHP itself.
1 parent 522b78b commit 2bbbdc6

5 files changed

Lines changed: 137 additions & 29 deletions

File tree

src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
use PhpParser\Node\Expr\FuncCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\DependencyInjection\AutowiredService;
8+
use PHPStan\Php\PhpVersion;
89
use PHPStan\Reflection\FunctionReflection;
10+
use PHPStan\Rules\Functions\PrintfHelper;
911
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
1012
use PHPStan\Type\Accessory\AccessoryNonFalsyStringType;
1113
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
@@ -14,6 +16,8 @@
1416
use PHPStan\Type\FloatType;
1517
use PHPStan\Type\IntegerType;
1618
use PHPStan\Type\IntersectionType;
19+
use PHPStan\Type\NeverType;
20+
use PHPStan\Type\NullType;
1721
use PHPStan\Type\StringType;
1822
use PHPStan\Type\Type;
1923
use PHPStan\Type\TypeCombinator;
@@ -25,6 +29,13 @@
2529
final class SscanfFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
2630
{
2731

32+
public function __construct(
33+
private PrintfHelper $printfHelper,
34+
private PhpVersion $phpVersion,
35+
)
36+
{
37+
}
38+
2839
public function isFunctionSupported(FunctionReflection $functionReflection): bool
2940
{
3041
return in_array($functionReflection->getName(), ['sscanf', 'fscanf'], true);
@@ -47,44 +58,70 @@ public function getTypeFromFunctionCall(
4758
}
4859
$formatType = $formatType[0];
4960

50-
if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatType->getValue(), $matches) > 0) {
51-
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
52-
53-
for ($i = 0; $i < count($matches[0]); $i++) {
54-
$length = $matches[1][$i];
55-
$specifier = $matches[2][$i];
56-
57-
$type = new StringType();
58-
if ($length !== '') {
59-
if (((int) $length) > 1) {
60-
$type = new IntersectionType([
61-
$type,
62-
new AccessoryNonFalsyStringType(),
63-
]);
64-
} else {
65-
$type = new IntersectionType([
66-
$type,
61+
$formatValue = $formatType->getValue();
62+
$placeholderCount = $this->printfHelper->getScanfPlaceholdersCount($formatValue);
63+
if ($placeholderCount === null) {
64+
return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new NullType();
65+
}
66+
67+
if ($placeholderCount === 0) {
68+
return TypeCombinator::addNull(
69+
ConstantArrayTypeBuilder::createEmpty()->getArray(),
70+
);
71+
}
72+
73+
if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) !== $placeholderCount) {
74+
$safeBuilder = ConstantArrayTypeBuilder::createEmpty();
75+
for ($i = 0; $i < $placeholderCount; ++$i) {
76+
$safeBuilder->setOffsetValueType(
77+
new ConstantIntegerType($i),
78+
TypeCombinator::union(
79+
new FloatType(),
80+
new IntegerType(),
81+
new IntersectionType([
82+
new StringType(),
6783
new AccessoryNonEmptyStringType(),
68-
]);
69-
}
70-
}
84+
]),
85+
new NullType(),
86+
),
87+
);
88+
}
89+
return TypeCombinator::addNull($safeBuilder->getArray());
90+
}
7191

72-
if (in_array($specifier, ['d', 'o', 'u', 'x'], true)) {
73-
$type = new IntegerType();
74-
}
92+
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
93+
for ($i = 0; $i < count($matches[0]); $i++) {
94+
$length = $matches[1][$i];
95+
$specifier = $matches[2][$i];
7596

76-
if (in_array($specifier, ['e', 'E', 'f'], true)) {
77-
$type = new FloatType();
97+
$type = new StringType();
98+
if ($length !== '') {
99+
if (((int) $length) > 1) {
100+
$type = new IntersectionType([
101+
$type,
102+
new AccessoryNonFalsyStringType(),
103+
]);
104+
} else {
105+
$type = new IntersectionType([
106+
$type,
107+
new AccessoryNonEmptyStringType(),
108+
]);
78109
}
110+
}
111+
112+
if (in_array($specifier, ['d', 'o', 'u', 'x'], true)) {
113+
$type = new IntegerType();
114+
}
79115

80-
$type = TypeCombinator::addNull($type);
81-
$arrayBuilder->setOffsetValueType(new ConstantIntegerType($i), $type);
116+
if (in_array($specifier, ['e', 'E', 'f'], true)) {
117+
$type = new FloatType();
82118
}
83119

84-
return TypeCombinator::addNull($arrayBuilder->getArray());
120+
$type = TypeCombinator::addNull($type);
121+
$arrayBuilder->setOffsetValueType(new ConstantIntegerType($i), $type);
85122
}
86123

87-
return null;
124+
return TypeCombinator::addNull($arrayBuilder->getArray());
88125
}
89126

90127
}

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,12 @@ private static function findTestFiles(): iterable
285285
yield __DIR__ . '/../Rules/Variables/data/bug-14124.php';
286286
yield __DIR__ . '/../Rules/Variables/data/bug-14124b.php';
287287
yield __DIR__ . '/../Rules/Arrays/data/bug-14308.php';
288+
289+
if (PHP_VERSION_ID < 80000) {
290+
yield __DIR__ . '/data/sscanf-php74.php';
291+
} else {
292+
yield __DIR__ . '/data/sscanf-php80.php';
293+
}
288294
}
289295

290296
/**
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace SscanfPHP74;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function sscanfInvalidFormatMixingPositionalWithSequential(string $s) {
8+
assertType('null', sscanf($s, '%1$s %s'));
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace SscanfPHP80;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function sscanfInvalidFormatMixingPositionalWithSequential(string $s) {
8+
assertType('*NEVER*', sscanf($s, '%1$s %s'));
9+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace Bug14597;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function sscanfNulTerminator(string $s) {
8+
// NUL byte terminates sscanf format string – placeholders after \0 are ignored
9+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%d\0%d"));
10+
assertType('array{float|int|non-empty-string|null, float|int|non-empty-string|null}|null', sscanf($s, "%d %s\0%d"));
11+
assertType('array{}|null', sscanf($s, "\0%d%s"));
12+
}
13+
14+
function fscanfNulTerminator($r) {
15+
// Same for fscanf
16+
assertType('array{float|int|non-empty-string|null}|null', fscanf($r, "%d\0%d"));
17+
assertType('array{float|int|non-empty-string|null, float|int|non-empty-string|null}|null', fscanf($r, "%d %s\0%d"));
18+
assertType('array{}|null', fscanf($r, "\0%d%s"));
19+
}
20+
21+
function sscanfEdgeCases(string $s) {
22+
// Empty format string – no placeholders
23+
assertType('array{}|null', sscanf($s, ""));
24+
25+
// %n – counts characters consumed, returns integer
26+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%n"));
27+
28+
// %% – literal percent, not a placeholder
29+
assertType('array{}|null', sscanf($s, "%%"));
30+
31+
// %i – integer with base detection
32+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%i"));
33+
34+
// %X – uppercase hex, same as %x
35+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%X"));
36+
37+
// %D – uppercase alias for %d
38+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%D"));
39+
40+
// Size modifiers (l, L, h) – consumed by ValidateFormat, no effect on PHP type
41+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%ld"));
42+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%lf"));
43+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%Lf"));
44+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%hd"));
45+
assertType('array{float|int|non-empty-string|null}|null', sscanf($s, "%lu"));
46+
assertType('array{float|int|non-empty-string|null, float|int|non-empty-string|null, float|int|non-empty-string|null}|null', sscanf($s, "%ld %lf %s"));
47+
}

0 commit comments

Comments
 (0)