ArgumentFunctionsReportCurrentValueSniff.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. <?php
  2. /**
  3. * PHPCompatibility, an external standard for PHP_CodeSniffer.
  4. *
  5. * @package PHPCompatibility
  6. * @copyright 2012-2019 PHPCompatibility Contributors
  7. * @license https://opensource.org/licenses/LGPL-3.0 LGPL3
  8. * @link https://github.com/PHPCompatibility/PHPCompatibility
  9. */
  10. namespace PHPCompatibility\Sniffs\FunctionUse;
  11. use PHPCompatibility\Sniff;
  12. use PHPCompatibility\PHPCSHelper;
  13. use PHP_CodeSniffer_File as File;
  14. use PHP_CodeSniffer_Tokens as Tokens;
  15. /**
  16. * Functions inspecting function arguments report the current parameter value
  17. * instead of the original since PHP 7.0.
  18. *
  19. * `func_get_arg()`, `func_get_args()`, `debug_backtrace()` and exception backtraces
  20. * will no longer report the original parameter value as was passed to the function,
  21. * but will instead provide the current value (which might have been modified).
  22. *
  23. * PHP version 7.0
  24. *
  25. * @link https://www.php.net/manual/en/migration70.incompatible.php#migration70.incompatible.other.func-parameter-modified
  26. *
  27. * @since 9.1.0
  28. */
  29. class ArgumentFunctionsReportCurrentValueSniff extends Sniff
  30. {
  31. /**
  32. * A list of functions that, when called, can behave differently in PHP 7
  33. * when dealing with parameters of the function they're called in.
  34. *
  35. * @since 9.1.0
  36. *
  37. * @var array
  38. */
  39. protected $changedFunctions = array(
  40. 'func_get_arg' => true,
  41. 'func_get_args' => true,
  42. 'debug_backtrace' => true,
  43. 'debug_print_backtrace' => true,
  44. );
  45. /**
  46. * Tokens to look out for to allow us to skip past nested scoped structures.
  47. *
  48. * @since 9.1.0
  49. *
  50. * @var array
  51. */
  52. private $skipPastNested = array(
  53. 'T_CLASS' => true,
  54. 'T_ANON_CLASS' => true,
  55. 'T_INTERFACE' => true,
  56. 'T_TRAIT' => true,
  57. 'T_FUNCTION' => true,
  58. 'T_CLOSURE' => true,
  59. );
  60. /**
  61. * List of tokens which when they preceed a T_STRING *within a function* indicate
  62. * this is not a call to a PHP native function.
  63. *
  64. * This list already takes into account that nested scoped structures are being
  65. * skipped over, so doesn't check for those again.
  66. * Similarly, as constants won't have parentheses, those don't need to be checked
  67. * for either.
  68. *
  69. * @since 9.1.0
  70. *
  71. * @var array
  72. */
  73. private $noneFunctionCallIndicators = array(
  74. \T_DOUBLE_COLON => true,
  75. \T_OBJECT_OPERATOR => true,
  76. );
  77. /**
  78. * The tokens for variable incrementing/decrementing.
  79. *
  80. * @since 9.1.0
  81. *
  82. * @var array
  83. */
  84. private $plusPlusMinusMinus = array(
  85. \T_DEC => true,
  86. \T_INC => true,
  87. );
  88. /**
  89. * Tokens to ignore when determining the start of a statement.
  90. *
  91. * @since 9.1.0
  92. *
  93. * @var array
  94. */
  95. private $ignoreForStartOfStatement = array(
  96. \T_COMMA,
  97. \T_DOUBLE_ARROW,
  98. \T_OPEN_SQUARE_BRACKET,
  99. \T_OPEN_PARENTHESIS,
  100. );
  101. /**
  102. * Returns an array of tokens this test wants to listen for.
  103. *
  104. * @since 9.1.0
  105. *
  106. * @return array
  107. */
  108. public function register()
  109. {
  110. return array(
  111. \T_FUNCTION,
  112. \T_CLOSURE,
  113. );
  114. }
  115. /**
  116. * Processes this test, when one of its tokens is encountered.
  117. *
  118. * @since 9.1.0
  119. *
  120. * @param \PHP_CodeSniffer_File $phpcsFile The file being scanned.
  121. * @param int $stackPtr The position of the current token
  122. * in the stack passed in $tokens.
  123. *
  124. * @return void
  125. */
  126. public function process(File $phpcsFile, $stackPtr)
  127. {
  128. if ($this->supportsAbove('7.0') === false) {
  129. return;
  130. }
  131. $tokens = $phpcsFile->getTokens();
  132. if (isset($tokens[$stackPtr]['scope_opener'], $tokens[$stackPtr]['scope_closer']) === false) {
  133. // Abstract function, interface function, live coding or parse error.
  134. return;
  135. }
  136. $scopeOpener = $tokens[$stackPtr]['scope_opener'];
  137. $scopeCloser = $tokens[$stackPtr]['scope_closer'];
  138. // Does the function declaration have parameters ?
  139. $params = PHPCSHelper::getMethodParameters($phpcsFile, $stackPtr);
  140. if (empty($params)) {
  141. // No named arguments found, so no risk of them being changed.
  142. return;
  143. }
  144. $paramNames = array();
  145. foreach ($params as $param) {
  146. $paramNames[] = $param['name'];
  147. }
  148. for ($i = ($scopeOpener + 1); $i < $scopeCloser; $i++) {
  149. if (isset($this->skipPastNested[$tokens[$i]['type']]) && isset($tokens[$i]['scope_closer'])) {
  150. // Skip past nested structures.
  151. $i = $tokens[$i]['scope_closer'];
  152. continue;
  153. }
  154. if ($tokens[$i]['code'] !== \T_STRING) {
  155. continue;
  156. }
  157. $foundFunctionName = strtolower($tokens[$i]['content']);
  158. if (isset($this->changedFunctions[$foundFunctionName]) === false) {
  159. // Not one of the target functions.
  160. continue;
  161. }
  162. /*
  163. * Ok, so is this really a function call to one of the PHP native functions ?
  164. */
  165. $next = $phpcsFile->findNext(Tokens::$emptyTokens, ($i + 1), null, true);
  166. if ($next === false || $tokens[$next]['code'] !== \T_OPEN_PARENTHESIS) {
  167. // Live coding, parse error or not a function call.
  168. continue;
  169. }
  170. $prev = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($i - 1), null, true);
  171. if ($prev !== false) {
  172. if (isset($this->noneFunctionCallIndicators[$tokens[$prev]['code']])) {
  173. continue;
  174. }
  175. // Check for namespaced functions, ie: \foo\bar() not \bar().
  176. if ($tokens[ $prev ]['code'] === \T_NS_SEPARATOR) {
  177. $pprev = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($prev - 1), null, true);
  178. if ($pprev !== false && $tokens[ $pprev ]['code'] === \T_STRING) {
  179. continue;
  180. }
  181. }
  182. }
  183. /*
  184. * Address some special cases.
  185. */
  186. if ($foundFunctionName !== 'func_get_args') {
  187. $paramOne = $this->getFunctionCallParameter($phpcsFile, $i, 1);
  188. if ($paramOne !== false) {
  189. switch ($foundFunctionName) {
  190. /*
  191. * Check if `debug_(print_)backtrace()` is called with the
  192. * `DEBUG_BACKTRACE_IGNORE_ARGS` option.
  193. */
  194. case 'debug_backtrace':
  195. case 'debug_print_backtrace':
  196. $hasIgnoreArgs = $phpcsFile->findNext(
  197. \T_STRING,
  198. $paramOne['start'],
  199. ($paramOne['end'] + 1),
  200. false,
  201. 'DEBUG_BACKTRACE_IGNORE_ARGS'
  202. );
  203. if ($hasIgnoreArgs !== false) {
  204. // Debug_backtrace() called with ignore args option.
  205. continue 2;
  206. }
  207. break;
  208. /*
  209. * Collect the necessary information to only throw a notice if the argument
  210. * touched/changed is in line with the passed $arg_num.
  211. *
  212. * Also, we can ignore `func_get_arg()` if the argument offset passed is
  213. * higher than the number of named parameters.
  214. *
  215. * {@internal Note: This does not take calculations into account!
  216. * Should be exceptionally rare and can - if needs be - be addressed at a later stage.}
  217. */
  218. case 'func_get_arg':
  219. $number = $phpcsFile->findNext(\T_LNUMBER, $paramOne['start'], ($paramOne['end'] + 1));
  220. if ($number !== false) {
  221. $argNumber = $tokens[$number]['content'];
  222. if (isset($paramNames[$argNumber]) === false) {
  223. // Requesting a non-named additional parameter. Ignore.
  224. continue 2;
  225. }
  226. }
  227. break;
  228. }
  229. }
  230. } else {
  231. /*
  232. * Check if the call to func_get_args() happens to be in an array_slice() or
  233. * array_splice() with an $offset higher than the number of named parameters.
  234. * In that case, we can ignore it.
  235. *
  236. * {@internal Note: This does not take offset calculations into account!
  237. * Should be exceptionally rare and can - if needs be - be addressed at a later stage.}
  238. */
  239. if ($prev !== false && $tokens[$prev]['code'] === \T_OPEN_PARENTHESIS) {
  240. $maybeFunctionCall = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($prev - 1), null, true);
  241. if ($maybeFunctionCall !== false
  242. && $tokens[$maybeFunctionCall]['code'] === \T_STRING
  243. && ($tokens[$maybeFunctionCall]['content'] === 'array_slice'
  244. || $tokens[$maybeFunctionCall]['content'] === 'array_splice')
  245. ) {
  246. $parentFuncParamTwo = $this->getFunctionCallParameter($phpcsFile, $maybeFunctionCall, 2);
  247. $number = $phpcsFile->findNext(
  248. \T_LNUMBER,
  249. $parentFuncParamTwo['start'],
  250. ($parentFuncParamTwo['end'] + 1)
  251. );
  252. if ($number !== false && isset($paramNames[$tokens[$number]['content']]) === false) {
  253. // Requesting non-named additional parameters. Ignore.
  254. continue ;
  255. }
  256. // Slice starts at a named argument, but we know which params are being accessed.
  257. $paramNamesSubset = \array_slice($paramNames, $tokens[$number]['content']);
  258. }
  259. }
  260. }
  261. /*
  262. * For debug_backtrace(), check if the result is being dereferenced and if so,
  263. * whether the `args` index is used.
  264. * I.e. whether `$index` in `debug_backtrace()[$stackFrame][$index]` is a string
  265. * with the content `args`.
  266. *
  267. * Note: We already know that $next is the open parenthesis of the function call.
  268. */
  269. if ($foundFunctionName === 'debug_backtrace' && isset($tokens[$next]['parenthesis_closer'])) {
  270. $afterParenthesis = $phpcsFile->findNext(
  271. Tokens::$emptyTokens,
  272. ($tokens[$next]['parenthesis_closer'] + 1),
  273. null,
  274. true
  275. );
  276. if ($tokens[$afterParenthesis]['code'] === \T_OPEN_SQUARE_BRACKET
  277. && isset($tokens[$afterParenthesis]['bracket_closer'])
  278. ) {
  279. $afterStackFrame = $phpcsFile->findNext(
  280. Tokens::$emptyTokens,
  281. ($tokens[$afterParenthesis]['bracket_closer'] + 1),
  282. null,
  283. true
  284. );
  285. if ($tokens[$afterStackFrame]['code'] === \T_OPEN_SQUARE_BRACKET
  286. && isset($tokens[$afterStackFrame]['bracket_closer'])
  287. ) {
  288. $arrayIndex = $phpcsFile->findNext(
  289. \T_CONSTANT_ENCAPSED_STRING,
  290. ($afterStackFrame + 1),
  291. $tokens[$afterStackFrame]['bracket_closer']
  292. );
  293. if ($arrayIndex !== false && $this->stripQuotes($tokens[$arrayIndex]['content']) !== 'args') {
  294. continue;
  295. }
  296. }
  297. }
  298. }
  299. /*
  300. * Only check for variables before the start of the statement to
  301. * prevent false positives on the return value of the function call
  302. * being assigned to one of the parameters, i.e.:
  303. * `$param = func_get_args();`.
  304. */
  305. $startOfStatement = PHPCSHelper::findStartOfStatement($phpcsFile, $i, $this->ignoreForStartOfStatement);
  306. /*
  307. * Ok, so we've found one of the target functions in the right scope.
  308. * Now, let's check if any of the passed parameters were touched.
  309. */
  310. $scanResult = 'clean';
  311. for ($j = ($scopeOpener + 1); $j < $startOfStatement; $j++) {
  312. if (isset($this->skipPastNested[$tokens[$j]['type']])
  313. && isset($tokens[$j]['scope_closer'])
  314. ) {
  315. // Skip past nested structures.
  316. $j = $tokens[$j]['scope_closer'];
  317. continue;
  318. }
  319. if ($tokens[$j]['code'] !== \T_VARIABLE) {
  320. continue;
  321. }
  322. if ($foundFunctionName === 'func_get_arg' && isset($argNumber)) {
  323. if (isset($paramNames[$argNumber])
  324. && $tokens[$j]['content'] !== $paramNames[$argNumber]
  325. ) {
  326. // Different param than the one requested by func_get_arg().
  327. continue;
  328. }
  329. } elseif ($foundFunctionName === 'func_get_args' && isset($paramNamesSubset)) {
  330. if (\in_array($tokens[$j]['content'], $paramNamesSubset, true) === false) {
  331. // Different param than the ones requested by func_get_args().
  332. continue;
  333. }
  334. } elseif (\in_array($tokens[$j]['content'], $paramNames, true) === false) {
  335. // Variable is not one of the function parameters.
  336. continue;
  337. }
  338. /*
  339. * Ok, so we've found a variable which was passed as one of the parameters.
  340. * Now, is this variable being changed, i.e. incremented, decremented or
  341. * assigned something ?
  342. */
  343. $scanResult = 'warning';
  344. if (isset($variableToken) === false) {
  345. $variableToken = $j;
  346. }
  347. $beforeVar = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($j - 1), null, true);
  348. if ($beforeVar !== false && isset($this->plusPlusMinusMinus[$tokens[$beforeVar]['code']])) {
  349. // Variable is being (pre-)incremented/decremented.
  350. $scanResult = 'error';
  351. $variableToken = $j;
  352. break;
  353. }
  354. $afterVar = $phpcsFile->findNext(Tokens::$emptyTokens, ($j + 1), null, true);
  355. if ($afterVar === false) {
  356. // Shouldn't be possible, but just in case.
  357. continue;
  358. }
  359. if (isset($this->plusPlusMinusMinus[$tokens[$afterVar]['code']])) {
  360. // Variable is being (post-)incremented/decremented.
  361. $scanResult = 'error';
  362. $variableToken = $j;
  363. break;
  364. }
  365. if ($tokens[$afterVar]['code'] === \T_OPEN_SQUARE_BRACKET
  366. && isset($tokens[$afterVar]['bracket_closer'])
  367. ) {
  368. // Skip past array access on the variable.
  369. while (($afterVar = $phpcsFile->findNext(Tokens::$emptyTokens, ($tokens[$afterVar]['bracket_closer'] + 1), null, true)) !== false) {
  370. if ($tokens[$afterVar]['code'] !== \T_OPEN_SQUARE_BRACKET
  371. || isset($tokens[$afterVar]['bracket_closer']) === false
  372. ) {
  373. break;
  374. }
  375. }
  376. }
  377. if ($afterVar !== false
  378. && isset(Tokens::$assignmentTokens[$tokens[$afterVar]['code']])
  379. ) {
  380. // Variable is being assigned something.
  381. $scanResult = 'error';
  382. $variableToken = $j;
  383. break;
  384. }
  385. }
  386. unset($argNumber, $paramNamesSubset);
  387. if ($scanResult === 'clean') {
  388. continue;
  389. }
  390. $error = 'Since PHP 7.0, functions inspecting arguments, like %1$s(), no longer report the original value as passed to a parameter, but will instead provide the current value. The parameter "%2$s" was %4$s on line %3$s.';
  391. $data = array(
  392. $foundFunctionName,
  393. $tokens[$variableToken]['content'],
  394. $tokens[$variableToken]['line'],
  395. );
  396. if ($scanResult === 'error') {
  397. $data[] = 'changed';
  398. $phpcsFile->addError($error, $i, 'Changed', $data);
  399. } elseif ($scanResult === 'warning') {
  400. $data[] = 'used, and possibly changed (by reference),';
  401. $phpcsFile->addWarning($error, $i, 'NeedsInspection', $data);
  402. }
  403. unset($variableToken);
  404. }
  405. }
  406. }