<?php
/**
* PHPCompatibility, an external standard for PHP_CodeSniffer.
*
* @package PHPCompatibility
* @copyright 2012-2021 PHPCompatibility Contributors
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
* @link https://github.com/PHPCompatibility/PHPCompatibility
*/
namespace PHPCompatibility\Sniffs\Variables;
use PHPCompatibility\Helpers\ScannedCode;
use PHPCompatibility\Sniff;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Context;
use PHPCSUtils\Utils\Operators;
use PHPCSUtils\Utils\Parentheses;
use PHPCSUtils\Utils\PassedParameters;
use PHPCSUtils\Utils\TextStrings;
use ReflectionFunction;
/**
* Detect indirect modification of the `$GLOBALS` variable and related changes in PHP 8.1.
*
* As of PHP 8.1, the `$GLOBALS` array is effectively a read-only copy of the global symbol table.
*
* This has the following side-effects:
* - The recursive `$GLOBALS['GLOBALS']` entry no longer exists.
* - Appending unnamed entries to the `$GLOBALS` array is no longer supported.
* - Assignments which overwrite the `$GLOBALS` array are no longer allowed.
* This includes unsetting the `$GLOBALS` variable and passing the `$GLOBALS` variable
* to functions which would modify the received parameter by reference.
* - Creating a reference to the `$GLOBALS` array is no longer allowed.
*
* Additionally, there are a couple of situations in which the behaviour around the use of $GLOBALS
* has changed.
*
* PHP version 8.1
*
* @link https://www.php.net/manual/en/migration81.incompatible.php#migration81.incompatible.core.globals-access
* @link https://wiki.php.net/rfc/restrict_globals_usage
*
* @since 10.0.0
*/
final class RemovedIndirectModificationOfGlobalsSniff extends Sniff
{
const WRITE_ERROR = 'Only individual keys in the $GLOBALS variable can be modified. The top-level $GLOBALS variable is read-only since PHP 8.1. Detected: %s';
/**
* List of PHP native functions.
*
* Will be set the first time the list is needed.
*
* @var array<string, int> Key is the function name in lowercase, value irrelevant.
*/
private $phpNativeFunctions;
/**
* Returns an array of tokens this test wants to listen for.
*
* @since 10.0.0
*
* @return array<int|string>
*/
public function register()
{
return [\T_VARIABLE];
}
/**
* Processes this test, when one of its tokens is encountered.
*
* @since 10.0.0
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param int $stackPtr The position of the current token in
* the stack passed in $tokens.
*
* @return void
*/
public function process(File $phpcsFile, $stackPtr)
{
if (ScannedCode::shouldRunOnOrAbove('8.1') === false) {
return;
}
$tokens = $phpcsFile->getTokens();
if ($tokens[$stackPtr]['content'] !== '$GLOBALS') {
return;
}
$nextNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, ($stackPtr + 1), null, true);
if ($nextNonEmpty === false) {
// Live coding or parse error.
return;
}
if ($tokens[$nextNonEmpty]['code'] === \T_OPEN_SQUARE_BRACKET) {
if (isset($tokens[$nextNonEmpty]['bracket_closer']) === false) {
// Live coding or parse error.
return;
}
$searchStart = ($nextNonEmpty + 1);
$searchEnd = $tokens[$nextNonEmpty]['bracket_closer'];
$hasNonEmptyTokens = $phpcsFile->findNext(Tokens::$emptyTokens, $searchStart, $searchEnd, true);
if ($hasNonEmptyTokens === false) {
// Assigning to a new array key.
$phpcsFile->addError(
'Appending a new non-named array entry to the $GLOBALS variable is not allowed since PHP 8.1.',
$stackPtr,
'Appending'
);
return;
}
$allowedNumeric = Tokens::$emptyTokens;
$allowedNumeric[\T_LNUMBER] = \T_LNUMBER;
$allowedNumeric[\T_DNUMBER] = \T_DNUMBER;
$hasNonNumeric = $phpcsFile->findNext($allowedNumeric, $searchStart, $searchEnd, true);
if ($hasNonNumeric === false) {
if (ScannedCode::shouldRunOnOrBelow('8.0') === true) {
// This warning will only be thrown when both PHP < 8.1 and PHP 8.1+ need to be supported.
$phpcsFile->addWarning(
'The way variables with an integer or floating point variable name are stored in the $GLOBALS variable has changed in PHP 8.1. Please review your code to evaluate the impact.',
$stackPtr,
'NumericKey'
);
}
return;
}
/*
* Note: doesn't allow for heredoc/nowdoc string keys, but that would be excedingly rare anyway.
* Also doesn't allow for T_DOUBLE_QUOTED_STRING as that means there is variable interpolation
* and that would never result in a match anyway.
*/
$allowedText = Tokens::$emptyTokens;
$allowedText[\T_CONSTANT_ENCAPSED_STRING] = \T_CONSTANT_ENCAPSED_STRING;
$hasNonText = $phpcsFile->findNext($allowedText, $searchStart, $searchEnd, true);
if ($hasNonText === false) {
$key = $phpcsFile->findNext([\T_CONSTANT_ENCAPSED_STRING], $searchStart, $searchEnd);
if (TextStrings::stripQuotes($tokens[$key]['content']) === 'GLOBALS') {
$phpcsFile->addError(
'The recursive $GLOBALS[\'GLOBALS\'] key no longer exists since PHP 8.1.',
$key,
'RecursiveKeyAccess'
);
return;
}
}
// In all other cases, this will be in access/use of $GLOBALS with a key without changed behaviour.
return;
}
$lastOwner = Parentheses::getLastOwner($phpcsFile, $stackPtr);
if ($tokens[$lastOwner]['code'] === \T_UNSET) {
$phpcsFile->addError(
'Unsetting the $GLOBALS variable is not allowed since PHP 8.1.',
$stackPtr,
'Unset'
);
return;
}
if ($tokens[$lastOwner]['code'] === \T_LIST) {
$phpcsFile->addError(
\sprintf(self::WRITE_ERROR, 'list assignment'),
$stackPtr,
'ListWrite'
);
return;
}
if ($tokens[$lastOwner]['code'] === \T_FOREACH) {
$inForeach = Context::inForeachCondition($phpcsFile, $stackPtr);
if ($inForeach === 'beforeAs') {
// Read access
return;
}
if ($inForeach === 'afterAs') {
$phpcsFile->addError(
\sprintf(self::WRITE_ERROR, 'foreach assignment'),
$stackPtr,
'ForeachWrite'
);
return;
}
}
if (isset(Tokens::$assignmentTokens[$tokens[$nextNonEmpty]['code']]) === true) {
$phpcsFile->addError(
\sprintf(self::WRITE_ERROR, 'assignment to $GLOBALS'),
$stackPtr,
'Overwrite'
);
return;
}
$prevNonEmpty = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($stackPtr - 1), null, true);
if ($tokens[$prevNonEmpty]['code'] === \T_BITWISE_AND
&& Operators::isReference($phpcsFile, $prevNonEmpty) === true
) {
$phpcsFile->addError(
'Creating a reference to the $GLOBALS variable is no longer supported since PHP 8.1.',
$stackPtr,
'Reference'
);
return;
}
if (($tokens[$prevNonEmpty]['code'] === \T_EQUAL
|| $tokens[$prevNonEmpty]['code'] === \T_COALESCE_EQUAL)
&& ScannedCode::shouldRunOnOrBelow('8.0') === true
) {
// This warning will only be thrown when both PHP < 8.1 and PHP 8.1+ need to be supported.
$phpcsFile->addWarning(
'Prior to PHP 8.1, assignment of $GLOBALS to another variable would effectively function like a reference. Since PHP 8.1, it creates a by-value copy.',
$stackPtr,
'AssignmentOfGlobals'
);
return;
}
if (isset(Collections::incrementDecrementOperators()[$tokens[$prevNonEmpty]['code']]) === true
|| isset(Collections::incrementDecrementOperators()[$tokens[$nextNonEmpty]['code']]) === true
) {
$phpcsFile->addError(
\sprintf(self::WRITE_ERROR, 'variable increment/decrement'),
$stackPtr,
'IncrementDecrement'
);
return;
}
$functionPtr = $this->isInNamedPHPNativeFunctionCall($phpcsFile, $stackPtr);
if ($functionPtr !== false) {
$this->checkFunctionCallForPassByReference($phpcsFile, $stackPtr, $functionPtr);
}
}
/**
* Check if `$GLOBALS` is (part of) a parameter passed to a global function call to a PHP native function.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param int $stackPtr The position of the current token.
*
* @return int|false Integer stack pointer to the function name token
* or false when not in a function call.
*/
private function isInNamedPHPNativeFunctionCall(File $phpcsFile, $stackPtr)
{
$tokens = $phpcsFile->getTokens();
$lastOpenParens = Parentheses::getLastOpener($phpcsFile, $stackPtr);
if ($lastOpenParens === false
|| isset($tokens[$lastOpenParens]['parenthesis_owner']) === true
) {
return false;
}
$maybeLabel = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($lastOpenParens - 1), null, true);
if ($maybeLabel === false
|| ($tokens[$maybeLabel]['code'] !== \T_STRING
&& $tokens[$maybeLabel]['code'] !== \T_NAME_FULLY_QUALIFIED)
) {
// Not a named function call.
return false;
}
// Set the native function property only once.
if (isset($this->phpNativeFunctions) === false) {
$functions = \get_defined_functions();
$functions = \array_flip($functions['internal']);
$this->phpNativeFunctions = \array_change_key_case($functions, \CASE_LOWER);
}
$functionName = \strtolower($tokens[$maybeLabel]['content']);
if ($tokens[$maybeLabel]['code'] === \T_NAME_FULLY_QUALIFIED) {
$functionName = \ltrim($functionName, '\\');
}
if (isset($this->phpNativeFunctions[$functionName]) === false) {
// Definitely not a PHP native function call.
return false;
}
$beforeLabel = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($maybeLabel - 1), null, true);
if ($beforeLabel !== false
&& (isset(Collections::objectOperators()[$tokens[$beforeLabel]['code']]) === true
|| $tokens[$beforeLabel]['code'] === \T_NEW)
) {
// Method call or class instantiation, not global function call.
return false;
}
if ($tokens[$beforeLabel]['code'] === \T_NS_SEPARATOR) {
$prevPrevToken = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($beforeLabel - 1), null, true);
if ($tokens[$prevPrevToken]['code'] === \T_STRING
|| $tokens[$prevPrevToken]['code'] === \T_NAMESPACE
) {
// Namespaced function call.
return false;
}
}
return $maybeLabel;
}
/**
* Check if `$GLOBALS` is in a parameter which is passed by reference.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param int $stackPtr The position of the current token.
* @param int $functionPtr The position of the function call name token.
*
* @return void
*/
private function checkFunctionCallForPassByReference(File $phpcsFile, $stackPtr, $functionPtr)
{
$tokens = $phpcsFile->getTokens();
$reflectionFunction = new ReflectionFunction($tokens[$functionPtr]['content']);
$declaredParams = $reflectionFunction->getParameters();
$referenceParams = [];
foreach ($declaredParams as $param) {
if ($param->isPassedByReference() === true) {
// Note: position is 0-based, while PassedParameters uses 1-based.
$referenceParams[$param->getPosition()] = $param->getName();
}
}
if (empty($referenceParams)) {
// Function doesn't change any parameters by reference.
return;
}
$params = PassedParameters::getParameters($phpcsFile, $functionPtr);
foreach ($referenceParams as $position => $name) {
$param = PassedParameters::getParameterFromStack($params, ($position + 1), $name);
if ($param !== false
&& ($stackPtr >= $param['start'] && $stackPtr <= $param['end'])
) {
// $GLOBALS was passed to the function call by reference.
$phpcsFile->addError(
\sprintf(self::WRITE_ERROR, 'pass by reference'),
$stackPtr,
'PassByReference'
);
}
}
}
}