Skip to content
10 changes: 9 additions & 1 deletion src/Rules/Functions/PrintfHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use function max;
use function sprintf;
use function strlen;
use function strstr;
use const PREG_SET_ORDER;

#[AutowiredService]
Expand All @@ -37,14 +38,21 @@ public function getPrintfPlaceholders(string $format): ?array

public function getScanfPlaceholdersCount(string $format): ?int
{
return $this->getPlaceholdersCount('(?<specifier>[cdDeEfinosuxX%s]|\[[^\]]+\])', $format, true);
return $this->getPlaceholdersCount('(?:[lLh]?(?<specifier>[cdDeEfginosuxX%s]|\[[^\]]+\]))', $format, true);
}

/**
* @phpstan-return array<int, non-empty-list<PrintfPlaceholder>>|null parameter index => placeholders
*/
private function parsePlaceholders(string $specifiersPattern, string $format, bool $isScanf): ?array
{
if ($isScanf) {
$beforeNul = strstr($format, "\0", true);
if ($beforeNul !== false) {
$format = $beforeNul;
}
}

$addSpecifier = '';
if ($this->phpVersion->supportsHhPrintfSpecifier()) {
$addSpecifier .= 'hH';
Expand Down
23 changes: 16 additions & 7 deletions src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use function count;
use function in_array;
use function preg_match_all;
use function strstr;

#[AutowiredService]
final class SscanfFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
Expand Down Expand Up @@ -48,9 +49,15 @@ public function getTypeFromFunctionCall(
return null;
}

if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatType->getValue(), $matches) > 0) {
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
$formatValue = $formatType->getValue();
$beforeNul = strstr($formatValue, "\0", true);
if ($beforeNul !== false) {
$formatValue = $beforeNul;
}

$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();

if (preg_match_all('/%(\d*)[lLh]?(\[[^\]]+\]|[cDdeEfginosuxX])/', $formatValue, $matches) > 0) {
for ($i = 0; $i < count($matches[0]); $i++) {
$length = $matches[1][$i];
$specifier = $matches[2][$i];
Expand All @@ -70,22 +77,24 @@ public function getTypeFromFunctionCall(
}
}

if (in_array($specifier, ['d', 'o', 'u', 'x'], true)) {
if (in_array($specifier, ['d', 'D', 'i', 'n', 'o', 'x', 'X'], true)) {
$type = new IntegerType();
}

if (in_array($specifier, ['e', 'E', 'f'], true)) {
if ($specifier === 'u') {
$type = TypeCombinator::union(new IntegerType(), new StringType());
}

if (in_array($specifier, ['e', 'E', 'f', 'g'], true)) {
$type = new FloatType();
}

$type = TypeCombinator::addNull($type);
$arrayBuilder->setOffsetValueType(new ConstantIntegerType($i), $type);
}

return TypeCombinator::addNull($arrayBuilder->getArray());
}

return null;
return TypeCombinator::addNull($arrayBuilder->getArray());
}

}
56 changes: 56 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14567.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Bug14567;

use function PHPStan\Testing\assertType;

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"));
}

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"));
}

function sscanfEdgeCases(string $s) {
// Empty format string - no placeholders
assertType('array{}|null', sscanf($s, ""));

// %n - counts characters consumed, returns integer
assertType('array{int|null}|null', sscanf($s, "%n"));

// %% - literal percent, not a placeholder
assertType('array{}|null', sscanf($s, "%%"));

// %i - integer with base detection
assertType('array{int|null}|null', sscanf($s, "%i"));

// %X - uppercase hex, same as %x
assertType('array{int|null}|null', sscanf($s, "%X"));

// %D - uppercase alias for %d
assertType('array{int|null}|null', sscanf($s, "%D"));

// %g - general float
assertType('array{float|null}|null', sscanf($s, "%g"));

// %u - unsigned integer, can return string for values > PHP_INT_MAX
assertType('array{int|string|null}|null', sscanf($s, "%u"));

// mixed specifiers with %n
assertType('array{int|null, int|null}|null', sscanf($s, "%d%n"));

// Size modifiers (l, L, h) — consumed by ValidateFormat, no effect on PHP type
assertType('array{int|null}|null', sscanf($s, "%ld"));
assertType('array{float|null}|null', sscanf($s, "%lf"));
assertType('array{float|null}|null', sscanf($s, "%Lf"));
assertType('array{int|null}|null', sscanf($s, "%hd"));
assertType('array{int|string|null}|null', sscanf($s, "%lu"));
assertType('array{int|null, float|null, string|null}|null', sscanf($s, "%ld %lf %s"));
}
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/sscanf.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function sscanfFormatInference(string $s) {
assertType('array{float|null}|null', sscanf($s, '%f'));
assertType('array{int|null}|null', sscanf($s, '%o'));
assertType('array{string|null}|null', sscanf($s, '%s'));
assertType('array{int|null}|null', sscanf($s, '%u'));
assertType('array{int|string|null}|null', sscanf($s, '%u'));
assertType('array{int|null}|null', sscanf($s, '%x'));

$mandate = "January 01 2000";
Expand Down
5 changes: 5 additions & 0 deletions tests/PHPStan/Rules/Functions/PrintfParametersRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,9 @@ public function testBug10260(): void
$this->analyse([__DIR__ . '/data/bug-10260.php'], []);
}

public function testBug14567(): void
{
$this->analyse([__DIR__ . '/data/bug-14567.php'], []);
}

}
51 changes: 51 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-14567.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace Bug14567;

// NUL byte terminates sscanf/fscanf format string parsing
// Placeholders after \0 should not be counted

// Only 1 placeholder active before NUL
sscanf('123 456', "%d\0%d", $a);

// Only 1 placeholder active before NUL (fscanf)
fscanf(STDIN, "%d\0%d", $a2);

// No placeholders after NUL
sscanf('123', "\0%d");

// Multiple placeholders, NUL in middle
sscanf('123 456 789', "%d %d\0%d", $b, $c);

// %n specifier - counts characters consumed, 1 placeholder
sscanf('hello', "%n", $n);

// %% - literal percent, 0 placeholders
sscanf('100%', "100%%");

// %i specifier - integer with base detection, 1 placeholder
sscanf('0xff', "%i", $hex);

// Mixed with %n
sscanf('hello world', "%s%n", $word, $pos);

// %D specifier - uppercase alias for %d, 1 placeholder
sscanf('42', "%D", $dval);

// %g specifier - general float, 1 placeholder
sscanf('1.5', "%g", $gval);

// Size modifiers (l, L, h) - consumed before specifier, 1 placeholder each
sscanf('42', "%ld", $long);
sscanf('3.14', "%lf", $longf);
sscanf('3.14', "%Lf", $longdouble);
sscanf('42', "%hd", $short);

// Size modifier with width
sscanf('42', "%10ld", $widelong);

// Size modifier with suppression - 0 capturing placeholders
sscanf('42 hello', "%*ld %s", $afterskip);

// Mixed size modifiers
sscanf('42 3.14 hello', "%ld %lf %s", $mix1, $mix2, $mix3);
Loading