Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ parameters:
- ZEND_DEBUG_BUILD
- ZEND_THREAD_SAFE
- E_ALL # different on PHP 8.4
readonlySuperglobals: false
superglobalTypes: []
customRulesetUsed: null
editorUrl: null
editorUrlTitle: null
Expand Down
2 changes: 2 additions & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ parametersSchema:
resultCacheSkipIfOlderThanDays: int()
resultCacheChecksProjectExtensionFilesDependencies: bool()
dynamicConstantNames: arrayOf(string())
readonlySuperglobals: bool()
superglobalTypes: arrayOf(string(), anyOf('GLOBALS', '_SERVER', '_GET', '_POST', '_FILES', '_COOKIE', '_SESSION', '_REQUEST', '_ENV'))
customRulesetUsed: schema(bool(), nullable())
rootDir: string()
tmpDir: string()
Expand Down
3 changes: 3 additions & 0 deletions src/Analyser/DirectInternalScopeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public function __construct(
private ConfiguredPhpVersionRangeHelper $configuredPhpVersionRangeHelper,
private $nodeCallback,
private ConstantResolver $constantResolver,
private SuperglobalTypeResolver $superglobalTypeResolver,
private bool $createsNodeCallbackScopes = false,
?ExpressionResultStorageStack $expressionResultStorageStack = null,
)
Expand Down Expand Up @@ -92,6 +93,7 @@ public function create(
$this->phpVersion,
$this->attributeReflectionFactory,
$this->configuredPhpVersionRangeHelper,
$this->superglobalTypeResolver,
$this->nodeCallback,
);

Expand Down Expand Up @@ -145,6 +147,7 @@ private function withFlavor(bool $createsNodeCallbackScopes): self
$this->configuredPhpVersionRangeHelper,
$this->nodeCallback,
$this->constantResolver,
$this->superglobalTypeResolver,
$createsNodeCallbackScopes,
$this->expressionResultStorageStack,
);
Expand Down
2 changes: 2 additions & 0 deletions src/Analyser/DirectInternalScopeFactoryFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public function __construct(
private AttributeReflectionFactory $attributeReflectionFactory,
private ConfiguredPhpVersionRangeHelper $configuredPhpVersionRangeHelper,
private ConstantResolver $constantResolver,
private SuperglobalTypeResolver $superglobalTypeResolver,
)
{
}
Expand All @@ -57,6 +58,7 @@ public function create(?callable $nodeCallback): DirectInternalScopeFactory
$this->configuredPhpVersionRangeHelper,
$nodeCallback,
$this->constantResolver,
$this->superglobalTypeResolver,
);
}

Expand Down
1 change: 1 addition & 0 deletions src/Analyser/LazyInternalScopeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public function create(
$this->container->getByType(PhpVersion::class),
$this->container->getByType(AttributeReflectionFactory::class),
$this->container->getByType(ConfiguredPhpVersionRangeHelper::class),
$this->container->getByType(SuperglobalTypeResolver::class),
$this->nodeCallback,
);

