annotate vendor/psy/psysh/src/CodeCleaner.php @ 13:5fb285c0d0e3

Update Drupal core to 8.4.7 via Composer. Security update; I *think* we've been lucky to get away with this so far, as we don't support self-registration which seems to be used by the so-called "drupalgeddon 2" attack that 8.4.5 was vulnerable to.
author Chris Cannam
date Mon, 23 Apr 2018 09:33:26 +0100
parents
children c2387f117808
rev   line source
Chris@13 1 <?php
Chris@13 2
Chris@13 3 /*
Chris@13 4 * This file is part of Psy Shell.
Chris@13 5 *
Chris@13 6 * (c) 2012-2018 Justin Hileman
Chris@13 7 *
Chris@13 8 * For the full copyright and license information, please view the LICENSE
Chris@13 9 * file that was distributed with this source code.
Chris@13 10 */
Chris@13 11
Chris@13 12 namespace Psy;
Chris@13 13
Chris@13 14 use PhpParser\NodeTraverser;
Chris@13 15 use PhpParser\Parser;
Chris@13 16 use PhpParser\PrettyPrinter\Standard as Printer;
Chris@13 17 use Psy\CodeCleaner\AbstractClassPass;
Chris@13 18 use Psy\CodeCleaner\AssignThisVariablePass;
Chris@13 19 use Psy\CodeCleaner\CalledClassPass;
Chris@13 20 use Psy\CodeCleaner\CallTimePassByReferencePass;
Chris@13 21 use Psy\CodeCleaner\ExitPass;
Chris@13 22 use Psy\CodeCleaner\FinalClassPass;
Chris@13 23 use Psy\CodeCleaner\FunctionContextPass;
Chris@13 24 use Psy\CodeCleaner\FunctionReturnInWriteContextPass;
Chris@13 25 use Psy\CodeCleaner\ImplicitReturnPass;
Chris@13 26 use Psy\CodeCleaner\InstanceOfPass;
Chris@13 27 use Psy\CodeCleaner\LeavePsyshAlonePass;
Chris@13 28 use Psy\CodeCleaner\LegacyEmptyPass;
Chris@13 29 use Psy\CodeCleaner\LoopContextPass;
Chris@13 30 use Psy\CodeCleaner\MagicConstantsPass;
Chris@13 31 use Psy\CodeCleaner\NamespacePass;
Chris@13 32 use Psy\CodeCleaner\PassableByReferencePass;
Chris@13 33 use Psy\CodeCleaner\RequirePass;
Chris@13 34 use Psy\CodeCleaner\StrictTypesPass;
Chris@13 35 use Psy\CodeCleaner\UseStatementPass;
Chris@13 36 use Psy\CodeCleaner\ValidClassNamePass;
Chris@13 37 use Psy\CodeCleaner\ValidConstantPass;
Chris@13 38 use Psy\CodeCleaner\ValidConstructorPass;
Chris@13 39 use Psy\CodeCleaner\ValidFunctionNamePass;
Chris@13 40 use Psy\Exception\ParseErrorException;
Chris@13 41
Chris@13 42 /**
Chris@13 43 * A service to clean up user input, detect parse errors before they happen,
Chris@13 44 * and generally work around issues with the PHP code evaluation experience.
Chris@13 45 */
Chris@13 46 class CodeCleaner
Chris@13 47 {
Chris@13 48 private $parser;
Chris@13 49 private $printer;
Chris@13 50 private $traverser;
Chris@13 51 private $namespace;
Chris@13 52
Chris@13 53 /**
Chris@13 54 * CodeCleaner constructor.
Chris@13 55 *
Chris@13 56 * @param Parser $parser A PhpParser Parser instance. One will be created if not explicitly supplied
Chris@13 57 * @param Printer $printer A PhpParser Printer instance. One will be created if not explicitly supplied
Chris@13 58 * @param NodeTraverser $traverser A PhpParser NodeTraverser instance. One will be created if not explicitly supplied
Chris@13 59 */
Chris@13 60 public function __construct(Parser $parser = null, Printer $printer = null, NodeTraverser $traverser = null)
Chris@13 61 {
Chris@13 62 if ($parser === null) {
Chris@13 63 $parserFactory = new ParserFactory();
Chris@13 64 $parser = $parserFactory->createParser();
Chris@13 65 }
Chris@13 66
Chris@13 67 $this->parser = $parser;
Chris@13 68 $this->printer = $printer ?: new Printer();
Chris@13 69 $this->traverser = $traverser ?: new NodeTraverser();
Chris@13 70
Chris@13 71 foreach ($this->getDefaultPasses() as $pass) {
Chris@13 72 $this->traverser->addVisitor($pass);
Chris@13 73 }
Chris@13 74 }
Chris@13 75
Chris@13 76 /**
Chris@13 77 * Get default CodeCleaner passes.
Chris@13 78 *
Chris@13 79 * @return array
Chris@13 80 */
Chris@13 81 private function getDefaultPasses()
Chris@13 82 {
Chris@13 83 $useStatementPass = new UseStatementPass();
Chris@13 84 $namespacePass = new NamespacePass($this);
Chris@13 85
Chris@13 86 // Try to add implicit `use` statements and an implicit namespace,
Chris@13 87 // based on the file in which the `debug` call was made.
Chris@13 88 $this->addImplicitDebugContext([$useStatementPass, $namespacePass]);
Chris@13 89
Chris@13 90 return [
Chris@13 91 // Validation passes
Chris@13 92 new AbstractClassPass(),
Chris@13 93 new AssignThisVariablePass(),
Chris@13 94 new CalledClassPass(),
Chris@13 95 new CallTimePassByReferencePass(),
Chris@13 96 new FinalClassPass(),
Chris@13 97 new FunctionContextPass(),
Chris@13 98 new FunctionReturnInWriteContextPass(),
Chris@13 99 new InstanceOfPass(),
Chris@13 100 new LeavePsyshAlonePass(),
Chris@13 101 new LegacyEmptyPass(),
Chris@13 102 new LoopContextPass(),
Chris@13 103 new PassableByReferencePass(),
Chris@13 104 new ValidConstructorPass(),
Chris@13 105
Chris@13 106 // Rewriting shenanigans
Chris@13 107 $useStatementPass, // must run before the namespace pass
Chris@13 108 new ExitPass(),
Chris@13 109 new ImplicitReturnPass(),
Chris@13 110 new MagicConstantsPass(),
Chris@13 111 $namespacePass, // must run after the implicit return pass
Chris@13 112 new RequirePass(),
Chris@13 113 new StrictTypesPass(),
Chris@13 114
Chris@13 115 // Namespace-aware validation (which depends on aforementioned shenanigans)
Chris@13 116 new ValidClassNamePass(),
Chris@13 117 new ValidConstantPass(),
Chris@13 118 new ValidFunctionNamePass(),
Chris@13 119 ];
Chris@13 120 }
Chris@13 121
Chris@13 122 /**
Chris@13 123 * "Warm up" code cleaner passes when we're coming from a debug call.
Chris@13 124 *
Chris@13 125 * This is useful, for example, for `UseStatementPass` and `NamespacePass`
Chris@13 126 * which keep track of state between calls, to maintain the current
Chris@13 127 * namespace and a map of use statements.
Chris@13 128 *
Chris@13 129 * @param array $passes
Chris@13 130 */
Chris@13 131 private function addImplicitDebugContext(array $passes)
Chris@13 132 {
Chris@13 133 $file = $this->getDebugFile();
Chris@13 134 if ($file === null) {
Chris@13 135 return;
Chris@13 136 }
Chris@13 137
Chris@13 138 try {
Chris@13 139 $code = @file_get_contents($file);
Chris@13 140 if (!$code) {
Chris@13 141 return;
Chris@13 142 }
Chris@13 143
Chris@13 144 $stmts = $this->parse($code, true);
Chris@13 145 if ($stmts === false) {
Chris@13 146 return;
Chris@13 147 }
Chris@13 148
Chris@13 149 // Set up a clean traverser for just these code cleaner passes
Chris@13 150 $traverser = new NodeTraverser();
Chris@13 151 foreach ($passes as $pass) {
Chris@13 152 $traverser->addVisitor($pass);
Chris@13 153 }
Chris@13 154
Chris@13 155 $traverser->traverse($stmts);
Chris@13 156 } catch (\Throwable $e) {
Chris@13 157 // Don't care.
Chris@13 158 } catch (\Exception $e) {
Chris@13 159 // Still don't care.
Chris@13 160 }
Chris@13 161 }
Chris@13 162
Chris@13 163 /**
Chris@13 164 * Search the stack trace for a file in which the user called Psy\debug.
Chris@13 165 *
Chris@13 166 * @return string|null
Chris@13 167 */
Chris@13 168 private static function getDebugFile()
Chris@13 169 {
Chris@13 170 $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
Chris@13 171
Chris@13 172 foreach (array_reverse($trace) as $stackFrame) {
Chris@13 173 if (!self::isDebugCall($stackFrame)) {
Chris@13 174 continue;
Chris@13 175 }
Chris@13 176
Chris@13 177 if (preg_match('/eval\(/', $stackFrame['file'])) {
Chris@13 178 preg_match_all('/([^\(]+)\((\d+)/', $stackFrame['file'], $matches);
Chris@13 179
Chris@13 180 return $matches[1][0];
Chris@13 181 }
Chris@13 182
Chris@13 183 return $stackFrame['file'];
Chris@13 184 }
Chris@13 185 }
Chris@13 186
Chris@13 187 /**
Chris@13 188 * Check whether a given backtrace frame is a call to Psy\debug.
Chris@13 189 *
Chris@13 190 * @param array $stackFrame
Chris@13 191 *
Chris@13 192 * @return bool
Chris@13 193 */
Chris@13 194 private static function isDebugCall(array $stackFrame)
Chris@13 195 {
Chris@13 196 $class = isset($stackFrame['class']) ? $stackFrame['class'] : null;
Chris@13 197 $function = isset($stackFrame['function']) ? $stackFrame['function'] : null;
Chris@13 198
Chris@13 199 return ($class === null && $function === 'Psy\debug') ||
Chris@13 200 ($class === 'Psy\Shell' && $function === 'debug');
Chris@13 201 }
Chris@13 202
Chris@13 203 /**
Chris@13 204 * Clean the given array of code.
Chris@13 205 *
Chris@13 206 * @throws ParseErrorException if the code is invalid PHP, and cannot be coerced into valid PHP
Chris@13 207 *
Chris@13 208 * @param array $codeLines
Chris@13 209 * @param bool $requireSemicolons
Chris@13 210 *
Chris@13 211 * @return string|false Cleaned PHP code, False if the input is incomplete
Chris@13 212 */
Chris@13 213 public function clean(array $codeLines, $requireSemicolons = false)
Chris@13 214 {
Chris@13 215 $stmts = $this->parse('<?php ' . implode(PHP_EOL, $codeLines) . PHP_EOL, $requireSemicolons);
Chris@13 216 if ($stmts === false) {
Chris@13 217 return false;
Chris@13 218 }
Chris@13 219
Chris@13 220 // Catch fatal errors before they happen
Chris@13 221 $stmts = $this->traverser->traverse($stmts);
Chris@13 222
Chris@13 223 // Work around https://github.com/nikic/PHP-Parser/issues/399
Chris@13 224 $oldLocale = setlocale(LC_NUMERIC, 0);
Chris@13 225 setlocale(LC_NUMERIC, 'C');
Chris@13 226
Chris@13 227 $code = $this->printer->prettyPrint($stmts);
Chris@13 228
Chris@13 229 // Now put the locale back
Chris@13 230 setlocale(LC_NUMERIC, $oldLocale);
Chris@13 231
Chris@13 232 return $code;
Chris@13 233 }
Chris@13 234
Chris@13 235 /**
Chris@13 236 * Set the current local namespace.
Chris@13 237 *
Chris@13 238 * @param null|array $namespace (default: null)
Chris@13 239 *
Chris@13 240 * @return null|array
Chris@13 241 */
Chris@13 242 public function setNamespace(array $namespace = null)
Chris@13 243 {
Chris@13 244 $this->namespace = $namespace;
Chris@13 245 }
Chris@13 246
Chris@13 247 /**
Chris@13 248 * Get the current local namespace.
Chris@13 249 *
Chris@13 250 * @return null|array
Chris@13 251 */
Chris@13 252 public function getNamespace()
Chris@13 253 {
Chris@13 254 return $this->namespace;
Chris@13 255 }
Chris@13 256
Chris@13 257 /**
Chris@13 258 * Lex and parse a block of code.
Chris@13 259 *
Chris@13 260 * @see Parser::parse
Chris@13 261 *
Chris@13 262 * @throws ParseErrorException for parse errors that can't be resolved by
Chris@13 263 * waiting a line to see what comes next
Chris@13 264 *
Chris@13 265 * @param string $code
Chris@13 266 * @param bool $requireSemicolons
Chris@13 267 *
Chris@13 268 * @return array|false A set of statements, or false if incomplete
Chris@13 269 */
Chris@13 270 protected function parse($code, $requireSemicolons = false)
Chris@13 271 {
Chris@13 272 try {
Chris@13 273 return $this->parser->parse($code);
Chris@13 274 } catch (\PhpParser\Error $e) {
Chris@13 275 if ($this->parseErrorIsUnclosedString($e, $code)) {
Chris@13 276 return false;
Chris@13 277 }
Chris@13 278
Chris@13 279 if ($this->parseErrorIsUnterminatedComment($e, $code)) {
Chris@13 280 return false;
Chris@13 281 }
Chris@13 282
Chris@13 283 if ($this->parseErrorIsTrailingComma($e, $code)) {
Chris@13 284 return false;
Chris@13 285 }
Chris@13 286
Chris@13 287 if (!$this->parseErrorIsEOF($e)) {
Chris@13 288 throw ParseErrorException::fromParseError($e);
Chris@13 289 }
Chris@13 290
Chris@13 291 if ($requireSemicolons) {
Chris@13 292 return false;
Chris@13 293 }
Chris@13 294
Chris@13 295 try {
Chris@13 296 // Unexpected EOF, try again with an implicit semicolon
Chris@13 297 return $this->parser->parse($code . ';');
Chris@13 298 } catch (\PhpParser\Error $e) {
Chris@13 299 return false;
Chris@13 300 }
Chris@13 301 }
Chris@13 302 }
Chris@13 303
Chris@13 304 private function parseErrorIsEOF(\PhpParser\Error $e)
Chris@13 305 {
Chris@13 306 $msg = $e->getRawMessage();
Chris@13 307
Chris@13 308 return ($msg === 'Unexpected token EOF') || (strpos($msg, 'Syntax error, unexpected EOF') !== false);
Chris@13 309 }
Chris@13 310
Chris@13 311 /**
Chris@13 312 * A special test for unclosed single-quoted strings.
Chris@13 313 *
Chris@13 314 * Unlike (all?) other unclosed statements, single quoted strings have
Chris@13 315 * their own special beautiful snowflake syntax error just for
Chris@13 316 * themselves.
Chris@13 317 *
Chris@13 318 * @param \PhpParser\Error $e
Chris@13 319 * @param string $code
Chris@13 320 *
Chris@13 321 * @return bool
Chris@13 322 */
Chris@13 323 private function parseErrorIsUnclosedString(\PhpParser\Error $e, $code)
Chris@13 324 {
Chris@13 325 if ($e->getRawMessage() !== 'Syntax error, unexpected T_ENCAPSED_AND_WHITESPACE') {
Chris@13 326 return false;
Chris@13 327 }
Chris@13 328
Chris@13 329 try {
Chris@13 330 $this->parser->parse($code . "';");
Chris@13 331 } catch (\Exception $e) {
Chris@13 332 return false;
Chris@13 333 }
Chris@13 334
Chris@13 335 return true;
Chris@13 336 }
Chris@13 337
Chris@13 338 private function parseErrorIsUnterminatedComment(\PhpParser\Error $e, $code)
Chris@13 339 {
Chris@13 340 return $e->getRawMessage() === 'Unterminated comment';
Chris@13 341 }
Chris@13 342
Chris@13 343 private function parseErrorIsTrailingComma(\PhpParser\Error $e, $code)
Chris@13 344 {
Chris@13 345 return ($e->getRawMessage() === 'A trailing comma is not allowed here') && (substr(rtrim($code), -1) === ',');
Chris@13 346 }
Chris@13 347 }