From 4f5fc866e7848854cc0056d4541a1915b56510dd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Oct 2026 09:40:56 -0700 Subject: [PATCH 1/7] Add superglobalTypes parameter to narrow superglobal types Superglobals not tracked by the scope are typed `array`, so every read of `$_GET['foo']` is `mixed`. A project can now declare the shape it treats as a contract: parameters: superglobalTypes: _GET: 'array>>' The configured type is the starting point only. Assignments, narrowing, and invalidation after impure calls behave as before, falling back to the configured type rather than `array`. It is a PHPDoc type: the native type stays `array`, so with `treatPhpDocTypesAsCertain: false` a defensive `is_string($_GET['id'])` is not reported as always true. The type is resolved by the new SuperglobalTypeResolver service, held in ScopeServices and called from MutatingScope::getVariableType() and its native mirror in turbo-ext. Co-Authored-By: Claude Opus 5.5 --- conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/Analyser/DirectInternalScopeFactory.php | 3 + .../DirectInternalScopeFactoryFactory.php | 2 + src/Analyser/LazyInternalScopeFactory.php | 1 + src/Analyser/MutatingScope.php | 3 +- src/Analyser/ScopeServices.php | 1 + src/Analyser/SuperglobalTypeResolver.php | 60 +++++++++++++++++++ src/Testing/PHPStanTestCase.php | 2 + .../PHPStan/Analyser/SuperglobalTypesTest.php | 36 +++++++++++ .../Analyser/data/superglobal-types.php | 55 +++++++++++++++++ tests/PHPStan/Analyser/superglobal-types.neon | 4 ++ turbo-ext/src/MutatingScope.cpp | 23 +++---- 13 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 src/Analyser/SuperglobalTypeResolver.php create mode 100644 tests/PHPStan/Analyser/SuperglobalTypesTest.php create mode 100644 tests/PHPStan/Analyser/data/superglobal-types.php create mode 100644 tests/PHPStan/Analyser/superglobal-types.neon diff --git a/conf/config.neon b/conf/config.neon index f623528506a..d6b1cceb5cc 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -241,6 +241,7 @@ parameters: - ZEND_DEBUG_BUILD - ZEND_THREAD_SAFE - E_ALL # different on PHP 8.4 + superglobalTypes: [] customRulesetUsed: null editorUrl: null editorUrlTitle: null diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 154054ccb1f..10e5596336d 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -177,6 +177,7 @@ parametersSchema: resultCacheSkipIfOlderThanDays: int() resultCacheChecksProjectExtensionFilesDependencies: bool() dynamicConstantNames: arrayOf(string()) + superglobalTypes: arrayOf(string()) customRulesetUsed: schema(bool(), nullable()) rootDir: string() tmpDir: string() diff --git a/src/Analyser/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php index c0a9007c0c7..1eaa9878ed2 100644 --- a/src/Analyser/DirectInternalScopeFactory.php +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -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, ) @@ -92,6 +93,7 @@ public function create( $this->phpVersion, $this->attributeReflectionFactory, $this->configuredPhpVersionRangeHelper, + $this->superglobalTypeResolver, $this->nodeCallback, ); @@ -145,6 +147,7 @@ private function withFlavor(bool $createsNodeCallbackScopes): self $this->configuredPhpVersionRangeHelper, $this->nodeCallback, $this->constantResolver, + $this->superglobalTypeResolver, $createsNodeCallbackScopes, $this->expressionResultStorageStack, ); diff --git a/src/Analyser/DirectInternalScopeFactoryFactory.php b/src/Analyser/DirectInternalScopeFactoryFactory.php index 4e778139644..5088a21cbed 100644 --- a/src/Analyser/DirectInternalScopeFactoryFactory.php +++ b/src/Analyser/DirectInternalScopeFactoryFactory.php @@ -34,6 +34,7 @@ public function __construct( private AttributeReflectionFactory $attributeReflectionFactory, private ConfiguredPhpVersionRangeHelper $configuredPhpVersionRangeHelper, private ConstantResolver $constantResolver, + private SuperglobalTypeResolver $superglobalTypeResolver, ) { } @@ -57,6 +58,7 @@ public function create(?callable $nodeCallback): DirectInternalScopeFactory $this->configuredPhpVersionRangeHelper, $nodeCallback, $this->constantResolver, + $this->superglobalTypeResolver, ); } diff --git a/src/Analyser/LazyInternalScopeFactory.php b/src/Analyser/LazyInternalScopeFactory.php index c40ea12abbc..b12ac30f2be 100644 --- a/src/Analyser/LazyInternalScopeFactory.php +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -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, ); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 85867200a20..0bf23cd7e5e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -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; @@ -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(); } diff --git a/src/Analyser/ScopeServices.php b/src/Analyser/ScopeServices.php index 72bbc767679..cad047f84c2 100644 --- a/src/Analyser/ScopeServices.php +++ b/src/Analyser/ScopeServices.php @@ -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, ) { diff --git a/src/Analyser/SuperglobalTypeResolver.php b/src/Analyser/SuperglobalTypeResolver.php new file mode 100644 index 00000000000..34c3e8d2049 --- /dev/null +++ b/src/Analyser/SuperglobalTypeResolver.php @@ -0,0 +1,60 @@ + */ + private array $configuredTypes = []; + + /** + * @param array $superglobalTypes + */ + public function __construct( + #[AutowiredParameter] + private array $superglobalTypes, + private Container $container, + ) + { + } + + public function getType(string $variableName, bool $nativeTypesPromoted): Type + { + if ($nativeTypesPromoted || !array_key_exists($variableName, $this->superglobalTypes)) { + return $this->defaultType ??= new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType(true)); + } + + // fetched lazily: TypeStringResolver depends on services that depend on the scope factory + return $this->configuredTypes[$variableName] ??= $this->container->getByType(TypeStringResolver::class)->resolve( + $this->superglobalTypes[$variableName], + new NameScope(null, [], className: null), + ); + } + +} diff --git a/src/Testing/PHPStanTestCase.php b/src/Testing/PHPStanTestCase.php index f7947ba02f4..ef3cbe51d38 100644 --- a/src/Testing/PHPStanTestCase.php +++ b/src/Testing/PHPStanTestCase.php @@ -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; @@ -130,6 +131,7 @@ public static function createScopeFactory(ReflectionProvider $reflectionProvider $container->getByType(AttributeReflectionFactory::class), $container->getByType(ConfiguredPhpVersionRangeHelper::class), $constantResolver, + $container->getByType(SuperglobalTypeResolver::class), ), ); } diff --git a/tests/PHPStan/Analyser/SuperglobalTypesTest.php b/tests/PHPStan/Analyser/SuperglobalTypesTest.php new file mode 100644 index 00000000000..37fa0bf27ec --- /dev/null +++ b/tests/PHPStan/Analyser/SuperglobalTypesTest.php @@ -0,0 +1,36 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/superglobal-types.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/data/superglobal-types.php b/tests/PHPStan/Analyser/data/superglobal-types.php new file mode 100644 index 00000000000..0b011e21159 --- /dev/null +++ b/tests/PHPStan/Analyser/data/superglobal-types.php @@ -0,0 +1,55 @@ +|string>|string>', $_GET); + assertType('array', $_COOKIE); + assertType('array', $_POST); + assertType('array', $_SERVER); + + assertNativeType('array', $_GET); + assertNativeType('array', $_COOKIE); +} + +function offsets(): void +{ + assertType('array|string>|string', $_GET['id']); + assertType('array|string>|string|null', $_GET['id'] ?? null); + assertNativeType('mixed', $_GET['id']); + + if (isset($_GET['id'])) { + assertType('array|string>|string', $_GET['id']); + } + + if (isset($_GET['id']) && is_string($_GET['id'])) { + assertType('string', $_GET['id']); + } + + assertType('string|null', $_COOKIE['session'] ?? null); +} + +function writes(): void +{ + $_GET['id'] = 1; + assertType('1', $_GET['id']); + + $_GET = []; + assertType('array{}', $_GET); +} + +function invalidatedByCalls(): void +{ + $_GET['id'] = 1; + assertType('1', $_GET['id']); + doSomething(); + assertType('array|string>|string>', $_GET); +} + +function doSomething(): void +{ +} diff --git a/tests/PHPStan/Analyser/superglobal-types.neon b/tests/PHPStan/Analyser/superglobal-types.neon new file mode 100644 index 00000000000..1916b059d3c --- /dev/null +++ b/tests/PHPStan/Analyser/superglobal-types.neon @@ -0,0 +1,4 @@ +parameters: + superglobalTypes: + _GET: 'array>>' + _COOKIE: 'array' diff --git a/turbo-ext/src/MutatingScope.cpp b/turbo-ext/src/MutatingScope.cpp index 1138b46ed9a..5b8d05e6952 100644 --- a/turbo-ext/src/MutatingScope.cpp +++ b/turbo-ext/src/MutatingScope.cpp @@ -339,6 +339,7 @@ enum : uint32_t PT_MSS_PHP_VERSION, PT_MSS_ATTRIBUTE_REFLECTION_FACTORY, PT_MSS_CONFIGURED_PHP_VERSION_RANGE_HELPER, + PT_MSS_SUPERGLOBAL_TYPE_RESOLVER, PT_MSS_NODE_CALLBACK, PT_MSS_COUNT, }; @@ -357,6 +358,7 @@ static const char *const pt_mss_names[PT_MSS_COUNT] = { "phpVersion", "attributeReflectionFactory", "configuredPhpVersionRangeHelper", + "superglobalTypeResolver", "nodeCallback", }; @@ -1709,22 +1711,11 @@ class MutatingScope zval *holder = zend_symtable_find(Z_ARRVAL_P(slot(PT_MS_PROP_EXPRESSION_TYPES).raw()), varExprString.get()); if (holder == NULL) { if (isGlobalVariable(variableName)) { - /* new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType(true)) */ - zval integer, string, mixed, benevolent, array; - if (UNEXPECTED(!pt_integer_type_new(&integer))) return zv::Val(); - if (UNEXPECTED(!pt_string_type_new(&string))) { - zval_ptr_dtor(&integer); - return zv::Val(); - } - zv::Arr members = zv::Arr::create(2); - members.push(zv::Val::adopt(integer)); - members.push(zv::Val::adopt(string)); - if (UNEXPECTED(!pt_benevolent_union_type_new(&benevolent, members.raw()))) return zv::Val(); - zv::Val benevolentVal = zv::Val::adopt(benevolent); - if (UNEXPECTED(!pt_mixed_type_new(&mixed, true))) return zv::Val(); - zv::Val mixedVal = zv::Val::adopt(mixed); - if (UNEXPECTED(!pt_array_type_new(&array, benevolentVal.raw(), mixedVal.raw()))) return zv::Val(); - return zv::Val::adopt(array); + /* $this->services->superglobalTypeResolver->getType($variableName, $this->nativeTypesPromoted) */ + zv::Ref superglobalTypeResolver = service(PT_MSS_SUPERGLOBAL_TYPE_RESOLVER); + if (UNEXPECTED(!superglobalTypeResolver.isObject())) return uninitializedProperty("superglobalTypeResolver"); + zv::Args args{variableName, slot(PT_MS_PROP_NATIVE_TYPES_PROMOTED).raw()}; + return pt_type_call(superglobalTypeResolver.asObject(), PT_LC("gettype"), 2, args); } return newMixedType(false); } From 518b588ad53952e6c317d3e10e5c97be48955184 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Oct 2026 10:01:16 -0700 Subject: [PATCH 2/7] Add readonlySuperglobals parameter When enabled, the superglobals PHP populates from the request and the environment get the shapes PHP gives them, without each project having to declare them in superglobalTypes: * `$_GET`, `$_POST`, `$_COOKIE`, and `$_REQUEST`: strings, nested into arrays by brackets in a name, tracked two levels deep. * `$_FILES`: the upload entry shape, for both single and multiple files. * `$_ENV`: `array`. `$_SERVER`, `$_SESSION`, and `$GLOBALS` are left as `array`, since their contents depend on the SAPI, the host, and the application. A type in superglobalTypes takes precedence over the assumed one, so any of them can still be declared. Assuming these types is only sound while nothing writes to the superglobals, so the flag also enables ReadonlySuperglobalRule, which reports assignments, offset writes, compound assignments, increments, `unset()`, and by-reference arguments that target a superglobal. The flag is off by default, leaving every superglobal `array` unless superglobalTypes says otherwise. Co-Authored-By: Claude Opus 5.5 --- conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/Analyser/SuperglobalTypeResolver.php | 49 +++++++++++++---- .../Variables/ReadonlySuperglobalRule.php | 54 +++++++++++++++++++ .../ReadonlySuperglobalsTypesTest.php | 36 +++++++++++++ .../Analyser/data/readonly-superglobals.php | 26 +++++++++ .../Analyser/data/superglobal-types.php | 2 + .../Analyser/readonly-superglobals.neon | 5 ++ .../Variables/ReadonlySuperglobalRuleTest.php | 50 +++++++++++++++++ .../Variables/data/readonly-superglobals.php | 38 +++++++++++++ 10 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 src/Rules/Variables/ReadonlySuperglobalRule.php create mode 100644 tests/PHPStan/Analyser/ReadonlySuperglobalsTypesTest.php create mode 100644 tests/PHPStan/Analyser/data/readonly-superglobals.php create mode 100644 tests/PHPStan/Analyser/readonly-superglobals.neon create mode 100644 tests/PHPStan/Rules/Variables/ReadonlySuperglobalRuleTest.php create mode 100644 tests/PHPStan/Rules/Variables/data/readonly-superglobals.php diff --git a/conf/config.neon b/conf/config.neon index d6b1cceb5cc..e93a0a9b877 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -241,6 +241,7 @@ parameters: - ZEND_DEBUG_BUILD - ZEND_THREAD_SAFE - E_ALL # different on PHP 8.4 + readonlySuperglobals: false superglobalTypes: [] customRulesetUsed: null editorUrl: null diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 10e5596336d..65ceb8855c5 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -177,6 +177,7 @@ parametersSchema: resultCacheSkipIfOlderThanDays: int() resultCacheChecksProjectExtensionFilesDependencies: bool() dynamicConstantNames: arrayOf(string()) + readonlySuperglobals: bool() superglobalTypes: arrayOf(string()) customRulesetUsed: schema(bool(), nullable()) rootDir: string() diff --git a/src/Analyser/SuperglobalTypeResolver.php b/src/Analyser/SuperglobalTypeResolver.php index 34c3e8d2049..02c0376f420 100644 --- a/src/Analyser/SuperglobalTypeResolver.php +++ b/src/Analyser/SuperglobalTypeResolver.php @@ -17,42 +17,71 @@ /** * 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. The `superglobalTypes` parameter narrows it for a project that treats a - * superglobal as a contract, such as `$_GET` only ever holding request input. + * to it. * - * The configured 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. + * By default every superglobal is `array`, 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 reports writes to them (see + * ReadonlySuperglobalRule), and `superglobalTypes` declares the type of any + * superglobal, taking precedence over those shapes. + * + * 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`, `$_SESSION` and `$GLOBALS` are left out: what they hold depends + * on the SAPI, the host and 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, size: int<0, max>}|array{name: array, full_path?: array, type: array, tmp_name: array, error: array, size: array}>', + '_ENV' => 'array', + ]; + + private const REQUEST_INPUT_TYPE = 'array>>'; + + /** @var array */ + private array $typeStrings; + private ?Type $defaultType = null; /** @var array */ - private array $configuredTypes = []; + private array $resolvedTypes = []; /** * @param array $superglobalTypes */ public function __construct( #[AutowiredParameter] - private array $superglobalTypes, + 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 { - if ($nativeTypesPromoted || !array_key_exists($variableName, $this->superglobalTypes)) { + if ($nativeTypesPromoted || !array_key_exists($variableName, $this->typeStrings)) { return $this->defaultType ??= new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType(true)); } // fetched lazily: TypeStringResolver depends on services that depend on the scope factory - return $this->configuredTypes[$variableName] ??= $this->container->getByType(TypeStringResolver::class)->resolve( - $this->superglobalTypes[$variableName], + return $this->resolvedTypes[$variableName] ??= $this->container->getByType(TypeStringResolver::class)->resolve( + $this->typeStrings[$variableName], new NameScope(null, [], className: null), ); } diff --git a/src/Rules/Variables/ReadonlySuperglobalRule.php b/src/Rules/Variables/ReadonlySuperglobalRule.php new file mode 100644 index 00000000000..8afbf241bbe --- /dev/null +++ b/src/Rules/Variables/ReadonlySuperglobalRule.php @@ -0,0 +1,54 @@ + + */ +#[RegisteredRule(level: 0)] +final class ReadonlySuperglobalRule implements Rule +{ + + public function __construct( + #[AutowiredParameter] + private bool $readonlySuperglobals, + ) + { + } + + public function getNodeType(): string + { + return VariableAssignNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->readonlySuperglobals) { + return []; + } + + $variable = $node->getVariable(); + if (!is_string($variable->name) || !in_array($variable->name, Scope::SUPERGLOBAL_VARIABLES, true)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Superglobal $%s is written to.', $variable->name)) + ->identifier('assign.readonlySuperglobal') + ->tip('Superglobals are read-only because the readonlySuperglobals parameter is enabled.') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/ReadonlySuperglobalsTypesTest.php b/tests/PHPStan/Analyser/ReadonlySuperglobalsTypesTest.php new file mode 100644 index 00000000000..ec06bd8e63e --- /dev/null +++ b/tests/PHPStan/Analyser/ReadonlySuperglobalsTypesTest.php @@ -0,0 +1,36 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/readonly-superglobals.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/data/readonly-superglobals.php b/tests/PHPStan/Analyser/data/readonly-superglobals.php new file mode 100644 index 00000000000..0ac021a5ba6 --- /dev/null +++ b/tests/PHPStan/Analyser/data/readonly-superglobals.php @@ -0,0 +1,26 @@ +|string>|string>', $_GET); + assertType('array|string>|string>', $_POST); + assertType('array|string>|string>', $_REQUEST); + assertType('array, full_path?: array, type: array, tmp_name: array, error: array, size: array}|array{name: string, full_path?: string, type: string, tmp_name: string, error: int<0, 8>, size: int<0, max>}>', $_FILES); + assertType('array', $_ENV); + assertType('array', $_SESSION); + assertType('array', $GLOBALS); + + assertNativeType('array', $_GET); + assertNativeType('array', $_FILES); +} + +function configuredTypesTakePrecedence(): void +{ + assertType('array', $_COOKIE); + assertType('array', $_SERVER); +} diff --git a/tests/PHPStan/Analyser/data/superglobal-types.php b/tests/PHPStan/Analyser/data/superglobal-types.php index 0b011e21159..49f15e97b6a 100644 --- a/tests/PHPStan/Analyser/data/superglobal-types.php +++ b/tests/PHPStan/Analyser/data/superglobal-types.php @@ -11,6 +11,8 @@ function configuredTypes(): void assertType('array', $_COOKIE); assertType('array', $_POST); assertType('array', $_SERVER); + assertType('array', $_FILES); + assertType('array', $_ENV); assertNativeType('array', $_GET); assertNativeType('array', $_COOKIE); diff --git a/tests/PHPStan/Analyser/readonly-superglobals.neon b/tests/PHPStan/Analyser/readonly-superglobals.neon new file mode 100644 index 00000000000..6127cb1284d --- /dev/null +++ b/tests/PHPStan/Analyser/readonly-superglobals.neon @@ -0,0 +1,5 @@ +parameters: + readonlySuperglobals: true + superglobalTypes: + _COOKIE: 'array' + _SERVER: 'array' diff --git a/tests/PHPStan/Rules/Variables/ReadonlySuperglobalRuleTest.php b/tests/PHPStan/Rules/Variables/ReadonlySuperglobalRuleTest.php new file mode 100644 index 00000000000..4f6cc452ffc --- /dev/null +++ b/tests/PHPStan/Rules/Variables/ReadonlySuperglobalRuleTest.php @@ -0,0 +1,50 @@ + + */ +class ReadonlySuperglobalRuleTest extends RuleTestCase +{ + + private bool $readonlySuperglobals; + + protected function getRule(): Rule + { + return new ReadonlySuperglobalRule($this->readonlySuperglobals); + } + + public function testRule(): void + { + $this->readonlySuperglobals = true; + $tip = 'Superglobals are read-only because the readonlySuperglobals parameter is enabled.'; + $this->analyse([__DIR__ . '/data/readonly-superglobals.php'], [ + ['Superglobal $_GET is written to.', 7, $tip], + ['Superglobal $_POST is written to.', 8, $tip], + ['Superglobal $_COOKIE is written to.', 9, $tip], + ['Superglobal $_REQUEST is written to.', 10, $tip], + ['Superglobal $_GET is written to.', 11, $tip], + ['Superglobal $_SERVER is written to.', 12, $tip], + ['Superglobal $_ENV is written to.', 13, $tip], + ['Superglobal $_GET is written to.', 14, $tip], + ['Superglobal $_GET is written to.', 14, $tip], + ['Superglobal $_POST is written to.', 15, $tip], + ['Superglobal $_GET is written to.', 17, $tip], + ['Superglobal $_POST is written to.', 18, $tip], + ['Superglobal $GLOBALS is written to.', 19, $tip], + ['Superglobal $_SESSION is written to.', 20, $tip], + ['Superglobal $_FILES is written to.', 21, $tip], + ]); + } + + public function testRuleDisabled(): void + { + $this->readonlySuperglobals = false; + $this->analyse([__DIR__ . '/data/readonly-superglobals.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Variables/data/readonly-superglobals.php b/tests/PHPStan/Rules/Variables/data/readonly-superglobals.php new file mode 100644 index 00000000000..fa91c94a678 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/readonly-superglobals.php @@ -0,0 +1,38 @@ + Date: Tue, 27 Oct 2026 10:59:17 -0700 Subject: [PATCH 3/7] Report writes that break a superglobal's type instead of all writes A declared or assumed superglobal type stays sound as long as what is written into the superglobal fits it: once the scope forgets the written value, reads fall back to that type. Banning writes outright went further than that and reported writes that keep the contract, like `$_GET['order'] = 'DESC';` or `unset($_GET['posted']);`, as well as writes to `$GLOBALS` and `$_SERVER`, which the parameters do not type. SuperglobalAssignTypeRule replaces ReadonlySuperglobalRule. Like the property type check, it reports a write whose value the superglobal's type does not accept, such as an int written into `$_GET`, and it applies to any superglobal with a type, from readonlySuperglobals or superglobalTypes. A write is checked against that type unioned with what the superglobal held before it, so a violation is reported where it is introduced rather than again at every later write in the function. Co-Authored-By: Claude Opus 5.5 --- src/Analyser/SuperglobalTypeResolver.php | 22 +++-- .../Variables/ReadonlySuperglobalRule.php | 54 ------------- .../Variables/SuperglobalAssignTypeRule.php | 81 +++++++++++++++++++ .../Variables/ReadonlySuperglobalRuleTest.php | 50 ------------ .../SuperglobalAssignTypeRuleTest.php | 60 ++++++++++++++ ...erglobalAssignTypeRuleWithoutTypesTest.php | 29 +++++++ .../Variables/data/readonly-superglobals.php | 38 --------- .../data/superglobal-assign-type.php | 49 +++++++++++ .../Variables/superglobal-assign-type.neon | 2 + 9 files changed, 238 insertions(+), 147 deletions(-) delete mode 100644 src/Rules/Variables/ReadonlySuperglobalRule.php create mode 100644 src/Rules/Variables/SuperglobalAssignTypeRule.php delete mode 100644 tests/PHPStan/Rules/Variables/ReadonlySuperglobalRuleTest.php create mode 100644 tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php create mode 100644 tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleWithoutTypesTest.php delete mode 100644 tests/PHPStan/Rules/Variables/data/readonly-superglobals.php create mode 100644 tests/PHPStan/Rules/Variables/data/superglobal-assign-type.php create mode 100644 tests/PHPStan/Rules/Variables/superglobal-assign-type.neon diff --git a/src/Analyser/SuperglobalTypeResolver.php b/src/Analyser/SuperglobalTypeResolver.php index 02c0376f420..c8c0f26d355 100644 --- a/src/Analyser/SuperglobalTypeResolver.php +++ b/src/Analyser/SuperglobalTypeResolver.php @@ -22,9 +22,10 @@ * By default every superglobal is `array`, 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 reports writes to them (see - * ReadonlySuperglobalRule), and `superglobalTypes` declares the type of any - * superglobal, taking precedence over those shapes. + * 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 @@ -75,8 +76,19 @@ public function __construct( public function getType(string $variableName, bool $nativeTypesPromoted): Type { - if ($nativeTypesPromoted || !array_key_exists($variableName, $this->typeStrings)) { - return $this->defaultType ??= new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType(true)); + $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 diff --git a/src/Rules/Variables/ReadonlySuperglobalRule.php b/src/Rules/Variables/ReadonlySuperglobalRule.php deleted file mode 100644 index 8afbf241bbe..00000000000 --- a/src/Rules/Variables/ReadonlySuperglobalRule.php +++ /dev/null @@ -1,54 +0,0 @@ - - */ -#[RegisteredRule(level: 0)] -final class ReadonlySuperglobalRule implements Rule -{ - - public function __construct( - #[AutowiredParameter] - private bool $readonlySuperglobals, - ) - { - } - - public function getNodeType(): string - { - return VariableAssignNode::class; - } - - public function processNode(Node $node, Scope $scope): array - { - if (!$this->readonlySuperglobals) { - return []; - } - - $variable = $node->getVariable(); - if (!is_string($variable->name) || !in_array($variable->name, Scope::SUPERGLOBAL_VARIABLES, true)) { - return []; - } - - return [ - RuleErrorBuilder::message(sprintf('Superglobal $%s is written to.', $variable->name)) - ->identifier('assign.readonlySuperglobal') - ->tip('Superglobals are read-only because the readonlySuperglobals parameter is enabled.') - ->build(), - ]; - } - -} diff --git a/src/Rules/Variables/SuperglobalAssignTypeRule.php b/src/Rules/Variables/SuperglobalAssignTypeRule.php new file mode 100644 index 00000000000..133179ecd86 --- /dev/null +++ b/src/Rules/Variables/SuperglobalAssignTypeRule.php @@ -0,0 +1,81 @@ + + */ +#[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(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Variables/ReadonlySuperglobalRuleTest.php b/tests/PHPStan/Rules/Variables/ReadonlySuperglobalRuleTest.php deleted file mode 100644 index 4f6cc452ffc..00000000000 --- a/tests/PHPStan/Rules/Variables/ReadonlySuperglobalRuleTest.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ -class ReadonlySuperglobalRuleTest extends RuleTestCase -{ - - private bool $readonlySuperglobals; - - protected function getRule(): Rule - { - return new ReadonlySuperglobalRule($this->readonlySuperglobals); - } - - public function testRule(): void - { - $this->readonlySuperglobals = true; - $tip = 'Superglobals are read-only because the readonlySuperglobals parameter is enabled.'; - $this->analyse([__DIR__ . '/data/readonly-superglobals.php'], [ - ['Superglobal $_GET is written to.', 7, $tip], - ['Superglobal $_POST is written to.', 8, $tip], - ['Superglobal $_COOKIE is written to.', 9, $tip], - ['Superglobal $_REQUEST is written to.', 10, $tip], - ['Superglobal $_GET is written to.', 11, $tip], - ['Superglobal $_SERVER is written to.', 12, $tip], - ['Superglobal $_ENV is written to.', 13, $tip], - ['Superglobal $_GET is written to.', 14, $tip], - ['Superglobal $_GET is written to.', 14, $tip], - ['Superglobal $_POST is written to.', 15, $tip], - ['Superglobal $_GET is written to.', 17, $tip], - ['Superglobal $_POST is written to.', 18, $tip], - ['Superglobal $GLOBALS is written to.', 19, $tip], - ['Superglobal $_SESSION is written to.', 20, $tip], - ['Superglobal $_FILES is written to.', 21, $tip], - ]); - } - - public function testRuleDisabled(): void - { - $this->readonlySuperglobals = false; - $this->analyse([__DIR__ . '/data/readonly-superglobals.php'], []); - } - -} diff --git a/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php new file mode 100644 index 00000000000..8a554e8337d --- /dev/null +++ b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php @@ -0,0 +1,60 @@ + + */ +class SuperglobalAssignTypeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new SuperglobalAssignTypeRule( + self::getContainer()->getByType(SuperglobalTypeResolver::class), + self::getContainer()->getByType(RuleLevelHelper::class), + ); + } + + public function testRule(): void + { + $request = 'array|string>|string>'; + $this->analyse([__DIR__ . '/data/superglobal-assign-type.php'], [ + [ + sprintf('Superglobal $_GET (%s) does not accept array|string>|int|string>.', $request), + 21, + ], + [ + sprintf('Superglobal $_POST (%s) does not accept array|int|string>|string>.', $request), + 23, + ], + [ + 'Superglobal $_ENV (array) does not accept array.', + 24, + ], + [ + // the second int written on the next line is not reported again + sprintf('Superglobal $_POST (%s) does not accept array|string>|int|string>.', $request), + 29, + ], + [ + sprintf('Superglobal $_GET (%s) does not accept string.', $request), + 40, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/superglobal-assign-type.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleWithoutTypesTest.php b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleWithoutTypesTest.php new file mode 100644 index 00000000000..9064c713c43 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleWithoutTypesTest.php @@ -0,0 +1,29 @@ + + */ +class SuperglobalAssignTypeRuleWithoutTypesTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new SuperglobalAssignTypeRule( + self::getContainer()->getByType(SuperglobalTypeResolver::class), + self::getContainer()->getByType(RuleLevelHelper::class), + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/superglobal-assign-type.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Variables/data/readonly-superglobals.php b/tests/PHPStan/Rules/Variables/data/readonly-superglobals.php deleted file mode 100644 index fa91c94a678..00000000000 --- a/tests/PHPStan/Rules/Variables/data/readonly-superglobals.php +++ /dev/null @@ -1,38 +0,0 @@ - $strings + * @param list $ints + */ +function nonConformingWrites(array $strings, array $ints, int $int): void +{ + $_GET['message'] = $int; + $_POST['ids'] = $strings; + $_POST['ids'] = $ints; + $_ENV['FOO'] = 1; +} + +function reportedOnce(int $int, string $string): void +{ + $_POST['link_id'] = $int; + $_POST['comment_parent'] = $int; + $_POST['title'] = $string; + unset($_POST['posted']); +} + +/** + * @param list $strings + */ +function foreachTarget(array $strings): void +{ + foreach ($strings as $_GET) { + } +} + +function untypedSuperglobals(int $int): void +{ + $GLOBALS['foo'] = $int; + $_SERVER['argc'] = $int; + $_SESSION['user'] = $int; +} diff --git a/tests/PHPStan/Rules/Variables/superglobal-assign-type.neon b/tests/PHPStan/Rules/Variables/superglobal-assign-type.neon new file mode 100644 index 00000000000..e810b3f38f0 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/superglobal-assign-type.neon @@ -0,0 +1,2 @@ +parameters: + readonlySuperglobals: true From 951c7617b1f5f5413f6d936ce81dbce5c9fd3010 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Oct 2026 11:24:39 -0700 Subject: [PATCH 4/7] Assume the shape of $_SERVER with readonlySuperglobals Under every standard SAPI, `$_SERVER` holds strings: the request variables, the headers, and in the CLI the environment. The exceptions are the few entries PHP adds itself, so readonlySuperglobals now types it as an unsealed shape: array{argc?: int<0, max>, argv?: list, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, ...} `argc` and `argv` are not the non-empty CLI types used for `$argc` and `$argv`, since on the web with `register_argc_argv` on and no query string, `argc` is 0 and `argv` is empty. As a shape, `$_SERVER` reports reads of keys that might not exist, such as `HTTP_USER_AGENT`, which a client can omit. The keys of `$_SERVER` and `$_ENV` stay `int|string`: an environment variable named `123` becomes the int key 123, like any numeric array key. Co-Authored-By: Claude Opus 5.5 --- src/Analyser/SuperglobalTypeResolver.php | 9 +++++++-- .../Analyser/data/readonly-superglobals.php | 18 ++++++++++++++++-- .../Analyser/readonly-superglobals.neon | 2 +- .../SuperglobalAssignTypeRuleTest.php | 16 +++++++++++----- .../Variables/data/superglobal-assign-type.php | 4 +++- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/Analyser/SuperglobalTypeResolver.php b/src/Analyser/SuperglobalTypeResolver.php index c8c0f26d355..1ae92b01301 100644 --- a/src/Analyser/SuperglobalTypeResolver.php +++ b/src/Analyser/SuperglobalTypeResolver.php @@ -38,8 +38,12 @@ 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`, `$_SESSION` and `$GLOBALS` are left out: what they hold depends - * on the SAPI, the host and the application. + * `$_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. + * 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, @@ -47,6 +51,7 @@ final class SuperglobalTypeResolver '_COOKIE' => self::REQUEST_INPUT_TYPE, '_REQUEST' => self::REQUEST_INPUT_TYPE, '_FILES' => 'array, size: int<0, max>}|array{name: array, full_path?: array, type: array, tmp_name: array, error: array, size: array}>', + '_SERVER' => 'array{argc?: int<0, max>, argv?: list, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, ...}', '_ENV' => 'array', ]; diff --git a/tests/PHPStan/Analyser/data/readonly-superglobals.php b/tests/PHPStan/Analyser/data/readonly-superglobals.php index 0ac021a5ba6..4322311b7b3 100644 --- a/tests/PHPStan/Analyser/data/readonly-superglobals.php +++ b/tests/PHPStan/Analyser/data/readonly-superglobals.php @@ -11,16 +11,30 @@ function defaultTypes(): void assertType('array|string>|string>', $_POST); assertType('array|string>|string>', $_REQUEST); assertType('array, full_path?: array, type: array, tmp_name: array, error: array, size: array}|array{name: string, full_path?: string, type: string, tmp_name: string, error: int<0, 8>, size: int<0, max>}>', $_FILES); - assertType('array', $_ENV); + assertType('array{argc?: int<0, max>, argv?: list, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, ...}', $_SERVER); assertType('array', $_SESSION); assertType('array', $GLOBALS); assertNativeType('array', $_GET); assertNativeType('array', $_FILES); + assertNativeType('array', $_SERVER); +} + +function serverOffsets(): void +{ + assertType('string|null', $_SERVER['REQUEST_URI'] ?? null); + assertType('int<0, max>|null', $_SERVER['argc'] ?? null); + assertType('list|null', $_SERVER['argv'] ?? null); + assertType('int|null', $_SERVER['REQUEST_TIME'] ?? null); + assertType('float|null', $_SERVER['REQUEST_TIME_FLOAT'] ?? null); + + if (isset($_SERVER['HTTP_HOST'])) { + assertType('string', $_SERVER['HTTP_HOST']); + } } function configuredTypesTakePrecedence(): void { assertType('array', $_COOKIE); - assertType('array', $_SERVER); + assertType('array', $_ENV); } diff --git a/tests/PHPStan/Analyser/readonly-superglobals.neon b/tests/PHPStan/Analyser/readonly-superglobals.neon index 6127cb1284d..47be013e0ae 100644 --- a/tests/PHPStan/Analyser/readonly-superglobals.neon +++ b/tests/PHPStan/Analyser/readonly-superglobals.neon @@ -2,4 +2,4 @@ parameters: readonlySuperglobals: true superglobalTypes: _COOKIE: 'array' - _SERVER: 'array' + _ENV: 'array' diff --git a/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php index 8a554e8337d..6332800b7e3 100644 --- a/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php +++ b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php @@ -25,27 +25,33 @@ protected function getRule(): Rule public function testRule(): void { $request = 'array|string>|string>'; + $server = 'array{argc?: int<0, max>, argv?: list, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, ...}'; $this->analyse([__DIR__ . '/data/superglobal-assign-type.php'], [ [ sprintf('Superglobal $_GET (%s) does not accept array|string>|int|string>.', $request), - 21, + 23, ], [ sprintf('Superglobal $_POST (%s) does not accept array|int|string>|string>.', $request), - 23, + 25, ], [ 'Superglobal $_ENV (array) does not accept array.', - 24, + 26, + ], + [ + sprintf('Superglobal $_SERVER (%s) does not accept array{argc?: int<0, max>, argv?: list, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, SERVER_PORT: int, ...}.', $server), + 27, + 'Unsealed array value type string does not accept extra offset \'SERVER_PORT\' with value type int.', ], [ // the second int written on the next line is not reported again sprintf('Superglobal $_POST (%s) does not accept array|string>|int|string>.', $request), - 29, + 32, ], [ sprintf('Superglobal $_GET (%s) does not accept string.', $request), - 40, + 43, ], ]); } diff --git a/tests/PHPStan/Rules/Variables/data/superglobal-assign-type.php b/tests/PHPStan/Rules/Variables/data/superglobal-assign-type.php index 2e584b0e77c..3de72f35a05 100644 --- a/tests/PHPStan/Rules/Variables/data/superglobal-assign-type.php +++ b/tests/PHPStan/Rules/Variables/data/superglobal-assign-type.php @@ -10,6 +10,8 @@ function conformingWrites(string $string): void unset($_GET['posted']); parse_str('a=b', $_GET); $_ENV['FOO'] = 'bar'; + $_SERVER['REQUEST_URI'] = $string; + unset($_SERVER['PATH_INFO']); } /** @@ -22,6 +24,7 @@ function nonConformingWrites(array $strings, array $ints, int $int): void $_POST['ids'] = $strings; $_POST['ids'] = $ints; $_ENV['FOO'] = 1; + $_SERVER['SERVER_PORT'] = $int; } function reportedOnce(int $int, string $string): void @@ -44,6 +47,5 @@ function foreachTarget(array $strings): void function untypedSuperglobals(int $int): void { $GLOBALS['foo'] = $int; - $_SERVER['argc'] = $int; $_SESSION['user'] = $int; } From aade2195786085fe87be4378f696000cfecc2509 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Oct 2026 11:28:47 -0700 Subject: [PATCH 5/7] Require REQUEST_TIME and REQUEST_TIME_FLOAT in $_SERVER PHP sets both for every request under every SAPI, in the CLI as well as on the web, so reading them needs no isset(). They are absent only when variables_order leaves out `S`, which leaves `$_SERVER` empty; neither php.ini that PHP ships does that. Every other key stays optional. `PHP_SELF`, `SCRIPT_NAME`, and `SCRIPT_FILENAME` are present in the CLI and the built-in server, but on a real web server they come from its configuration, and a header like `HTTP_HOST` is only there when the client sends it. Co-Authored-By: Claude Opus 5.5 --- src/Analyser/SuperglobalTypeResolver.php | 5 ++++- tests/PHPStan/Analyser/data/readonly-superglobals.php | 6 +++--- .../Rules/Variables/SuperglobalAssignTypeRuleTest.php | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Analyser/SuperglobalTypeResolver.php b/src/Analyser/SuperglobalTypeResolver.php index 1ae92b01301..17304815499 100644 --- a/src/Analyser/SuperglobalTypeResolver.php +++ b/src/Analyser/SuperglobalTypeResolver.php @@ -41,6 +41,9 @@ final class SuperglobalTypeResolver * `$_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 by every + * SAPI, unless `variables_order` leaves out `S` and `$_SERVER` stays empty; + * any other key, a header in particular, is up to the SAPI, the web server or the client. * 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. @@ -51,7 +54,7 @@ final class SuperglobalTypeResolver '_COOKIE' => self::REQUEST_INPUT_TYPE, '_REQUEST' => self::REQUEST_INPUT_TYPE, '_FILES' => 'array, size: int<0, max>}|array{name: array, full_path?: array, type: array, tmp_name: array, error: array, size: array}>', - '_SERVER' => 'array{argc?: int<0, max>, argv?: list, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, ...}', + '_SERVER' => 'array{REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, ...}', '_ENV' => 'array', ]; diff --git a/tests/PHPStan/Analyser/data/readonly-superglobals.php b/tests/PHPStan/Analyser/data/readonly-superglobals.php index 4322311b7b3..e02d7cdcf06 100644 --- a/tests/PHPStan/Analyser/data/readonly-superglobals.php +++ b/tests/PHPStan/Analyser/data/readonly-superglobals.php @@ -11,7 +11,7 @@ function defaultTypes(): void assertType('array|string>|string>', $_POST); assertType('array|string>|string>', $_REQUEST); assertType('array, full_path?: array, type: array, tmp_name: array, error: array, size: array}|array{name: string, full_path?: string, type: string, tmp_name: string, error: int<0, 8>, size: int<0, max>}>', $_FILES); - assertType('array{argc?: int<0, max>, argv?: list, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, ...}', $_SERVER); + assertType('array{REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, ...}', $_SERVER); assertType('array', $_SESSION); assertType('array', $GLOBALS); @@ -25,8 +25,8 @@ function serverOffsets(): void assertType('string|null', $_SERVER['REQUEST_URI'] ?? null); assertType('int<0, max>|null', $_SERVER['argc'] ?? null); assertType('list|null', $_SERVER['argv'] ?? null); - assertType('int|null', $_SERVER['REQUEST_TIME'] ?? null); - assertType('float|null', $_SERVER['REQUEST_TIME_FLOAT'] ?? null); + assertType('int', $_SERVER['REQUEST_TIME']); + assertType('float', $_SERVER['REQUEST_TIME_FLOAT']); if (isset($_SERVER['HTTP_HOST'])) { assertType('string', $_SERVER['HTTP_HOST']); diff --git a/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php index 6332800b7e3..bc4d5189b0d 100644 --- a/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php +++ b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php @@ -25,7 +25,7 @@ protected function getRule(): Rule public function testRule(): void { $request = 'array|string>|string>'; - $server = 'array{argc?: int<0, max>, argv?: list, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, ...}'; + $server = 'array{REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, ...}'; $this->analyse([__DIR__ . '/data/superglobal-assign-type.php'], [ [ sprintf('Superglobal $_GET (%s) does not accept array|string>|int|string>.', $request), @@ -40,7 +40,7 @@ public function testRule(): void 26, ], [ - sprintf('Superglobal $_SERVER (%s) does not accept array{argc?: int<0, max>, argv?: list, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, SERVER_PORT: int, ...}.', $server), + sprintf('Superglobal $_SERVER (%s) does not accept array{REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, SERVER_PORT: int, ...}.', $server), 27, 'Unsealed array value type string does not accept extra offset \'SERVER_PORT\' with value type int.', ], From 3718891612782030cffd9a70ef48e4d63c376e69 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Oct 2026 12:03:39 -0700 Subject: [PATCH 6/7] Require PHP_SELF in $_SERVER Every bundled SAPI registers `PHP_SELF`: CGI and FPM fall back to an empty string when there is no request URI, Apache uses the request URI, and the CLI, the built-in server, LiteSpeed, phpdbg, and embed set it unconditionally. It can be empty, but it is always there. `SCRIPT_NAME` and `SCRIPT_FILENAME` stay optional: the embed SAPI does not set them, under CGI and FPM they come from the web server's parameters, and CGI unsets `SCRIPT_FILENAME` when it cannot find the script. Co-Authored-By: Claude Opus 5.5 --- src/Analyser/SuperglobalTypeResolver.php | 10 ++++++---- tests/PHPStan/Analyser/data/readonly-superglobals.php | 4 +++- .../Rules/Variables/SuperglobalAssignTypeRuleTest.php | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Analyser/SuperglobalTypeResolver.php b/src/Analyser/SuperglobalTypeResolver.php index 17304815499..a0bd51eb9e3 100644 --- a/src/Analyser/SuperglobalTypeResolver.php +++ b/src/Analyser/SuperglobalTypeResolver.php @@ -41,9 +41,11 @@ final class SuperglobalTypeResolver * `$_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 by every - * SAPI, unless `variables_order` leaves out `S` and `$_SERVER` stays empty; - * any other key, a header in particular, is up to the SAPI, the web server or the client. + * `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. @@ -54,7 +56,7 @@ final class SuperglobalTypeResolver '_COOKIE' => self::REQUEST_INPUT_TYPE, '_REQUEST' => self::REQUEST_INPUT_TYPE, '_FILES' => 'array, size: int<0, max>}|array{name: array, full_path?: array, type: array, tmp_name: array, error: array, size: array}>', - '_SERVER' => 'array{REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, ...}', + '_SERVER' => 'array{PHP_SELF: string, REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, ...}', '_ENV' => 'array', ]; diff --git a/tests/PHPStan/Analyser/data/readonly-superglobals.php b/tests/PHPStan/Analyser/data/readonly-superglobals.php index e02d7cdcf06..d9dc38be7bb 100644 --- a/tests/PHPStan/Analyser/data/readonly-superglobals.php +++ b/tests/PHPStan/Analyser/data/readonly-superglobals.php @@ -11,7 +11,7 @@ function defaultTypes(): void assertType('array|string>|string>', $_POST); assertType('array|string>|string>', $_REQUEST); assertType('array, full_path?: array, type: array, tmp_name: array, error: array, size: array}|array{name: string, full_path?: string, type: string, tmp_name: string, error: int<0, 8>, size: int<0, max>}>', $_FILES); - assertType('array{REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, ...}', $_SERVER); + assertType('array{PHP_SELF: string, REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, ...}', $_SERVER); assertType('array', $_SESSION); assertType('array', $GLOBALS); @@ -25,6 +25,8 @@ function serverOffsets(): void assertType('string|null', $_SERVER['REQUEST_URI'] ?? null); assertType('int<0, max>|null', $_SERVER['argc'] ?? null); assertType('list|null', $_SERVER['argv'] ?? null); + assertType('string', $_SERVER['PHP_SELF']); + assertType('string|null', $_SERVER['SCRIPT_FILENAME'] ?? null); assertType('int', $_SERVER['REQUEST_TIME']); assertType('float', $_SERVER['REQUEST_TIME_FLOAT']); diff --git a/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php index bc4d5189b0d..9a45a9c7c97 100644 --- a/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php +++ b/tests/PHPStan/Rules/Variables/SuperglobalAssignTypeRuleTest.php @@ -25,7 +25,7 @@ protected function getRule(): Rule public function testRule(): void { $request = 'array|string>|string>'; - $server = 'array{REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, ...}'; + $server = 'array{PHP_SELF: string, REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, ...}'; $this->analyse([__DIR__ . '/data/superglobal-assign-type.php'], [ [ sprintf('Superglobal $_GET (%s) does not accept array|string>|int|string>.', $request), @@ -40,7 +40,7 @@ public function testRule(): void 26, ], [ - sprintf('Superglobal $_SERVER (%s) does not accept array{REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, SERVER_PORT: int, ...}.', $server), + sprintf('Superglobal $_SERVER (%s) does not accept array{PHP_SELF: string, REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list, SERVER_PORT: int, ...}.', $server), 27, 'Unsealed array value type string does not accept extra offset \'SERVER_PORT\' with value type int.', ], From d20d6d74bda07ec1a6188a703548126080e4b115 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Oct 2026 15:28:53 -0700 Subject: [PATCH 7/7] Reject superglobalTypes keys that are not superglobals A key that names no superglobal was silently ignored: a typo like `_GTE`, the variable written with its sigil as `$_GET`, or a list entry, whose key is an int. The project would believe it had declared a type while the superglobal kept its default. The schema now limits the keys to the names in Scope::SUPERGLOBAL_VARIABLES, so such a key fails the configuration with a message listing the valid names. The test expects the container to fail to build, so ContainerInitializer skips it like InvalidIgnoredErrorExceptionTest. Co-Authored-By: Claude Opus 5.5 --- conf/parametersSchema.neon | 2 +- src/Testing/PHPUnit/ContainerInitializer.php | 6 ++- .../InvalidSuperglobalTypesTest.php | 51 +++++++++++++++++++ .../invalidSuperglobalTypes/dollar-sign.neon | 3 ++ .../invalidSuperglobalTypes/list.neon | 3 ++ .../invalidSuperglobalTypes/typo.neon | 3 ++ 6 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/DependencyInjection/InvalidSuperglobalTypesTest.php create mode 100644 tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/dollar-sign.neon create mode 100644 tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/list.neon create mode 100644 tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/typo.neon diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 65ceb8855c5..58381d84ce5 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -178,7 +178,7 @@ parametersSchema: resultCacheChecksProjectExtensionFilesDependencies: bool() dynamicConstantNames: arrayOf(string()) readonlySuperglobals: bool() - superglobalTypes: arrayOf(string()) + superglobalTypes: arrayOf(string(), anyOf('GLOBALS', '_SERVER', '_GET', '_POST', '_FILES', '_COOKIE', '_SESSION', '_REQUEST', '_ENV')) customRulesetUsed: schema(bool(), nullable()) rootDir: string() tmpDir: string() diff --git a/src/Testing/PHPUnit/ContainerInitializer.php b/src/Testing/PHPUnit/ContainerInitializer.php index 8f29c59aa90..33930e6d679 100644 --- a/src/Testing/PHPUnit/ContainerInitializer.php +++ b/src/Testing/PHPUnit/ContainerInitializer.php @@ -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; } diff --git a/tests/PHPStan/DependencyInjection/InvalidSuperglobalTypesTest.php b/tests/PHPStan/DependencyInjection/InvalidSuperglobalTypesTest.php new file mode 100644 index 00000000000..809729a9768 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/InvalidSuperglobalTypesTest.php @@ -0,0 +1,51 @@ + + */ + public static function dataValidateSuperglobalTypes(): iterable + { + $expected = static fn (string $key, string $given): string => "The key of item 'parameters\u{a0}›\u{a0}superglobalTypes\u{a0}›\u{a0}" . $key . "' expects to be 'GLOBALS'|'_SERVER'|'_GET'|'_POST'|'_FILES'|'_COOKIE'|'_SESSION'|'_REQUEST'|'_ENV', " . $given . ' given.'; + yield [ + __DIR__ . '/invalidSuperglobalTypes/typo.neon', + $expected('_GTE', "'_GTE'"), + ]; + yield [ + __DIR__ . '/invalidSuperglobalTypes/dollar-sign.neon', + $expected('$_GET', '\'$_GET\''), + ]; + yield [ + __DIR__ . '/invalidSuperglobalTypes/list.neon', + $expected('0', '0'), + ]; + } + + #[DataProvider('dataValidateSuperglobalTypes')] + public function testValidateSuperglobalTypes(string $file, string $expectedMessage): void + { + self::$configFile = $file; + $this->expectExceptionMessage($expectedMessage); + self::getContainer(); + } + + public static function getAdditionalConfigFiles(): array + { + $files = parent::getAdditionalConfigFiles(); + if (self::$configFile !== null) { + $files[] = self::$configFile; + } + + return $files; + } + +} diff --git a/tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/dollar-sign.neon b/tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/dollar-sign.neon new file mode 100644 index 00000000000..157ace19e69 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/dollar-sign.neon @@ -0,0 +1,3 @@ +parameters: + superglobalTypes: + $_GET: 'array' diff --git a/tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/list.neon b/tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/list.neon new file mode 100644 index 00000000000..5484c25da8d --- /dev/null +++ b/tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/list.neon @@ -0,0 +1,3 @@ +parameters: + superglobalTypes: + - 'array' diff --git a/tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/typo.neon b/tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/typo.neon new file mode 100644 index 00000000000..bc0b03adab5 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/invalidSuperglobalTypes/typo.neon @@ -0,0 +1,3 @@ +parameters: + superglobalTypes: + _GTE: 'array'