Expand Down
3 changes: 1 addition & 2 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@
use PHPStan\Type\Generic\TemplateTypeHelper;
use PHPStan\Type\Generic\TemplateTypeMap;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
Expand Down Expand Up @@ -795,7 +794,7 @@ public function getVariableType(string $variableName): Type
$varExprString = '$' . $variableName;
if (!array_key_exists($varExprString, $this->expressionTypes)) {
if ($this->isGlobalVariable($variableName)) {
return new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType(true));
return $this->services->superglobalTypeResolver->getType($variableName, $this->nativeTypesPromoted);
}
return new MixedType();
}
Expand Down
1 change: 1 addition & 0 deletions src/Analyser/ScopeServices.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public function __construct(
public readonly PhpVersion $phpVersion,
public readonly AttributeReflectionFactory $attributeReflectionFactory,
public readonly ConfiguredPhpVersionRangeHelper $configuredPhpVersionRangeHelper,
public readonly SuperglobalTypeResolver $superglobalTypeResolver,
public readonly mixed $nodeCallback,
)
{
Expand Down
111 changes: 111 additions & 0 deletions src/Analyser/SuperglobalTypeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\DependencyInjection\Container;
use PHPStan\PhpDoc\TypeStringResolver;
use PHPStan\Type\ArrayType;
use PHPStan\Type\BenevolentUnionType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use function array_key_exists;

/**
* Types a superglobal variable whose type the scope does not track, i.e. its
* value at the start of every function and after anything that may have written
* to it.
*
* By default every superglobal is `array<mixed>`, since a script can write
* anything into it. A project that treats superglobals as a contract opts in to
* narrower types: `readonlySuperglobals` assumes the shapes PHP populates them
* with (READONLY_SUPERGLOBAL_TYPES), and `superglobalTypes` declares the type of
* any superglobal, taking precedence over those shapes. Either type stays sound
* only while what is written into the superglobal fits it, which
* SuperglobalAssignTypeRule checks.
*
* Either type is a PHPDoc type: PHP itself does not enforce it, so the native
* type stays the default and, with `treatPhpDocTypesAsCertain: false`, a
* defensive check like `is_string($_GET['id'])` is not reported as always true.
*/
#[AutowiredService]
final class SuperglobalTypeResolver
{

/**
* Request input is strings, nested into arrays by brackets in a name
* (`?a[]=1&a[b][]=2`); the nesting is unbounded, so the type stops at two levels.
* `$_SERVER` is strings too, the request variables, headers and environment,
* except for the few entries PHP adds itself; `argc` is 0 and `argv` empty on
* the web when `register_argc_argv` is on and there is no query string.
* `REQUEST_TIME` and `REQUEST_TIME_FLOAT` are set for every request, and every
* bundled SAPI sets `PHP_SELF`, if only to an empty string, unless
* `variables_order` leaves out `S` and `$_SERVER` stays empty. Any other key is
* up to the SAPI, the web server or the client: `SCRIPT_FILENAME` comes from
* the web server's configuration, and a header from the request.
* The keys are not narrowed to strings: like any array key, a numeric name
* (`?1=a`, an environment variable named `123`) becomes an int.
* `$_SESSION` and `$GLOBALS` are left out: what they hold is up to the application.
*/
private const READONLY_SUPERGLOBAL_TYPES = [
'_GET' => self::REQUEST_INPUT_TYPE,
'_POST' => self::REQUEST_INPUT_TYPE,
'_COOKIE' => self::REQUEST_INPUT_TYPE,
'_REQUEST' => self::REQUEST_INPUT_TYPE,
'_FILES' => 'array<array{name: string, full_path?: string, type: string, tmp_name: string, error: int<0, 8>, size: int<0, max>}|array{name: array<mixed>, full_path?: array<mixed>, type: array<mixed>, tmp_name: array<mixed>, error: array<mixed>, size: array<mixed>}>',
'_SERVER' => 'array{PHP_SELF: string, REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list<string>, ...<string>}',
'_ENV' => 'array<string>',
];

private const REQUEST_INPUT_TYPE = 'array<string|array<string|array<mixed>>>';

/** @var array<string, string> */
private array $typeStrings;

private ?Type $defaultType = null;

/** @var array<string, Type> */
private array $resolvedTypes = [];

/**
* @param array<string, string> $superglobalTypes
*/
public function __construct(
#[AutowiredParameter]
bool $readonlySuperglobals,
#[AutowiredParameter]
array $superglobalTypes,
private Container $container,
)
{
$this->typeStrings = $readonlySuperglobals ? $superglobalTypes + self::READONLY_SUPERGLOBAL_TYPES : $superglobalTypes;
}

public function getType(string $variableName, bool $nativeTypesPromoted): Type
{
$contractType = $nativeTypesPromoted ? null : $this->getContractType($variableName);

return $contractType ?? $this->defaultType ??= new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType(true));
}

/**
* The type the project holds the superglobal to, declared or assumed;
* null when it has none and anything may be written into it.
*/
public function getContractType(string $variableName): ?Type
{
if (!array_key_exists($variableName, $this->typeStrings)) {
return null;
}

// fetched lazily: TypeStringResolver depends on services that depend on the scope factory
return $this->resolvedTypes[$variableName] ??= $this->container->getByType(TypeStringResolver::class)->resolve(
$this->typeStrings[$variableName],
new NameScope(null, [], className: null),
);
}

}
81 changes: 81 additions & 0 deletions src/Rules/Variables/SuperglobalAssignTypeRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Variables;

use PhpParser\Node;
use PhpParser\Node\Expr\Variable;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SuperglobalTypeResolver;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Node\VariableAssignNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\VerbosityLevel;
use function in_array;
use function is_string;
use function sprintf;

