Skip to content

Commit 735a6c1

Browse files
phpstan-botstaabmclaude
authored
Forget the tracked ob_get_level() after impure calls that may open or close output buffers (#5964)
Co-authored-by: staabm <120441+staabm@users.noreply.github.com> Co-authored-by: Markus Staab <markus.staab@redaxo.de> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent aea91fd commit 735a6c1

8 files changed

Lines changed: 356 additions & 25 deletions

File tree

‎src/Analyser/ExprHandler/FuncCallHandler.php‎

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
use PhpParser\Node;
77
use PhpParser\Node\Arg;
88
use PhpParser\Node\Expr;
9-
use PhpParser\Node\Expr\BinaryOp;
109
use PhpParser\Node\Expr\FuncCall;
1110
use PhpParser\Node\Expr\MethodCall;
1211
use PhpParser\Node\Expr\PropertyFetch;
@@ -19,6 +18,7 @@
1918
use PHPStan\Analyser\ExpressionResult;
2019
use PHPStan\Analyser\ExpressionResultStorage;
2120
use PHPStan\Analyser\ExprHandler;
21+
use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper;
2222
use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer;
2323
use PHPStan\Analyser\ImpurePoint;
2424
use PHPStan\Analyser\InternalThrowPoint;
@@ -53,7 +53,6 @@
5353
use PHPStan\Type\ClosureType;
5454
use PHPStan\Type\Constant\ConstantArrayType;
5555
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
56-
use PHPStan\Type\Constant\ConstantIntegerType;
5756
use PHPStan\Type\ErrorType;
5857
use PHPStan\Type\GeneralizePrecision;
5958
use PHPStan\Type\Generic\TemplateTypeHelper;
@@ -572,29 +571,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
572571
$scope = $scope->afterOpenSslCall($functionReflection->getName());
573572
}
574573

575-
if ($functionReflection !== null) {
576-
$functionName = $functionReflection->getName();
577-
if ($functionName === 'ob_start') {
578-
$outputBufferDelta = 1;
579-
} elseif (in_array($functionName, ['ob_get_clean', 'ob_get_flush', 'ob_end_clean', 'ob_end_flush'], true)) {
580-
$outputBufferDelta = -1;
581-
} else {
582-
$outputBufferDelta = 0;
583-
}
584-
if ($outputBufferDelta !== 0) {
585-
$obGetLevelCall = new FuncCall(new Name('ob_get_level'), []);
586-
$scope = $scope->assignExpression(
587-
$obGetLevelCall,
588-
$scope->getType(new BinaryOp\Plus(
589-
new TypeExpr($scope->getType($obGetLevelCall)),
590-
new TypeExpr(new ConstantIntegerType($outputBufferDelta)),
591-
)),
592-
$scope->getType(new BinaryOp\Plus(
593-
new TypeExpr($scope->getNativeType($obGetLevelCall)),
594-
new TypeExpr(new ConstantIntegerType($outputBufferDelta)),
595-
)),
596-
);
597-
}
574+
$outputBufferDelta = $functionReflection !== null ? OutputBufferHelper::getLevelDelta($functionReflection->getName()) : 0;
575+
if ($outputBufferDelta !== 0) {
576+
$scope = OutputBufferHelper::applyLevelDelta($scope, $outputBufferDelta);
577+
}
578+
579+
$pureCallable = $parametersAcceptor instanceof CallableParametersAcceptor
580+
&& count($parametersAcceptor->getImpurePoints()) === 0;
581+
if (
582+
($functionReflection !== null && !$functionReflection->isBuiltin() && !$functionReflection->hasSideEffects()->no())
583+
|| ($functionReflection === null && !$pureCallable)
584+
) {
585+
$scope = $scope->invalidateVolatileExpressions();
598586
}
599587

600588
return new ExpressionResult(
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser\ExprHandler\Helper;
4+
5+
use PhpParser\Node\Expr\BinaryOp;
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PhpParser\Node\Name;
8+
use PHPStan\Analyser\MutatingScope;
9+
use PHPStan\Node\Expr\TypeExpr;
10+
use PHPStan\Type\Constant\ConstantIntegerType;
11+
use function in_array;
12+
13+
final class OutputBufferHelper
14+
{
15+
16+
private const LEVEL_INCREMENTING_FUNCTIONS = ['ob_start'];
17+
18+
private const LEVEL_DECREMENTING_FUNCTIONS = ['ob_get_clean', 'ob_get_flush', 'ob_end_clean', 'ob_end_flush'];
19+
20+
public static function getLevelDelta(string $functionName): int
21+
{
22+
if (in_array($functionName, self::LEVEL_INCREMENTING_FUNCTIONS, true)) {
23+
return 1;
24+
}
25+
26+
if (in_array($functionName, self::LEVEL_DECREMENTING_FUNCTIONS, true)) {
27+
return -1;
28+
}
29+
30+
return 0;
31+
}
32+
33+
public static function applyLevelDelta(MutatingScope $scope, int $delta): MutatingScope
34+
{
35+
foreach ([new Name('ob_get_level'), new Name\FullyQualified('ob_get_level')] as $name) {
36+
$obGetLevelCall = new FuncCall($name, []);
37+
38+
$scope = $scope->assignExpression(
39+
$obGetLevelCall,
40+
$scope->getType(new BinaryOp\Plus(
41+
new TypeExpr($scope->getType($obGetLevelCall)),
42+
new TypeExpr(new ConstantIntegerType($delta)),
43+
)),
44+
$scope->getType(new BinaryOp\Plus(
45+
new TypeExpr($scope->getNativeType($obGetLevelCall)),
46+
new TypeExpr(new ConstantIntegerType($delta)),
47+
)),
48+
);
49+
}
50+
51+
return $scope;
52+
}
53+
54+
}

‎src/Analyser/ExprHandler/MethodCallHandler.php‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
186186
$scope = $scope->invalidateExpression($normalizedExpr->var, true);
187187
$throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr);
188188
}
189+
if (
190+
$methodReflection === null
191+
|| (!$methodReflection->getDeclaringClass()->isBuiltin() && !$methodReflection->hasSideEffects()->no())
192+
) {
193+
$scope = $scope->invalidateVolatileExpressions();
194+
}
195+
189196
$hasYield = $hasYield || $argsResult->hasYield();
190197
$throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints());
191198
$impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints());

