Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.82% covered (danger)
9.82%
16 / 163
16.67% covered (danger)
16.67%
2 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PCG_Load_Tester
9.82% covered (danger)
9.82%
16 / 163
16.67% covered (danger)
16.67%
2 / 12
2440.08
0.00% covered (danger)
0.00%
0 / 1
 test
28.21% covered (danger)
28.21%
11 / 39
0.00% covered (danger)
0.00%
0 / 1
38.98
 is_block
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 is_error
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 log_probe_error
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 relative_basenames
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 probe_error_reason
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 build_probe_payload
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prepare_probe
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 parse_response
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
156
 collect_auth_cookie_header
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
72
 build_same_host_cookie_hook
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
156
 transient_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * HTTP-based plugin-load probe.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8/**
9 * Runs a plugin's main file in a separate HTTP self-request and
10 * returns the probe verdict as an associative array.
11 */
12class PCG_Load_Tester {
13
14    const PROBE_TIMEOUT  = 15;
15    const TOKEN_LIFETIME = 30;
16
17    /** Activation guard: plugins are inactive; endpoint require_once's each. */
18    const MODE_ACTIVATION = 'activation';
19
20    /**
21     * Post-update healthcheck: plugins are already loaded by WP's bootstrap;
22     * endpoint skips require_once (would fatal with "Cannot redeclare").
23     */
24    const MODE_UPDATE = 'update';
25
26    /**
27     * Probe a batch of plugin main files in one loopback request pair.
28     *
29     * Fires front-end + admin probes in parallel; front-end auth cookies are
30     * forwarded so admin_init can fire. Fatal from either wins; otherwise
31     * front-end's verdict. On fatal/throwable, the verdict's `plugin` key
32     * names the file the endpoint was loading at the time.
33     *
34     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
35     * @param string   $mode         self::MODE_ACTIVATION or self::MODE_UPDATE.
36     * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string}
37     */
38    public function test( array $plugin_mains, $mode = self::MODE_ACTIVATION ) {
39        $plugin_mains = array_values(
40            array_filter(
41                array_map( static fn( $p ) => (string) $p, $plugin_mains ),
42                static fn( $p ) => '' !== $p && is_file( $p ) && is_readable( $p )
43            )
44        );
45        if ( empty( $plugin_mains ) ) {
46            return array(
47                'status' => 'error',
48                'reason' => 'No probable plugin main files supplied.',
49            );
50        }
51
52        $front = $this->prepare_probe( $plugin_mains, home_url( '/' ), false, $mode );
53        $admin = $this->prepare_probe( $plugin_mains, admin_url( 'index.php' ), true, $mode );
54
55        try {
56            $responses = \WpOrg\Requests\Requests::request_multiple(
57                array(
58                    'front' => $front['request'],
59                    'admin' => $admin['request'],
60                ),
61                array(
62                    'timeout'   => self::PROBE_TIMEOUT,
63                    // Match `wp_remote_get`'s default; covers http->https,
64                    // force_ssl_admin's scheme bounce, and locale redirects.
65                    // `build_same_host_cookie_hook` keeps admin auth from
66                    // leaking if the redirect points off-host.
67                    'redirects' => 5,
68                )
69            );
70        } catch ( \Throwable $t ) {
71            return array(
72                'status' => 'error',
73                'reason' => sprintf( 'Probe request failed: %s', $t->getMessage() ),
74            );
75        } finally {
76            delete_transient( self::transient_key( $front['token'] ) );
77            delete_transient( self::transient_key( $admin['token'] ) );
78        }
79
80        $front_result = $this->parse_response( $responses['front'] );
81        $admin_result = $this->parse_response( $responses['admin'] );
82
83        // Log transport-level errors (most often timeouts at PROBE_TIMEOUT)
84        // so we can see how often they fire before deciding whether to
85        // scale the timeout with batch size.
86        if ( $this->is_error( $front_result ) || $this->is_error( $admin_result ) ) {
87            $this->log_probe_error( $mode, $plugin_mains, $front_result, $admin_result );
88        }
89
90        // fatal/throwable wins; an inconclusive `error` from one probe must
91        // not shadow a real fatal from the other. Front-end is the canonical
92        // "site works" signal when neither probe captured a fatal.
93        if ( $this->is_block( $front_result ) ) {
94            return $front_result;
95        }
96        if ( $this->is_block( $admin_result ) ) {
97            return $admin_result;
98        }
99        return $front_result;
100    }
101
102    /**
103     * Whether a verdict is a captured fatal that should block the activation.
104     *
105     * @param array $result Probe verdict.
106     * @return bool
107     */
108    protected function is_block( $result ) {
109        $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : '';
110        return 'fatal' === $status || 'throwable' === $status;
111    }
112
113    /**
114     * Whether a verdict is a transport-level error (timeout, connection
115     * failure, non-JSON body). Distinct from `is_block` — errors are
116     * inconclusive and don't block activation, but are worth logging.
117     *
118     * @param array $result Probe verdict.
119     * @return bool
120     */
121    protected function is_error( $result ) {
122        $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : '';
123        return 'error' === $status;
124    }
125
126    /**
127     * Log a probe transport error to logstash whenever either probe came
128     * back as `error` (timeout at PROBE_TIMEOUT, connection failure,
129     * non-JSON body). Lets us measure timeout frequency vs. batch size
130     * before deciding whether to scale `PROBE_TIMEOUT` with N.
131     *
132     * @param string   $mode         Probe mode constant.
133     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
134     * @param array    $front_result Front-end probe verdict.
135     * @param array    $admin_result Admin probe verdict.
136     * @return void
137     */
138    protected function log_probe_error( $mode, array $plugin_mains, array $front_result, array $admin_result ) {
139        pcg_log_event(
140            'Probe transport error',
141            array(
142                'mode'    => $mode,
143                'plugins' => $this->relative_basenames( $plugin_mains ),
144                'front'   => $this->probe_error_reason( $front_result ),
145                'admin'   => $this->probe_error_reason( $admin_result ),
146            )
147        );
148    }
149
150    /**
151     * Strip `WP_PLUGIN_DIR/` from each absolute path so log entries carry
152     * the canonical plugin basename (e.g. `akismet/akismet.php`).
153     *
154     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
155     * @return string[]
156     */
157    protected function relative_basenames( array $plugin_mains ) {
158        $prefix = WP_PLUGIN_DIR . '/';
159        $out    = array();
160        foreach ( $plugin_mains as $path ) {
161            $out[] = str_starts_with( (string) $path, $prefix )
162                ? substr( (string) $path, strlen( $prefix ) )
163                : (string) $path;
164        }
165        return $out;
166    }
167
168    /**
169     * One-line reason from a probe verdict — `reason` if set, else
170     * `status`, else empty. Used for diagnostic logs.
171     *
172     * @param array $result Probe verdict.
173     * @return string
174     */
175    protected function probe_error_reason( array $result ) {
176        return (string) ( $result['reason'] ?? $result['status'] ?? '' );
177    }
178
179    /**
180     * Build the transient payload that the probe endpoint will consume.
181     *
182     * Exposed for unit tests so they can assert the stash shape without
183     * needing a live HTTP loopback. Not part of the public API.
184     *
185     * @internal
186     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
187     * @param string   $mode         Probe mode constant.
188     * @return array{plugins:string[],mode:string}
189     */
190    public static function build_probe_payload( array $plugin_mains, $mode = self::MODE_ACTIVATION ) {
191        return array(
192            'plugins' => array_values( array_map( static fn( $p ) => (string) $p, $plugin_mains ) ),
193            'mode'    => self::MODE_UPDATE === $mode ? self::MODE_UPDATE : self::MODE_ACTIVATION,
194        );
195    }
196
197    /**
198     * Stash a probe transient and build the `Requests::request_multiple`
199     * descriptor for one of the two parallel probes.
200     *
201     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
202     * @param string   $base_url     Front-end or admin base URL.
203     * @param bool     $is_admin     Adds `pcg_admin=1` and forwards auth cookies.
204     * @param string   $mode         Probe mode constant.
205     * @return array{token:string,request:array}
206     */
207    protected function prepare_probe( array $plugin_mains, $base_url, $is_admin, $mode = self::MODE_ACTIVATION ) {
208        $token = wp_generate_password( 32, false );
209        set_transient( self::transient_key( $token ), self::build_probe_payload( $plugin_mains, $mode ), self::TOKEN_LIFETIME );
210
211        $query   = array(
212            'pcg_probe' => '1',
213            'token'     => $token,
214        );
215        $headers = array();
216        $options = array();
217        if ( $is_admin ) {
218            $query['pcg_admin'] = '1';
219            $cookie_header      = $this->collect_auth_cookie_header();
220            if ( '' !== $cookie_header ) {
221                $headers['Cookie'] = $cookie_header;
222                $options['hooks']  = $this->build_same_host_cookie_hook( $base_url );
223            }
224        }
225
226        return array(
227            'token'   => $token,
228            'request' => array(
229                'url'     => add_query_arg( $query, $base_url ),
230                'type'    => 'GET',
231                'headers' => $headers,
232                'options' => $options,
233            ),
234        );
235    }
236
237    /**
238     * Translate a `Requests::request_multiple` response into a probe verdict.
239     *
240     * @param mixed $response A `WpOrg\Requests\Response`, or an exception
241     *                        thrown for that single request.
242     * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string}
243     */
244    protected function parse_response( $response ) {
245        if ( $response instanceof \Throwable ) {
246            // Bootstrap was healthy enough to issue several redirects in
247            // a row, so treat redirect-budget exhaustion as inconclusive
248            // rather than an error.
249            if ( $response instanceof \WpOrg\Requests\Exception && 'toomanyredirects' === $response->getType() ) {
250                return array(
251                    'status' => 'ok-inconclusive',
252                    'reason' => 'Probe exceeded redirect budget; treating as inconclusive ok.',
253                );
254            }
255            return array(
256                'status' => 'error',
257                'reason' => sprintf( 'Probe request failed: %s', $response->getMessage() ),
258            );
259        }
260
261        $code           = (int) ( $response->status_code ?? 0 );
262        $body           = (string) ( $response->body ?? '' );
263        $redirect_count = (int) ( $response->redirects ?? 0 );
264
265        $decoded = json_decode( $body, true );
266        if ( is_array( $decoded ) && isset( $decoded['status'] ) ) {
267            return $decoded;
268        }
269
270        // 3xx that Requests refused to follow (cross-scheme downgrade,
271        // malformed Location). Treat as ok — bootstrap completed enough
272        // to emit one.
273        if ( $code >= 300 && $code < 400 ) {
274            return array(
275                'status' => 'ok-inconclusive',
276                'reason' => sprintf( 'Probe redirected (HTTP %d); treating as inconclusive ok.', $code ),
277            );
278        }
279
280        if ( 500 === $code ) {
281            return array(
282                'status'  => 'fatal',
283                'message' => 'Probe request returned HTTP 500 without a JSON verdict; the plugin likely fatals during load.',
284            );
285        }
286
287        if ( $code >= 200 && $code < 300 ) {
288            // Followed a redirect whose destination dropped the probe
289            // query. Bootstrap rendered cleanly, so don't block.
290            if ( $redirect_count > 0 ) {
291                return array(
292                    'status' => 'ok-inconclusive',
293                    'reason' => sprintf( 'Probe followed %d redirect(s) but destination dropped the probe query; treating as inconclusive ok.', $redirect_count ),
294                );
295            }
296            // Probe endpoint always emits JSON; a 2xx without one and no
297            // redirect means the bootstrap was terminated mid-flight
298            // (exit/die during load/init/admin_init).
299            return array(
300                'status'  => 'fatal',
301                'message' => sprintf(
302                    'Probe completed without a verdict (HTTP %d, non-JSON body). A plugin in the batch may have terminated the request during load, init, or admin_init.',
303                    $code
304                ),
305            );
306        }
307
308        return array(
309            'status' => 'error',
310            'reason' => sprintf( 'Probe returned HTTP %d without a verdict payload.', $code ),
311        );
312    }
313
314    /**
315     * `Cookie:` header from the current request's WP auth cookies, so the
316     * admin loopback authenticates as the same user. Empty if none found.
317     *
318     * @return string
319     */
320    protected function collect_auth_cookie_header() {
321        if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) {
322            return '';
323        }
324        $pairs = array();
325        foreach ( $_COOKIE as $name => $value ) {
326            if ( ! is_string( $name ) || ! is_string( $value ) ) {
327                continue;
328            }
329            if ( ! str_starts_with( $name, 'wordpress_' ) && ! str_starts_with( $name, 'wp-' ) ) {
330                continue;
331            }
332            $pairs[] = $name . '=' . wp_unslash( $value );
333        }
334        return implode( '; ', $pairs );
335    }
336
337    /**
338     * Build a Hooks instance that strips the forwarded `Cookie:` header on
339     * any redirect that leaves the original origin: off-host, or an
340     * https→http scheme downgrade on the same host. Defends against
341     * leaking admin auth cookies (we forward `Cookie:` manually, so
342     * Requests won't enforce browser `Secure` semantics for us).
343     * Relative redirects inherit the original origin and pass through.
344     *
345     * @param string $original_url Initial probe URL whose origin is the trust boundary.
346     * @return \WpOrg\Requests\Hooks
347     */
348    protected function build_same_host_cookie_hook( $original_url ) {
349        $original        = wp_parse_url( $original_url );
350        $original_host   = isset( $original['host'] ) ? strtolower( (string) $original['host'] ) : '';
351        $original_scheme = isset( $original['scheme'] ) ? strtolower( (string) $original['scheme'] ) : '';
352
353        $hooks = new \WpOrg\Requests\Hooks();
354        $hooks->register(
355            'requests.before_redirect',
356            static function ( &$location, &$req_headers, &$req_data, &$options, $return_value ) use ( $original_host, $original_scheme ) {
357                unset( $req_data, $options, $return_value );
358                if ( ! is_array( $req_headers ) ) {
359                    return;
360                }
361                $next             = wp_parse_url( (string) $location );
362                $next_host        = isset( $next['host'] ) ? strtolower( (string) $next['host'] ) : $original_host;
363                $next_scheme      = isset( $next['scheme'] ) ? strtolower( (string) $next['scheme'] ) : $original_scheme;
364                $same_host        = '' !== $next_host && $next_host === $original_host;
365                $scheme_downgrade = 'https' === $original_scheme && 'https' !== $next_scheme;
366                if ( ! $same_host || $scheme_downgrade ) {
367                    foreach ( array_keys( $req_headers ) as $key ) {
368                        if ( 0 === strcasecmp( (string) $key, 'Cookie' ) ) {
369                            unset( $req_headers[ $key ] );
370                        }
371                    }
372                }
373            }
374        );
375        return $hooks;
376    }
377
378    /**
379     * Transient key for a probe token. Shared with the endpoint.
380     *
381     * @param string $token Random probe token.
382     * @return string
383     */
384    public static function transient_key( $token ) {
385        return 'pcg_probe_' . md5( (string) $token );
386    }
387}