From f4f5232115272e617bf2b22ab32da9f3060bea72 Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Mon, 11 May 2026 14:35:41 +0200 Subject: [PATCH 1/7] Guard `*scanf()` return type extension by counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the recently fixed `PrintfHelper::getScanfPlaceholdersCount()` to detect invalid format strings in the return type extension. If the format is uncountable, the call is guaranteed to fail – return `NullType` immediately. This is the same approach that eliminated the count regression. It already fixes all false‑positive type inferences for malformed formats. For example, invalid format (mixing positional %n$ with sequential %) returns `null`. Gegenprobe: the counter doesn’t guess – it asks PHP itself. --- .../SscanfFunctionDynamicReturnTypeExtension.php | 14 +++++++++++++- tests/PHPStan/Analyser/nsrt/sscanf.php | 4 ++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index de22ba0a461..ad2c3c0e97f 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Rules\Functions\PrintfHelper; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; @@ -15,6 +16,7 @@ use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NullType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -26,6 +28,10 @@ final class SscanfFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + public function __construct(private PrintfHelper $printfHelper) + { + } + public function isFunctionSupported(FunctionReflection $functionReflection): bool { return in_array($functionReflection->getName(), ['sscanf', 'fscanf'], true); @@ -48,7 +54,13 @@ public function getTypeFromFunctionCall( return null; } - if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatType->getValue(), $matches) > 0) { + $formatValue = $formatType->getValue(); + $placeholderCount = $this->printfHelper->getScanfPlaceholdersCount($formatValue); + if ($placeholderCount === null) { + return new NullType(); + } + + if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); for ($i = 0; $i < count($matches[0]); $i++) { diff --git a/tests/PHPStan/Analyser/nsrt/sscanf.php b/tests/PHPStan/Analyser/nsrt/sscanf.php index 484febdf9b4..2f58a13313d 100644 --- a/tests/PHPStan/Analyser/nsrt/sscanf.php +++ b/tests/PHPStan/Analyser/nsrt/sscanf.php @@ -55,3 +55,7 @@ function sscanfSuppression(string $s) { assertType('array{string|null}|null', sscanf($s, '%*d %s')); assertType('array{int|null}|null', sscanf($s, '%*[a-z]%d')); } + +function sscanfInvalidFormatMixingPositionalWithSequential(string $s) { + assertType('null', sscanf($s, '%1$s %s')); +} From c67ad8d29ac3c50cfefebd38985a451f97013012 Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Mon, 11 May 2026 19:22:40 +0200 Subject: [PATCH 2/7] fixup! Guard `*scanf()` return type extension by counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return `NeverType` on PHP 8+ (where an invalid format throws `ValueError`) and `NullType` on PHP < 8.0. This makes the gatekeeper precise about the call never returning on modern PHP. The test strategy for the version‑dependent types will be settled in review – the CI now whispers `*NEVER*` where `null` stood before, and `array{}|null` where `array|null` stood before. --- .../SscanfFunctionDynamicReturnTypeExtension.php | 15 ++++++++++----- tests/PHPStan/Analyser/NodeScopeResolverTest.php | 6 ++++++ tests/PHPStan/Analyser/data/sscanf-php74.php | 9 +++++++++ tests/PHPStan/Analyser/data/sscanf-php80.php | 9 +++++++++ tests/PHPStan/Analyser/nsrt/sscanf.php | 4 ---- 5 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/data/sscanf-php74.php create mode 100644 tests/PHPStan/Analyser/data/sscanf-php80.php diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index ad2c3c0e97f..269eed43921 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; use PHPStan\Rules\Functions\PrintfHelper; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; @@ -16,6 +17,7 @@ use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -28,7 +30,10 @@ final class SscanfFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public function __construct(private PrintfHelper $printfHelper) + public function __construct( + private PrintfHelper $printfHelper, + private PhpVersion $phpVersion, + ) { } @@ -57,11 +62,12 @@ public function getTypeFromFunctionCall( $formatValue = $formatType->getValue(); $placeholderCount = $this->printfHelper->getScanfPlaceholdersCount($formatValue); if ($placeholderCount === null) { - return new NullType(); + return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new NullType(); } + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { - $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); for ($i = 0; $i < count($matches[0]); $i++) { $length = $matches[1][$i]; @@ -94,10 +100,9 @@ public function getTypeFromFunctionCall( $arrayBuilder->setOffsetValueType(new ConstantIntegerType($i), $type); } - return TypeCombinator::addNull($arrayBuilder->getArray()); } - return null; + return TypeCombinator::addNull($arrayBuilder->getArray()); } } diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index a8903a13df5..ced33a0ee59 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -284,6 +284,12 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Variables/data/bug-14124.php'; yield __DIR__ . '/../Rules/Variables/data/bug-14124b.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-14308.php'; + + if (PHP_VERSION_ID < 80000) { + yield __DIR__ . '/data/sscanf-php74.php'; + } else { + yield __DIR__ . '/data/sscanf-php80.php'; + } } /** diff --git a/tests/PHPStan/Analyser/data/sscanf-php74.php b/tests/PHPStan/Analyser/data/sscanf-php74.php new file mode 100644 index 00000000000..19b89bc0bb4 --- /dev/null +++ b/tests/PHPStan/Analyser/data/sscanf-php74.php @@ -0,0 +1,9 @@ + Date: Sat, 16 May 2026 19:26:22 +0200 Subject: [PATCH 3/7] Truncate `*scanf()` format at NUL byte nsrt tests Regression cases for the function's dynamic return type extension. Taken from the earlier PR. Paint the build red. --- tests/PHPStan/Analyser/nsrt/bug-14567.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14567.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-14567.php b/tests/PHPStan/Analyser/nsrt/bug-14567.php new file mode 100644 index 00000000000..d838290164a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14567.php @@ -0,0 +1,19 @@ + Date: Sat, 16 May 2026 19:33:30 +0200 Subject: [PATCH 4/7] Truncate `*scanf()` format at NUL byte for return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In PHP’s sscanf/fscanf, a NUL byte (\0) in the format string terminates parsing at the C level. The return type extension was not truncating the format, so placeholders after a NUL could leak into the inferred type. Truncate the format string at the first NUL byte before matching conversion specifiers in SscanfFunctionDynamicReturnTypeExtension. The counter already handles NUL correctly because the runtime sscanf call stops at the NUL. --- src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index 269eed43921..6339f95d0bb 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -25,6 +25,7 @@ use function count; use function in_array; use function preg_match_all; +use function strstr; #[AutowiredService] final class SscanfFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -65,6 +66,11 @@ public function getTypeFromFunctionCall( return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new NullType(); } + $beforeNul = strstr($formatValue, "\0", true); + if ($beforeNul !== false) { + $formatValue = $beforeNul; + } + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { From f47c49c22d171a11f26a018c25aa737e7bf6495a Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Sat, 16 May 2026 19:54:47 +0200 Subject: [PATCH 5/7] Empty `*scanf()` format returns empty array type Refine return type, refine the test data. Paint the build red again. --- tests/PHPStan/Analyser/nsrt/bug-14567.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14567.php b/tests/PHPStan/Analyser/nsrt/bug-14567.php index d838290164a..d04b32665ad 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14567.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14567.php @@ -8,12 +8,20 @@ function sscanfNulTerminator(string $s) { // NUL byte terminates sscanf format string - placeholders after \0 are ignored assertType('array{int|null}|null', sscanf($s, "%d\0%d")); assertType('array{int|null, string|null}|null', sscanf($s, "%d %s\0%d")); - assertType('array{}|null', sscanf($s, "\0%d%s")); + assertType('array{}', sscanf($s, "\0%d%s")); } function fscanfNulTerminator($r) { // Same for fscanf assertType('array{int|null}|null', fscanf($r, "%d\0%d")); assertType('array{int|null, string|null}|null', fscanf($r, "%d %s\0%d")); - assertType('array{}|null', fscanf($r, "\0%d%s")); + assertType('array{}', fscanf($r, "\0%d%s")); +} + +function sscanfEdgeCases(string $s) { + // Empty format string - no placeholders + assertType('array{}', sscanf($s, "")); + + // %% - literal percent, not a placeholder + assertType('array{}|null', sscanf($s, "%%")); } From 7d7b1e6e35d5ee97a75ea33f4d3b97f3f14bc3e1 Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Sat, 16 May 2026 20:05:31 +0200 Subject: [PATCH 6/7] Empty `*scanf()` format returns empty array type When the format string is empty (e.g. after NUL truncation, as can be seen on 3v4l.org [1]), sscanf returns an empty array, not null, on all PHP versions. Return the precise `array{}` type instead of falling through to the default `array{}|null` signature. [1]: https://3v4l.org/Xpbu5 --- src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index 6339f95d0bb..9bd7dcc86d5 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -73,6 +73,10 @@ public function getTypeFromFunctionCall( $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + if ($formatValue === '') { + return $arrayBuilder->getArray(); + } + if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { for ($i = 0; $i < count($matches[0]); $i++) { From 6b2e819b498140d531254121373c0ed6c0bbd1df Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Sat, 16 May 2026 22:13:40 +0200 Subject: [PATCH 7/7] Skip regex when the counter says zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No need to run the preg_match_all machinery when we already know there are no placeholders – the counter has already told us. --- src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index 9bd7dcc86d5..a079f62fe8f 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -77,6 +77,10 @@ public function getTypeFromFunctionCall( return $arrayBuilder->getArray(); } + if ($placeholderCount === 0) { + return TypeCombinator::addNull($arrayBuilder->getArray()); + } + if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { for ($i = 0; $i < count($matches[0]); $i++) {