/**
* Reports a write that leaves a superglobal outside the type it is held to by
* `readonlySuperglobals` or `superglobalTypes`. Once the scope forgets the
* written value, such as after a function call, reads fall back to that type,
* so a value outside it would be typed wrongly from then on.
*
* @implements Rule<VariableAssignNode>
*/
#[RegisteredRule(level: 3)]
final class SuperglobalAssignTypeRule implements Rule
{

public function __construct(
private SuperglobalTypeResolver $superglobalTypeResolver,
private RuleLevelHelper $ruleLevelHelper,
)
{
}

public function getNodeType(): string
{
return VariableAssignNode::class;
}

public function processNode(Node $node, Scope $scope): array
{
$variable = $node->getVariable();
if (!is_string($variable->name) || !in_array($variable->name, Scope::SUPERGLOBAL_VARIABLES, true)) {
return [];
}

$superglobalType = $this->superglobalTypeResolver->getContractType($variable->name);
if ($superglobalType === null) {
return [];
}

$assignedValueType = $scope->getType($node->getAssignedExpr());

// what the superglobal held before this write was either accepted or already reported,
// so only a value this write newly brings in is reported, not every later write
$acceptingType = TypeCombinator::union($superglobalType, $scope->getType(new Variable($variable->name)));
$accepts = $this->ruleLevelHelper->accepts($acceptingType, $assignedValueType, $scope->isDeclareStrictTypes());
if ($accepts->result) {
return [];
}

$verbosityLevel = VerbosityLevel::getRecommendedLevelByType($superglobalType, $assignedValueType);

return [
RuleErrorBuilder::message(sprintf(
'Superglobal $%s (%s) does not accept %s.',
$variable->name,
$superglobalType->describe($verbosityLevel),
$assignedValueType->describe($verbosityLevel),
))
->identifier('assign.superglobalType')
->acceptsReasonsTip($accepts->reasons)
->build(),
];
}

}
2 changes: 2 additions & 0 deletions src/Testing/PHPStanTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PHPStan\Analyser\DirectInternalScopeFactoryFactory;
use PHPStan\Analyser\Error;
use PHPStan\Analyser\ScopeFactory;
use PHPStan\Analyser\SuperglobalTypeResolver;
use PHPStan\Analyser\TypeSpecifier;
use PHPStan\BetterReflection\Reflector\Reflector;
use PHPStan\DependencyInjection\Reflection\ClassReflectionExtensionRegistryProvider;
Expand Down Expand Up @@ -130,6 +131,7 @@ public static function createScopeFactory(ReflectionProvider $reflectionProvider
$container->getByType(AttributeReflectionFactory::class),
$container->getByType(ConfiguredPhpVersionRangeHelper::class),
$constantResolver,
$container->getByType(SuperglobalTypeResolver::class),
),
);
}
Expand Down
6 changes: 4 additions & 2 deletions src/Testing/PHPUnit/ContainerInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
namespace PHPStan\Testing\PHPUnit;

use PHPStan\DependencyInjection\InvalidIgnoredErrorExceptionTest;
use PHPStan\DependencyInjection\InvalidSuperglobalTypesTest;
use function in_array;

final class ContainerInitializer
{

public static function initialize(string $testClassName): void
{
// This test expects an exception during container initialization
if ($testClassName === InvalidIgnoredErrorExceptionTest::class) {
// These tests expect an exception during container initialization
if (in_array($testClassName, [InvalidIgnoredErrorExceptionTest::class, InvalidSuperglobalTypesTest::class], true)) {
return;
}

Expand Down
36 changes: 36 additions & 0 deletions tests/PHPStan/Analyser/ReadonlySuperglobalsTypesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class ReadonlySuperglobalsTypesTest extends TypeInferenceTestCase
{

public static function dataFileAsserts(): iterable
{
yield from self::gatherAssertTypes(__DIR__ . '/data/readonly-superglobals.php');
}

/**
* @param mixed ...$args
*/
#[DataProvider('dataFileAsserts')]
public function testFileAsserts(
string $assertType,
string $file,
...$args,
): void
{
$this->assertFileAsserts($assertType, $file, ...$args);
}

public static function getAdditionalConfigFiles(): array
{
return [
__DIR__ . '/readonly-superglobals.neon',
];
}

}
36 changes: 36 additions & 0 deletions tests/PHPStan/Analyser/SuperglobalTypesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class SuperglobalTypesTest extends TypeInferenceTestCase
{

public static function dataFileAsserts(): iterable
{
yield from self::gatherAssertTypes(__DIR__ . '/data/superglobal-types.php');
}

/**
* @param mixed ...$args
*/
#[DataProvider('dataFileAsserts')]
public function testFileAsserts(
string $assertType,
string $file,
...$args,
): void
{
$this->assertFileAsserts($assertType, $file, ...$args);
}

public static function getAdditionalConfigFiles(): array
{
return [
__DIR__ . '/superglobal-types.neon',
];
}

}
Loading
Loading