Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.71% covered (warning)
85.71%
66 / 77
33.33% covered (danger)
33.33%
1 / 3
CRAP
n/a
0 / 0
pcg_update_guard_check
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
8.15
pcg_update_guard_log_blocked
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
pcg_update_guard_scan_for_parse_errors
80.65% covered (warning)
80.65%
25 / 31
0.00% covered (danger)
0.00%
0 / 1
11.88
1<?php
2/**
3 * Update guard — refuses plugin installs / updates when the unpacked
4 * package contains PHP parse errors.
5 *
6 * @package automattic/jetpack-mu-wpcom
7 */
8
9add_filter( 'upgrader_source_selection', 'pcg_update_guard_check', 99, 4 );
10
11/**
12 * Wall-clock budget (seconds) for scanning a package for parse errors.
13 * Big packages (looking at you, WooCommerce) can have thousands of PHP
14 * files; we'd rather bail out cleanly than blow the cron / request
15 * timeout.
16 */
17const PCG_UPDATE_GUARD_BUDGET_SECONDS = 8.0;
18
19/**
20 * Filter callback. Returns a WP_Error (aborts the install/update) when
21 * the extracted source contains any PHP parse errors.
22 *
23 * @param string|WP_Error  $source        Extracted source directory, or error from a prior filter.
24 * @param string           $remote_source Original remote source path (unused).
25 * @param WP_Upgrader|null $upgrader      Upgrader instance (unused).
26 * @param array            $hook_extra    { type, action, plugin?, theme? }.
27 * @return string|WP_Error
28 */
29function pcg_update_guard_check( $source, $remote_source, $upgrader, $hook_extra = array() ) {
30    unset( $remote_source, $upgrader );
31
32    if ( is_wp_error( $source ) ) {
33        return $source;
34    }
35    if ( ! apply_filters( 'pcg_guard_activation', true ) ) {
36        return $source;
37    }
38    $type   = $hook_extra['type'] ?? '';
39    $action = (string) ( $hook_extra['action'] ?? '' );
40    if ( 'plugin' !== $type || ! in_array( $action, array( 'install', 'update' ), true ) ) {
41        return $source;
42    }
43
44    $scan = pcg_update_guard_scan_for_parse_errors( (string) $source );
45
46    if ( empty( $scan['errors'] ) ) {
47        if ( $scan['budget_exceeded'] ) {
48            // Don't fail-closed on a slow scan; log so we can see how often
49            // this fires and which packages trip it.
50            $slug = (string) ( $hook_extra['plugin'] ?? ( $hook_extra['theme'] ?? '' ) );
51            error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
52                sprintf( 'PCG update guard: scan exceeded %.1fs budget for %s; allowing %s.', PCG_UPDATE_GUARD_BUDGET_SECONDS, $slug, $action )
53            );
54        }
55        return $source;
56    }
57
58    $first = $scan['errors'][0];
59
60    pcg_update_guard_log_blocked( $action, $hook_extra, $scan, (string) $source );
61
62    return new WP_Error(
63        'pcg_update_parse_error',
64        sprintf(
65            /* translators: 1: install or update, 2: file name, 3: line number, 4: PHP parse-error message. */
66            __( 'WordPress.com blocked the %1$s: the package contains a PHP parse error in %2$s (line %3$d): %4$s', 'jetpack-mu-wpcom' ),
67            'update' === $action ? __( 'update', 'jetpack-mu-wpcom' ) : __( 'install', 'jetpack-mu-wpcom' ),
68            basename( $first['file'] ),
69            (int) $first['line'],
70            (string) $first['message']
71        ),
72        array( 'errors' => $scan['errors'] )
73    );
74}
75
76/**
77 * Log a refused install/update to logstash. Best-effort; no-op off WordPress.com.
78 *
79 * @param string $action     `install` or `update`.
80 * @param array  $hook_extra Hook payload from `upgrader_source_selection`.
81 * @param array  $scan       Result from `pcg_update_guard_scan_for_parse_errors()`.
82 * @param string $source     Extracted package directory (fallback slug source on installs,
83 *                           since `Plugin_Upgrader::install()` doesn't populate `hook_extra['plugin']`).
84 * @return void
85 */
86function pcg_update_guard_log_blocked( $action, array $hook_extra, array $scan, $source = '' ) {
87    $first = $scan['errors'][0];
88
89    $slug = (string) ( $hook_extra['plugin'] ?? ( $hook_extra['theme'] ?? '' ) );
90    if ( '' === $slug && '' !== $source ) {
91        $slug = basename( untrailingslashit( $source ) );
92    }
93
94    pcg_log_event(
95        'Update blocked',
96        array(
97            'action'      => (string) $action,
98            'slug'        => $slug,
99            // Basename only — absolute paths leak install layout.
100            'file'        => basename( (string) $first['file'] ),
101            'line'        => (int) $first['line'],
102            'reason'      => (string) $first['message'],
103            'error_count' => count( $scan['errors'] ),
104        )
105    );
106}
107
108/**
109 * Tokenize every `.php` under $dir with TOKEN_PARSE and collect the failures.
110 * Bails out once the wall-clock budget is exceeded.
111 *
112 * @param string $dir Extracted package directory.
113 * @return array{errors:array<int,array{file:string,line:int,message:string}>,budget_exceeded:bool}
114 */
115function pcg_update_guard_scan_for_parse_errors( $dir ) {
116    $result = array(
117        'errors'          => array(),
118        'budget_exceeded' => false,
119    );
120    if ( '' === $dir || ! is_dir( $dir ) ) {
121        return $result;
122    }
123
124    $started_at = microtime( true );
125    $iter       = new RecursiveIteratorIterator(
126        new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS )
127    );
128    foreach ( $iter as $path => $file ) {
129        if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
130            continue;
131        }
132        if ( ! is_readable( (string) $path ) ) {
133            continue;
134        }
135        if ( ( microtime( true ) - $started_at ) > PCG_UPDATE_GUARD_BUDGET_SECONDS ) {
136            $result['budget_exceeded'] = true;
137            return $result;
138        }
139        $code = file_get_contents( (string) $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local read inside a scan loop; WP_Filesystem is overkill here.
140        if ( false === $code ) {
141            continue;
142        }
143        try {
144            // @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown -- called only for the ParseError it throws under TOKEN_PARSE; tokens themselves are unused.
145            token_get_all( $code, TOKEN_PARSE );
146        } catch ( \ParseError $e ) {
147            $result['errors'][] = array(
148                'file'    => (string) $path,
149                'line'    => $e->getLine(),
150                'message' => $e->getMessage(),
151            );
152        } catch ( \Throwable $e ) {
153            unset( $e );
154        }
155    }
156    return $result;
157}