‎src/Analyser/ExprHandler/NewHandler.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
215215
$throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr);
216216
}
217217

218+
$calleeKnown = $classReflection !== null && !($isDynamic && !$classReflection->isFinal());
219+
if (
220+
($constructorReflection !== null && !$constructorReflection->getDeclaringClass()->isBuiltin() && !$constructorReflection->hasSideEffects()->no())
221+
|| ($constructorReflection === null && !$calleeKnown)
222+
) {
223+
$scope = $scope->invalidateVolatileExpressions();
224+
}
225+
218226
return new ExpressionResult(
219227
$scope,
220228
hasYield: $hasYield,

‎src/Analyser/ExprHandler/StaticCallHandler.php‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
274274
}
275275
}
276276

277+
if (
278+
$methodReflection === null
279+
|| (!$methodReflection->getDeclaringClass()->isBuiltin() && !$methodReflection->hasSideEffects()->no())
280+
) {
281+
$scope = $scope->invalidateVolatileExpressions();
282+
}
283+
277284
$hasYield = $hasYield || $argsResult->hasYield();
278285
$throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints());
279286
$impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints());

‎src/Analyser/MutatingScope.php‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,60 @@ public function afterOpenSslCall(string $openSslFunctionName): self
638638
);
639639
}
640640

641+
/**
642+
* Forgets every tracked volatile global-state expression: argument-less
643+
* function-call expressions whose value reflects mutable global/output-buffer
644+
* state rather than just their arguments (ob_get_level(), openssl_error_string()).
645+
* Any call to code PHPStan cannot inspect may change that state transitively, so
646+
* these narrowings must be forgotten afterwards. These functions take no
647+
* arguments, so the exact key lookups keep this O(1) in the common case where
648+
* nothing is tracked.
649+
*/
650+
public function invalidateVolatileExpressions(): self
651+
{
652+
$expressionTypes = $this->expressionTypes;
653+
$nativeExpressionTypes = $this->nativeExpressionTypes;
654+
655+
$changed = false;
656+
foreach (['ob_get_level', 'openssl_error_string'] as $functionName) {
657+
foreach ([$functionName . '()', '\\' . $functionName . '()'] as $exprString) {
658+
if (
659+
!array_key_exists($exprString, $expressionTypes)
660+
&& !array_key_exists($exprString, $nativeExpressionTypes)
661+
) {
662+
continue;
663+
}
664+
665+
unset($expressionTypes[$exprString]);
666+
unset($nativeExpressionTypes[$exprString]);
667+
$changed = true;
668+
}
669+
}
670+
671+
if (!$changed) {
672+
return $this;
673+
}
674+
675+
return $this->scopeFactory->create(
676+
$this->context,
677+
$this->isDeclareStrictTypes(),
678+
$this->getFunction(),
679+
$this->getNamespace(),
680+
$expressionTypes,
681+
$nativeExpressionTypes,
682+
$this->conditionalExpressions,
683+
$this->inClosureBindScopeClasses,
684+
$this->anonymousFunctionReflection,
685+
$this->isInFirstLevelStatement(),
686+
$this->currentlyAssignedExpressions,
687+
$this->currentlyAllowedUndefinedExpressions,
688+
$this->inFunctionCallsStack,
689+
$this->afterExtractCall,
690+
$this->parentScope,
691+
$this->nativeTypesPromoted,
692+
);
693+
}
694+
641695
/** @api */
642696
public function hasVariableType(string $variableName): TrinaryLogic
643697
{

‎tests/PHPStan/Analyser/nsrt/bug-7106.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,19 @@ public function openSslError(string $signature): string
2323
assertNativeType('string|false', openssl_error_string());
2424
}
2525
}
26+
27+
public function impureCallForgetsOpenSslError(string $signature): void
28+
{
29+
if (false === \openssl_error_string()) {
30+
assertType('false', openssl_error_string());
31+
// the impure method may call openssl_*() transitively
32+
$this->doImpure($signature);
33+
assertType('string|false', openssl_error_string());
34+
}
35+
}
36+
37+
public function doImpure(string $signature): void
38+
{
39+
openssl_sign('1', $signature, '');
40+
}
2641
}

0 commit comments

Comments
 (0)