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