PluginProbe
Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts / 2.7.9
Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts v2.7.9
2.7.9 2.7.8 2.7.7 2.7.6 2.7.5 2.7.4 trunk 1.3 2.0.4 2.0.6 2.1.91 2.2.4 2.2.7 2.2.9 2.3.1 2.3.10 2.4.10 2.4.2 2.4.4 2.4.5 2.4.6 2.4.7 2.4.8 2.4.9 2.6.0 All 30 releases
insert-php / includes / class.code-validator.php

class.code-validator.php in Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts 2.7.9, at includes/class.code-validator.php

567 lines 17.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Preflight checks for executable snippets.
4 *
5 * @package WP_Plugin_Insert_PHP
6 */
7
8 // Exit if accessed directly.
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * WINP_Code_Validator class.
15 */
16 class WINP_Code_Validator {
17
18 /**
19 * Validate executable snippet code by evaluating it once.
20 *
21 * Runs the redeclaration preflight and then evaluates the code with output
22 * discarded, reporting syntax errors, warnings and uncaught errors. The code
23 * is executed in the current request, exactly as the snippet editor does.
24 *
25 * @param string $snippet_code Unslashed snippet code.
26 * @param string $snippet_type Snippet type.
27 * @return array{valid:bool,message:string} Validation result.
28 */
29 public static function validate_code( $snippet_code, $snippet_type ) {
30 if ( empty( $snippet_code ) ) {
31 return [
32 'valid' => true,
33 'message' => '',
34 ];
35 }
36
37 $redeclaration_error = self::get_redeclaration_error( $snippet_code, $snippet_type );
38 if ( null !== $redeclaration_error ) {
39 return [
40 'valid' => false,
41 'message' => $redeclaration_error->getMessage(),
42 ];
43 }
44
45 $validation_errors = [];
46
47 // Set custom error handler to catch warnings and notices.
48 set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
49 function ( $errno, $errstr, $errfile, $errline ) use ( &$validation_errors ) {
50 // Extract line number from eval'd code if present.
51 if ( strpos( $errfile, "eval()'d code" ) !== false ) {
52 // translators: %1$d is the line number, %2$s is the error message.
53 $validation_errors[] = sprintf( __( 'Line %1$d: %2$s', 'insert-php' ), $errline, $errstr );
54 } else {
55 $validation_errors[] = $errstr;
56 }
57 return true; // Don't execute PHP internal error handler.
58 }
59 );
60
61 ob_start();
62
63 try {
64 $result = WINP_SNIPPET_TYPE_UNIVERSAL === $snippet_type
65 ? eval( '?> ' . $snippet_code . ' <?php ' )
66 : eval( $snippet_code );
67 } catch ( ParseError $e ) {
68 ob_end_clean();
69 restore_error_handler();
70
71 return [
72 'valid' => false,
73 // translators: %1$d is the line number, %2$s is the error message.
74 'message' => sprintf( __( 'Syntax error on line %1$d: %2$s', 'insert-php' ), $e->getLine(), $e->getMessage() ),
75 ];
76 } catch ( Throwable $e ) {
77 ob_end_clean();
78 restore_error_handler();
79
80 // For fatal errors in eval'd code, report the actual line number.
81 if ( strpos( $e->getFile(), "eval()'d code" ) !== false ) {
82 return [
83 'valid' => false,
84 // translators: %1$d is the line number, %2$s is the error message.
85 'message' => sprintf( __( 'Error on line %1$d: %2$s', 'insert-php' ), $e->getLine(), $e->getMessage() ),
86 ];
87 }
88
89 return [
90 'valid' => false,
91 // translators: %s is the error message.
92 'message' => sprintf( __( 'Error: %s', 'insert-php' ), $e->getMessage() ),
93 ];
94 }
95
96 // Discard any output (echo/print statements are normal for snippets).
97 ob_end_clean();
98 restore_error_handler();
99
100 if ( ! empty( $validation_errors ) ) {
101 return [
102 'valid' => false,
103 'message' => implode( '<br>', $validation_errors ),
104 ];
105 }
106
107 if ( false === $result ) {
108 return [
109 'valid' => false,
110 'message' => __( 'The code contains syntax errors. Please review and fix them before saving.', 'insert-php' ),
111 ];
112 }
113
114 return [
115 'valid' => true,
116 'message' => '',
117 ];
118 }
119
120 /**
121 * Check executable snippet code for syntax errors without running it.
122 *
123 * The tokenizer parses the code in TOKEN_PARSE mode and never executes it.
124 * Errors PHP raises only when the code runs, such as a call to an undefined
125 * function, are left to the check that runs when the snippet is activated.
126 * Without the tokenizer extension nothing is checked and the result says so.
127 *
128 * @param string $snippet_code Snippet code, without the opening tag for PHP snippets.
129 * @param string $snippet_type Snippet type.
130 * @return array{valid:bool,checked:bool,message:string} Check result.
131 */
132 public static function check_syntax( string $snippet_code, $snippet_type ): array {
133 if ( ! self::can_tokenize() ) {
134 return [
135 'valid' => true,
136 'checked' => false,
137 'message' => '',
138 ];
139 }
140
141 try {
142 token_get_all( self::get_source( $snippet_code, $snippet_type ), TOKEN_PARSE );
143 } catch ( CompileError $e ) {
144 return [
145 'valid' => false,
146 'checked' => true,
147 // translators: %1$d is the line number, %2$s is the error message.
148 'message' => sprintf( __( 'Syntax error on line %1$d: %2$s', 'insert-php' ), $e->getLine(), $e->getMessage() ),
149 ];
150 }
151
152 return [
153 'valid' => true,
154 'checked' => true,
155 'message' => '',
156 ];
157 }
158
159 /**
160 * Find a top-level function declaration that would be redeclared by eval().
161 *
162 * PHP terminates the request when eval() declares an already-loaded function,
163 * so this conflict has to be detected before evaluating the snippet.
164 *
165 * @param string $snippet_code Snippet code.
166 * @param string $snippet_type Snippet type.
167 * @return array{name:string,line:int}|null Conflict details, or null when none is found.
168 */
169 public static function find_function_redeclaration( $snippet_code, $snippet_type ) {
170 $redeclaration = self::scan_redeclarations( (string) $snippet_code, $snippet_type, false );
171 if ( null === $redeclaration ) {
172 return null;
173 }
174
175 return [
176 'name' => $redeclaration['name'],
177 'line' => $redeclaration['line'],
178 ];
179 }
180
181 /**
182 * Find a top-level function, class, interface, trait or enum that would be redeclared by eval().
183 *
184 * PHP terminates the request when eval() declares a name that is already in
185 * use, so this conflict has to be detected before evaluating the snippet.
186 *
187 * @param string $snippet_code Snippet code.
188 * @param string $snippet_type Snippet type.
189 * @return array{kind:string,name:string,line:int}|null Conflict details, or null when none is found.
190 */
191 public static function find_redeclaration( string $snippet_code, $snippet_type ): ?array {
192 return self::scan_redeclarations( $snippet_code, $snippet_type, true );
193 }
194
195 /**
196 * Describe a redeclaration found by find_redeclaration().
197 *
198 * @param array{kind:string,name:string,line:int} $redeclaration Conflict details.
199 * @return string
200 */
201 public static function get_redeclaration_message( array $redeclaration ): string {
202 switch ( $redeclaration['kind'] ) {
203 case 'class':
204 // translators: %s is the fully qualified class name.
205 return sprintf( __( 'Cannot declare class %s, because the name is already in use. Rename the class or guard its declaration with class_exists().', 'insert-php' ), $redeclaration['name'] );
206 case 'interface':
207 // translators: %s is the fully qualified interface name.
208 return sprintf( __( 'Cannot declare interface %s, because the name is already in use. Rename the interface or guard its declaration with interface_exists().', 'insert-php' ), $redeclaration['name'] );
209 case 'trait':
210 // translators: %s is the fully qualified trait name.
211 return sprintf( __( 'Cannot declare trait %s, because the name is already in use. Rename the trait or guard its declaration with trait_exists().', 'insert-php' ), $redeclaration['name'] );
212 case 'enum':
213 // translators: %s is the fully qualified enum name.
214 return sprintf( __( 'Cannot declare enum %s, because the name is already in use. Rename the enum or guard its declaration with enum_exists().', 'insert-php' ), $redeclaration['name'] );
215 default:
216 // translators: %s is the fully qualified function name.
217 return sprintf( __( 'Cannot redeclare function %s(). Rename the function or guard its declaration with function_exists().', 'insert-php' ), $redeclaration['name'] );
218 }
219 }
220
221 /**
222 * Build the error for a top-level declaration the snippet would redeclare.
223 *
224 * @param string $snippet_code Snippet code.
225 * @param string $snippet_type Snippet type.
226 * @return ErrorException|null Error carrying the snippet line, or null when none is found.
227 */
228 public static function get_redeclaration_error( string $snippet_code, $snippet_type ): ?ErrorException {
229 $redeclaration = self::find_redeclaration( $snippet_code, $snippet_type );
230 if ( null === $redeclaration ) {
231 return null;
232 }
233
234 return new ErrorException(
235 // translators: %1$d is the line number, %2$s is the error message.
236 sprintf( __( 'Line %1$d: %2$s', 'insert-php' ), $redeclaration['line'], self::get_redeclaration_message( $redeclaration ) ),
237 0,
238 E_COMPILE_ERROR,
239 __FILE__,
240 $redeclaration['line']
241 );
242 }
243
244 /**
245 * Find the first top-level declaration whose name is already in use.
246 *
247 * Top-level functions are bound when eval() compiles the code, so a conflict
248 * always ends the request. Classes, interfaces, traits and enums are declared
249 * when execution reaches them, so they are not reported after a return, exit,
250 * throw or goto outside any function or class body, which may guard them.
251 * Declarations inside braces are conditional and are skipped, even when the
252 * condition is always true. Class-like declarations inside alternative-syntax
253 * blocks, as in if ( ... ): ... endif;, are skipped too, while functions there
254 * are still reported. Nothing is reported without the tokenizer extension.
255 *
256 * @param string $snippet_code Snippet code.
257 * @param string $snippet_type Snippet type.
258 * @param bool $include_classes Whether class-like declarations are checked.
259 * @return array{kind:string,name:string,line:int}|null Conflict details, or null when none is found.
260 */
261 private static function scan_redeclarations( string $snippet_code, $snippet_type, bool $include_classes ): ?array {
262 if ( ! self::can_tokenize() ) {
263 return null;
264 }
265
266 $tokens = token_get_all( self::get_source( $snippet_code, $snippet_type ) );
267 $declaration_kinds = self::get_declaration_kinds();
268 $blocks = []; // One entry per open brace, true when it opens a function or class body.
269 $opens_body = false;
270 $alternative_depth = 0;
271 $may_stop = false;
272 $namespace = '';
273 $namespace_depth = 0;
274 $reading_namespace = false;
275 $namespace_name = '';
276 $declared = [];
277
278 foreach ( $tokens as $index => $token ) {
279 if ( ! is_array( $token ) ) {
280 if ( $reading_namespace ) {
281 $reading_namespace = false;
282
283 if ( ';' === $token ) {
284 $namespace = trim( $namespace_name, '\\' );
285 $namespace_depth = 0;
286 continue;
287 }
288
289 if ( '{' !== $token ) {
290 $namespace_name = '';
291 continue;
292 }
293
294 $blocks[] = false;
295 $namespace = trim( $namespace_name, '\\' );
296 $namespace_depth = count( $blocks );
297 continue;
298 }
299
300 if ( '{' === $token ) {
301 $blocks[] = $opens_body;
302 $opens_body = false;
303 } elseif ( '}' === $token ) {
304 array_pop( $blocks );
305 if ( $namespace_depth > count( $blocks ) ) {
306 $namespace = '';
307 $namespace_depth = 0;
308 }
309 } elseif ( ';' === $token ) {
310 // Abstract and interface methods have no body.
311 $opens_body = false;
312 }
313 continue;
314 }
315
316 $token_id = $token[0];
317
318 if ( in_array( $token_id, [ T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES ], true ) ) {
319 $blocks[] = false;
320 continue;
321 }
322
323 if ( T_NAMESPACE === $token_id ) {
324 $reading_namespace = true;
325 $namespace_name = '';
326 continue;
327 }
328
329 if ( $reading_namespace ) {
330 if ( self::is_namespace_name_token( $token_id ) ) {
331 $namespace_name .= $token[1];
332 } elseif ( ! self::is_insignificant( $token ) ) {
333 $reading_namespace = false;
334 $namespace_name = '';
335 }
336 continue;
337 }
338
339 if ( in_array( $token_id, [ T_RETURN, T_EXIT, T_THROW, T_GOTO ], true ) ) {
340 $may_stop = $may_stop || ! in_array( true, $blocks, true );
341 continue;
342 }
343
344 if ( in_array( $token_id, [ T_IF, T_WHILE, T_FOR, T_FOREACH, T_SWITCH ], true ) ) {
345 if ( self::opens_alternative_block( $tokens, $index ) ) {
346 ++$alternative_depth;
347 }
348 continue;
349 }
350
351 if ( in_array( $token_id, [ T_ENDIF, T_ENDWHILE, T_ENDFOR, T_ENDFOREACH, T_ENDSWITCH ], true ) ) {
352 $alternative_depth = max( 0, $alternative_depth - 1 );
353 continue;
354 }
355
356 if ( ! isset( $declaration_kinds[ $token_id ] ) || self::is_name_reference( $tokens, $index ) ) {
357 continue;
358 }
359
360 // Named and anonymous functions and classes open their body at the next brace.
361 $opens_body = true;
362 $kind = $declaration_kinds[ $token_id ];
363 $is_function = 'function' === $kind;
364
365 if ( count( $blocks ) !== $namespace_depth || ( ! $is_function && ( ! $include_classes || $may_stop || $alternative_depth > 0 ) ) ) {
366 continue;
367 }
368
369 $name = self::get_declared_name( $tokens, $index );
370 if ( null === $name ) {
371 continue;
372 }
373
374 $qualified_name = $namespace ? $namespace . '\\' . $name : $name;
375 $symbol_key = ( $is_function ? 'function:' : 'class:' ) . strtolower( $qualified_name );
376 $in_use = $is_function ? function_exists( $qualified_name ) : self::is_class_name_in_use( $qualified_name );
377
378 if ( isset( $declared[ $symbol_key ] ) || $in_use ) {
379 return [
380 'kind' => $kind,
381 'name' => $qualified_name,
382 'line' => (int) $token[2],
383 ];
384 }
385
386 $declared[ $symbol_key ] = true;
387 }
388
389 return null;
390 }
391
392 /**
393 * Check whether the tokenizer extension is available.
394 *
395 * @return bool
396 */
397 private static function can_tokenize(): bool {
398 return function_exists( 'token_get_all' );
399 }
400
401 /**
402 * Build the source the tokenizer reads: PHP snippets are stored without their opening tag.
403 *
404 * @param string $snippet_code Snippet code.
405 * @param string $snippet_type Snippet type.
406 * @return string
407 */
408 private static function get_source( string $snippet_code, $snippet_type ): string {
409 return WINP_SNIPPET_TYPE_UNIVERSAL === $snippet_type ? $snippet_code : '<?php ' . $snippet_code;
410 }
411
412 /**
413 * Map the declaration tokens to the kind of name they declare.
414 *
415 * @return array<int, string>
416 */
417 private static function get_declaration_kinds(): array {
418 $kinds = [
419 T_FUNCTION => 'function',
420 T_CLASS => 'class',
421 T_INTERFACE => 'interface',
422 T_TRAIT => 'trait',
423 ];
424
425 if ( defined( 'T_ENUM' ) ) {
426 $kinds[ constant( 'T_ENUM' ) ] = 'enum';
427 }
428
429 return $kinds;
430 }
431
432 /**
433 * Check whether a class, interface, trait or enum with this name is already loaded.
434 *
435 * Autoloading is skipped: eval() only fails on names that are already declared.
436 *
437 * @param string $name Fully qualified name.
438 * @return bool
439 */
440 private static function is_class_name_in_use( string $name ): bool {
441 return class_exists( $name, false ) || interface_exists( $name, false ) || trait_exists( $name, false );
442 }
443
444 /**
445 * Check whether a token is whitespace or a comment.
446 *
447 * @param array<int, int|string>|string $token Token.
448 * @return bool
449 */
450 private static function is_insignificant( $token ): bool {
451 return is_array( $token ) && in_array( $token[0], [ T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ], true );
452 }
453
454 /**
455 * Check whether a declaration keyword only refers to a name, as in Example::class or use function example;.
456 *
457 * @param array<int, array<int, int|string>|string> $tokens Token stream.
458 * @param int $index Keyword token index.
459 * @return bool
460 */
461 private static function is_name_reference( array $tokens, int $index ): bool {
462 $preceding = [ T_DOUBLE_COLON, T_OBJECT_OPERATOR, T_USE ];
463
464 if ( defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) ) {
465 $preceding[] = constant( 'T_NULLSAFE_OBJECT_OPERATOR' );
466 }
467
468 for ( $previous = $index - 1; $previous >= 0; --$previous ) {
469 if ( ! self::is_insignificant( $tokens[ $previous ] ) ) {
470 return is_array( $tokens[ $previous ] ) && in_array( $tokens[ $previous ][0], $preceding, true );
471 }
472 }
473
474 return false;
475 }
476
477 /**
478 * Check whether a control structure uses the alternative syntax, as in if ( ... ): ... endif;.
479 *
480 * @param array<int, array<int, int|string>|string> $tokens Token stream.
481 * @param int $index Control structure token index.
482 * @return bool
483 */
484 private static function opens_alternative_block( array $tokens, int $index ): bool {
485 $token_count = count( $tokens );
486 $depth = 0;
487
488 for ( $current = $index + 1; $current < $token_count; ++$current ) {
489 $token = $tokens[ $current ];
490
491 if ( self::is_insignificant( $token ) ) {
492 continue;
493 }
494
495 if ( '(' === $token ) {
496 ++$depth;
497 continue;
498 }
499
500 if ( 0 === $depth ) {
501 return false;
502 }
503
504 if ( ')' === $token && 0 === --$depth ) {
505 for ( $next = $current + 1; $next < $token_count; ++$next ) {
506 if ( ! self::is_insignificant( $tokens[ $next ] ) ) {
507 return ':' === $tokens[ $next ];
508 }
509 }
510
511 return false;
512 }
513 }
514
515 return false;
516 }
517
518 /**
519 * Check whether a token can be part of a namespace name.
520 *
521 * @param int $token_id Token identifier.
522 * @return bool
523 */
524 private static function is_namespace_name_token( $token_id ) {
525 $name_tokens = [ T_STRING, T_NS_SEPARATOR ];
526
527 foreach ( [ 'T_NAME_QUALIFIED', 'T_NAME_FULLY_QUALIFIED' ] as $constant_name ) {
528 if ( defined( $constant_name ) ) {
529 $name_tokens[] = constant( $constant_name );
530 }
531 }
532
533 return in_array( $token_id, $name_tokens, true );
534 }
535
536 /**
537 * Get the name following a declaration token, ignoring whitespace and references.
538 *
539 * @param array<int, array<int, int|string>|string> $tokens Token stream.
540 * @param int $declaration_index Declaration token index.
541 * @return string|null Null for closures, anonymous classes and other unnamed uses.
542 */
543 private static function get_declared_name( $tokens, $declaration_index ) {
544 $token_count = count( $tokens );
545
546 for ( $index = $declaration_index + 1; $index < $token_count; ++$index ) {
547 $token = $tokens[ $index ];
548
549 if ( is_array( $token ) ) {
550 if ( self::is_insignificant( $token ) || '&' === $token[1] ) {
551 continue;
552 }
553
554 return T_STRING === $token[0] ? (string) $token[1] : null;
555 }
556
557 if ( '&' === $token ) {
558 continue;
559 }
560
561 return null;
562 }
563
564 return null;
565 }
566 }
567