Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
42.50% covered (danger)
42.50%
102 / 240
27.78% covered (danger)
27.78%
5 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
PCG_Load_Tester
42.50% covered (danger)
42.50%
102 / 240
27.78% covered (danger)
27.78%
5 / 18
1458.54
0.00% covered (danger)
0.00%
0 / 1
 test
20.75% covered (danger)
20.75%
11 / 53
0.00% covered (danger)
0.00%
0 / 1
126.97
 confirm_via_normal_load
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 is_clean_confirmation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 downgrade_after_confirmation
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 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
 is_anomalous_allow
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 log_probe_anomaly
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 probe_anomaly_label
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 relative_basenames
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 build_probe_payload
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 prepare_probe
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
4.13
 parse_response
56.25% covered (warning)
56.25%
27 / 48
0.00% covered (danger)
0.00%
0 / 1
27.15
 probe_endpoint_was_reached
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 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
 classify_shutdown
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 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
16    /**
17     * Probe-token transient TTL, in seconds.
18     *
19     * Must outlast the *whole* probe, not a single hop: each followed
20     * redirect (`redirects => 5`) is a fresh request that re-reads the same
21     * transient, so the worst case is `PROBE_TIMEOUT` × several hops. The
22     * old 30s was shorter than that and a slow redirect chain (e.g. the
23     * force_ssl_admin http→https bounce on a sluggish site) could outlive
24     * the token, making the endpoint bail with "Invalid or expired probe
25     * token." `test()` deletes the transient in its `finally` block anyway,
26     * so a generous TTL never leaks.
27     */
28    const TOKEN_LIFETIME = 300;
29
30    /**
31     * Engine-fatal mask used by the probe shutdown classifier. Anything
32     * outside this mask (notice, warning, deprecation, or `error_get_last`
33     * returning null after a clean `exit`) is treated as not-a-fatal.
34     *
35     * `E_RECOVERABLE_ERROR` is included even though it's "catchable" by a
36     * custom `set_error_handler` — by the time PHP shutdown fires with
37     * `error_get_last()` still pointing at it, no handler caught it, so
38     * it terminated execution like any other fatal. The probe context
39     * doesn't install a recovery handler, so the only way this errno
40     * lands in `error_get_last()` here is the uncaught path.
41     */
42    const SHUTDOWN_FATAL_MASK = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR;
43
44    /** Activation guard: plugins are inactive; endpoint require_once's each. */
45    const MODE_ACTIVATION = 'activation';
46
47    /**
48     * Post-update healthcheck: plugins are already loaded by WP's bootstrap;
49     * endpoint skips require_once (would fatal with "Cannot redeclare").
50     */
51    const MODE_UPDATE = 'update';
52
53    /**
54     * Probe a batch of plugin main files in one loopback request pair.
55     *
56     * Fires front-end + admin probes in parallel; front-end auth cookies are
57     * forwarded so admin_init can fire. Fatal from either wins; otherwise
58     * front-end's verdict. On fatal/throwable, the verdict's `plugin` key
59     * names the file the endpoint was loading at the time.
60     *
61     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
62     * @param string   $mode         self::MODE_ACTIVATION or self::MODE_UPDATE.
63     * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string}
64     */
65    public function test( array $plugin_mains, $mode = self::MODE_ACTIVATION ) {
66        $plugin_mains = array_values(
67            array_filter(
68                array_map( static fn( $p ) => (string) $p, $plugin_mains ),
69                static fn( $p ) => '' !== $p && is_file( $p ) && is_readable( $p )
70            )
71        );
72        if ( empty( $plugin_mains ) ) {
73            return array(
74                'status' => 'error',
75                'reason' => 'No probable plugin main files supplied.',
76            );
77        }
78
79        $front = $this->prepare_probe( $plugin_mains, home_url( '/' ), false, $mode );
80        $admin = $this->prepare_probe( $plugin_mains, admin_url( 'index.php' ), true, $mode );
81
82        try {
83            $responses = \WpOrg\Requests\Requests::request_multiple(
84                array(
85                    'front' => $front['request'],
86                    'admin' => $admin['request'],
87                ),
88                array(
89                    'timeout'   => self::PROBE_TIMEOUT,
90                    // Match `wp_remote_get`'s default; covers http->https,
91                    // force_ssl_admin's scheme bounce, and locale redirects.
92                    // `build_same_host_cookie_hook` keeps admin auth from
93                    // leaking if the redirect points off-host.
94                    'redirects' => 5,
95                )
96            );
97        } catch ( \Throwable $t ) {
98            return array(
99                'status' => 'error',
100                'reason' => sprintf( 'Probe request failed: %s', $t->getMessage() ),
101            );
102        } finally {
103            delete_transient( self::transient_key( $front['token'] ) );
104            delete_transient( self::transient_key( $admin['token'] ) );
105        }
106
107        $front_result = $this->parse_response( $responses['front'] );
108        $admin_result = $this->parse_response( $responses['admin'] );
109
110        // Every surface that captured a fatal. An admin-only fatal still
111        // crashes the site, so both must be confirmed. Front-end is the
112        // canonical verdict when both block.
113        $blocking = array();
114        if ( $this->is_block( $front_result ) ) {
115            $blocking['front'] = $front_result;
116        }
117        if ( $this->is_block( $admin_result ) ) {
118            $blocking['admin'] = $admin_result;
119        }
120
121        if ( ! empty( $blocking ) ) {
122            $verdict = $this->is_block( $front_result ) ? $front_result : $admin_result;
123
124            // Confirm each blocking surface via WP's normal active-plugin
125            // bootstrap; downgrade only when EVERY one comes back an explicit
126            // clean `ok`. Anything else keeps the captured fatal (see
127            // is_clean_confirmation). Update mode never confirms — fatals must
128            // block so PCG_Rollback can fire.
129            if ( self::MODE_ACTIVATION === $mode ) {
130                $clean_confirmation = null;
131                foreach ( array_keys( $blocking ) as $surface ) {
132                    $confirmation = $this->confirm_via_normal_load( $plugin_mains, $surface );
133                    if ( ! $this->is_clean_confirmation( $confirmation ) ) {
134                        $clean_confirmation = null;
135                        break;
136                    }
137                    $clean_confirmation = $confirmation;
138                }
139                if ( null !== $clean_confirmation ) {
140                    return $this->downgrade_after_confirmation( $verdict, $clean_confirmation );
141                }
142            }
143            return $verdict;
144        }
145
146        // Neither probe blocked. Log if either verdict was a transport-level
147        // error or a synthesized ok-inconclusive (HTTP 500, marker+non-JSON,
148        // redirect-budget exhaustion) — those are the allow paths under the
149        // "only block on captured fatal" policy, and the log is how we
150        // measure how often we let an activation/update through despite a
151        // suspicious signal.
152        if ( $this->is_anomalous_allow( $front_result ) || $this->is_anomalous_allow( $admin_result ) ) {
153            $this->log_probe_anomaly( $mode, $plugin_mains, $front_result, $admin_result );
154        }
155
156        return $front_result;
157    }
158
159    /**
160     * Fire a confirmation probe for a single surface with `pcg_confirm=1`.
161     * Returns null on transport failure — the caller keeps the original
162     * verdict on uncertainty.
163     *
164     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
165     * @param string   $surface      Which surface to re-probe: `front` or `admin`.
166     * @return array|null Parsed verdict, or null on transport failure.
167     */
168    protected function confirm_via_normal_load( array $plugin_mains, $surface = 'front' ) {
169        $is_admin = 'admin' === $surface;
170        $base_url = $is_admin ? admin_url( 'index.php' ) : home_url( '/' );
171        $probe    = $this->prepare_probe( $plugin_mains, $base_url, $is_admin, self::MODE_ACTIVATION, true );
172
173        try {
174            $responses = \WpOrg\Requests\Requests::request_multiple(
175                array( $surface => $probe['request'] ),
176                array(
177                    'timeout'   => self::PROBE_TIMEOUT,
178                    'redirects' => 5,
179                )
180            );
181        } catch ( \Throwable $t ) {
182            return null;
183        } finally {
184            delete_transient( self::transient_key( $probe['token'] ) );
185        }
186
187        return $this->parse_response( $responses[ $surface ] );
188    }
189
190    /**
191     * Whether a confirmation-probe verdict is an explicit clean load. Only
192     * status=`ok` qualifies — the probe endpoint emits it from
193     * wp_loaded/admin_init once the candidate loaded via the real
194     * active_plugins path and the whole bootstrap completed without a
195     * captured fatal. This is the sole result that downgrades a captured
196     * fatal: a fatal during the active_plugins-driven load dies in
197     * wp-settings.php before the endpoint registers its shutdown handler,
198     * so it can never return as status=`fatal` — it comes back as a 500 /
199     * `ok-inconclusive` / transport error (null), none of which we trust
200     * to override a genuine captured fatal.
201     *
202     * @param array|null $confirmation Confirmation verdict, or null on transport failure.
203     * @return bool
204     */
205    protected function is_clean_confirmation( $confirmation ) {
206        return is_array( $confirmation ) && 'ok' === (string) ( $confirmation['status'] ?? '' );
207    }
208
209    /**
210     * Downgraded verdict after a clean confirmation. Preserves the
211     * original captured-fatal context for log attribution.
212     *
213     * @param array $verdict      Original captured-fatal verdict.
214     * @param array $confirmation Clean confirmation-probe verdict.
215     * @return array
216     */
217    protected function downgrade_after_confirmation( array $verdict, array $confirmation ) {
218        pcg_log_event(
219            'Probe fatal downgraded after confirmation',
220            array(
221                'plugin'  => isset( $verdict['plugin'] ) ? $this->relative_basenames( array( (string) $verdict['plugin'] ) )[0] : '',
222                'status'  => (string) ( $verdict['status'] ?? '' ),
223                'reason'  => (string) ( $verdict['message'] ?? $verdict['reason'] ?? '' ),
224                'file'    => isset( $verdict['file'] ) ? basename( (string) $verdict['file'] ) : '',
225                'confirm' => (string) ( $confirmation['status'] ?? '' ),
226            )
227        );
228        $out = array(
229            'status'  => 'ok-inconclusive',
230            'reason'  => 'Captured fatal did not reproduce when the candidate was loaded via WP\'s normal active-plugin bootstrap; downgrading to allow.',
231            'message' => (string) ( $verdict['message'] ?? '' ),
232            'file'    => (string) ( $verdict['file'] ?? '' ),
233        );
234        if ( '' !== (string) ( $verdict['plugin'] ?? '' ) ) {
235            $out['plugin'] = (string) $verdict['plugin'];
236        }
237        return $out;
238    }
239
240    /**
241     * Whether a verdict is a captured fatal that should block the activation.
242     *
243     * @param array $result Probe verdict.
244     * @return bool
245     */
246    protected function is_block( $result ) {
247        $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : '';
248        return 'fatal' === $status || 'throwable' === $status;
249    }
250
251    /**
252     * Whether a verdict is a transport-level error (timeout, connection
253     * failure, non-JSON body). Distinct from `is_block` — errors are
254     * inconclusive and don't block activation, but are worth logging.
255     *
256     * @param array $result Probe verdict.
257     * @return bool
258     */
259    protected function is_error( $result ) {
260        $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : '';
261        return 'error' === $status;
262    }
263
264    /**
265     * Whether a verdict is one we chose to allow despite a suspicious
266     * signal — either a transport `error` or a synthesized
267     * `ok-inconclusive` from `parse_response` (HTTP 500, marker+non-JSON,
268     * redirect-budget exhaustion, dropped probe query on redirect). We log
269     * these so the dashboard can show the rate at which we're letting
270     * activations through without a captured verdict — the cost of the
271     * "only block on captured fatal" policy.
272     *
273     * @param array $result Probe verdict.
274     * @return bool
275     */
276    protected function is_anomalous_allow( $result ) {
277        if ( $this->is_error( $result ) ) {
278            return true;
279        }
280        $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : '';
281        // `ok-shutdown` means the probe reached PHP shutdown without a
282        // wp_loaded/admin_init verdict — bootstrap died silently or a
283        // plugin called `exit` during init. Not a captured fatal, so we
284        // allow under the policy, but the rate is worth logging because
285        // it's the new replacement for the old "marker present, no
286        // JSON body" class.
287        return 'ok-inconclusive' === $status || 'ok-shutdown' === $status;
288    }
289
290    /**
291     * Log a probe anomaly (transport error or synthesized
292     * ok-inconclusive) to logstash whenever either probe came back as
293     * such. Lets us measure how often we silently allow despite a
294     * suspicious signal — the observability backstop for the
295     * "only block on captured fatal" policy.
296     *
297     * @param string   $mode         Probe mode constant.
298     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
299     * @param array    $front_result Front-end probe verdict.
300     * @param array    $admin_result Admin probe verdict.
301     * @return void
302     */
303    protected function log_probe_anomaly( $mode, array $plugin_mains, array $front_result, array $admin_result ) {
304        pcg_log_event(
305            'Probe anomaly allowed',
306            array(
307                'mode'    => $mode,
308                'plugins' => $this->relative_basenames( $plugin_mains ),
309                'front'   => $this->probe_anomaly_label( $front_result ),
310                'admin'   => $this->probe_anomaly_label( $admin_result ),
311            )
312        );
313    }
314
315    /**
316     * One-line label for an anomalous-allow verdict — `<status>: <reason>`.
317     * Lets a single log entry name both the class of allow (error vs
318     * ok-inconclusive) and the underlying cause (HTTP 500, redirect
319     * cycle, intercepted loopback, etc.). Reasons are author-written
320     * sentences from `parse_response` and `pcg_log_event` already caps
321     * payload size, so we don't truncate here.
322     *
323     * @param array $result Probe verdict.
324     * @return string
325     */
326    protected function probe_anomaly_label( array $result ) {
327        if ( ! $this->is_anomalous_allow( $result ) ) {
328            return '';
329        }
330        $status = (string) ( $result['status'] ?? '' );
331        $reason = (string) ( $result['reason'] ?? $result['message'] ?? '' );
332        return '' !== $reason ? $status . ': ' . $reason : $status;
333    }
334
335    /**
336     * Strip `WP_PLUGIN_DIR/` from each absolute path so log entries carry
337     * the canonical plugin basename (e.g. `akismet/akismet.php`).
338     *
339     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
340     * @return string[]
341     */
342    protected function relative_basenames( array $plugin_mains ) {
343        $prefix = WP_PLUGIN_DIR . '/';
344        $out    = array();
345        foreach ( $plugin_mains as $path ) {
346            $out[] = str_starts_with( (string) $path, $prefix )
347                ? substr( (string) $path, strlen( $prefix ) )
348                : (string) $path;
349        }
350        return $out;
351    }
352
353    /**
354     * Build the transient payload that the probe endpoint will consume.
355     *
356     * Exposed for unit tests so they can assert the stash shape without
357     * needing a live HTTP loopback. Not part of the public API.
358     *
359     * @internal
360     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
361     * @param string   $mode         Probe mode constant.
362     * @param bool     $is_confirm   Whether this payload is for a confirmation probe.
363     * @return array{plugins:string[],mode:string,confirm:bool}
364     */
365    public static function build_probe_payload( array $plugin_mains, $mode = self::MODE_ACTIVATION, $is_confirm = false ) {
366        return array(
367            'plugins' => array_values( array_map( static fn( $p ) => (string) $p, $plugin_mains ) ),
368            'mode'    => self::MODE_UPDATE === $mode ? self::MODE_UPDATE : self::MODE_ACTIVATION,
369            'confirm' => (bool) $is_confirm,
370        );
371    }
372
373    /**
374     * Stash a probe transient and build the `Requests::request_multiple`
375     * descriptor for one of the two parallel probes.
376     *
377     * @param string[] $plugin_mains Absolute paths to plugin main PHP files.
378     * @param string   $base_url     Front-end or admin base URL.
379     * @param bool     $is_admin     Adds `pcg_admin=1` and forwards auth cookies.
380     * @param string   $mode         Probe mode constant.
381     * @param bool     $is_confirm   Adds `pcg_confirm=1` so the early bootstrap injects candidates into active_plugins.
382     * @return array{token:string,request:array}
383     */
384    protected function prepare_probe( array $plugin_mains, $base_url, $is_admin, $mode = self::MODE_ACTIVATION, $is_confirm = false ) {
385        $token = wp_generate_password( 32, false );
386        set_transient( self::transient_key( $token ), self::build_probe_payload( $plugin_mains, $mode, $is_confirm ), self::TOKEN_LIFETIME );
387
388        $query   = array(
389            'pcg_probe' => '1',
390            'token'     => $token,
391        );
392        $headers = array();
393        $options = array();
394        if ( $is_confirm ) {
395            $query['pcg_confirm'] = '1';
396        }
397        if ( $is_admin ) {
398            $query['pcg_admin'] = '1';
399            $cookie_header      = $this->collect_auth_cookie_header();
400            if ( '' !== $cookie_header ) {
401                $headers['Cookie'] = $cookie_header;
402                $options['hooks']  = $this->build_same_host_cookie_hook( $base_url );
403            }
404        }
405
406        return array(
407            'token'   => $token,
408            'request' => array(
409                'url'     => add_query_arg( $query, $base_url ),
410                'type'    => 'GET',
411                'headers' => $headers,
412                'options' => $options,
413            ),
414        );
415    }
416
417    /**
418     * Translate a `Requests::request_multiple` response into a probe verdict.
419     *
420     * @param mixed $response A `WpOrg\Requests\Response`, or an exception
421     *                        thrown for that single request.
422     * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string}
423     */
424    protected function parse_response( $response ) {
425        if ( $response instanceof \Throwable ) {
426            // Bootstrap was healthy enough to issue several redirects in
427            // a row, so treat redirect-budget exhaustion as inconclusive
428            // rather than an error.
429            if ( $response instanceof \WpOrg\Requests\Exception && 'toomanyredirects' === $response->getType() ) {
430                return array(
431                    'status' => 'ok-inconclusive',
432                    'reason' => 'Probe exceeded redirect budget; treating as inconclusive ok.',
433                );
434            }
435            return array(
436                'status' => 'error',
437                'reason' => sprintf( 'Probe request failed: %s', $response->getMessage() ),
438            );
439        }
440
441        $code           = (int) ( $response->status_code ?? 0 );
442        $body           = (string) ( $response->body ?? '' );
443        $redirect_count = (int) ( $response->redirects ?? 0 );
444
445        $decoded = json_decode( $body, true );
446        if ( is_array( $decoded ) && isset( $decoded['status'] ) ) {
447            return $decoded;
448        }
449
450        // 3xx that Requests refused to follow (cross-scheme downgrade,
451        // malformed Location). Treat as ok — bootstrap completed enough
452        // to emit one.
453        if ( $code >= 300 && $code < 400 ) {
454            return array(
455                'status' => 'ok-inconclusive',
456                'reason' => sprintf( 'Probe redirected (HTTP %d); treating as inconclusive ok.', $code ),
457            );
458        }
459
460        // Policy: only block on a captured fatal (status=fatal from the
461        // shutdown handler's classify_shutdown, or status=throwable from
462        // the require-time catch). HTTP 500 and "marker present, no JSON
463        // verdict" are guesses at a fatal from outside the request — on
464        // real traffic, each is dominated by upstream LBs, edge proxies,
465        // intercepting plugins, engine death (segfault/OOM/FastCGI
466        // termination), or a mid-stream connection drop. Treat as
467        // `ok-inconclusive` (allow + log) so the user can proceed and
468        // `log_probe_anomaly` records the rate.
469        if ( 500 === $code ) {
470            return array(
471                'status' => 'ok-inconclusive',
472                'reason' => 'Probe loopback returned HTTP 500 without a JSON verdict; no captured fatal, so treating as inconclusive ok. Could be an upstream LB, edge proxy, intercepting plugin, or a real engine death we can\'t verify.',
473            );
474        }
475
476        if ( $code >= 200 && $code < 300 ) {
477            // Marker present + non-JSON 200: the probe endpoint ran but no
478            // verdict was written. With the shutdown handler always emitting
479            // a verdict, this branch is reachable mainly when the engine
480            // itself died (segfault, OOM kill, FastCGI process terminated)
481            // or a mid-stream connection drop / re-entry-guarded partial
482            // body. None is a captured fatal we can confidently attribute
483            // to a plugin, so allow + log per the policy above.
484            if ( $this->probe_endpoint_was_reached( $response ) ) {
485                return array(
486                    'status' => 'ok-inconclusive',
487                    'reason' => sprintf(
488                        'Probe endpoint ran but no JSON verdict was emitted (HTTP %d). No captured PHP fatal, so treating as inconclusive ok. Most likely engine death (segfault / OOM kill / process terminated) or a connection drop mid-response.',
489                        $code
490                    ),
491                );
492            }
493            // No marker, but a redirect was followed: the destination dropped
494            // the probe query and landed on a clean page. Bootstrap rendered
495            // fine, so don't block.
496            if ( $redirect_count > 0 ) {
497                return array(
498                    'status' => 'ok-inconclusive',
499                    'reason' => sprintf( 'Probe followed %d redirect(s) but destination dropped the probe query; treating as inconclusive ok.', $redirect_count ),
500                );
501            }
502            // No marker and no redirect: the loopback never reached our
503            // endpoint — a full-page/edge cache, a security plugin, or a
504            // maintenance page answered with a 200. We learned nothing about
505            // the plugin, so this is an inconclusive transport `error` (logged,
506            // non-blocking) — NOT a fatal. Blocking here would reject a
507            // perfectly healthy plugin.
508            return array(
509                'status' => 'error',
510                'reason' => sprintf( 'Probe loopback returned HTTP %d without reaching the PCG endpoint (cache or intercepting plugin).', $code ),
511            );
512        }
513
514        return array(
515            'status' => 'error',
516            'reason' => sprintf( 'Probe returned HTTP %d without a verdict payload.', $code ),
517        );
518    }
519
520    /**
521     * Whether the probe endpoint actually executed for this response.
522     *
523     * `probe-endpoint.php` sends `X-PCG-Probe: 1` the instant it recognises a
524     * probe request. Its absence means the loopback was answered by something
525     * else (cache layer, security plugin, maintenance page) before our code
526     * ran. Header lookup is case-insensitive via `Requests`' Headers object.
527     *
528     * @param \WpOrg\Requests\Response $response Probe response.
529     * @return bool
530     */
531    protected function probe_endpoint_was_reached( $response ) {
532        return isset( $response->headers ) && isset( $response->headers['x-pcg-probe'] );
533    }
534
535    /**
536     * `Cookie:` header from the current request's WP auth cookies, so the
537     * admin loopback authenticates as the same user. Empty if none found.
538     *
539     * @return string
540     */
541    protected function collect_auth_cookie_header() {
542        if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) {
543            return '';
544        }
545        $pairs = array();
546        foreach ( $_COOKIE as $name => $value ) {
547            if ( ! is_string( $name ) || ! is_string( $value ) ) {
548                continue;
549            }
550            if ( ! str_starts_with( $name, 'wordpress_' ) && ! str_starts_with( $name, 'wp-' ) ) {
551                continue;
552            }
553            $pairs[] = $name . '=' . wp_unslash( $value );
554        }
555        return implode( '; ', $pairs );
556    }
557
558    /**
559     * Build a Hooks instance that strips the forwarded `Cookie:` header on
560     * any redirect that leaves the original origin: off-host, or an
561     * https→http scheme downgrade on the same host. Defends against
562     * leaking admin auth cookies (we forward `Cookie:` manually, so
563     * Requests won't enforce browser `Secure` semantics for us).
564     * Relative redirects inherit the original origin and pass through.
565     *
566     * @param string $original_url Initial probe URL whose origin is the trust boundary.
567     * @return \WpOrg\Requests\Hooks
568     */
569    protected function build_same_host_cookie_hook( $original_url ) {
570        $original        = wp_parse_url( $original_url );
571        $original_host   = isset( $original['host'] ) ? strtolower( (string) $original['host'] ) : '';
572        $original_scheme = isset( $original['scheme'] ) ? strtolower( (string) $original['scheme'] ) : '';
573
574        $hooks = new \WpOrg\Requests\Hooks();
575        $hooks->register(
576            'requests.before_redirect',
577            static function ( &$location, &$req_headers, &$req_data, &$options, $return_value ) use ( $original_host, $original_scheme ) {
578                unset( $req_data, $options, $return_value );
579                if ( ! is_array( $req_headers ) ) {
580                    return;
581                }
582                $next             = wp_parse_url( (string) $location );
583                $next_host        = isset( $next['host'] ) ? strtolower( (string) $next['host'] ) : $original_host;
584                $next_scheme      = isset( $next['scheme'] ) ? strtolower( (string) $next['scheme'] ) : $original_scheme;
585                $same_host        = '' !== $next_host && $next_host === $original_host;
586                $scheme_downgrade = 'https' === $original_scheme && 'https' !== $next_scheme;
587                if ( ! $same_host || $scheme_downgrade ) {
588                    foreach ( array_keys( $req_headers ) as $key ) {
589                        if ( 0 === strcasecmp( (string) $key, 'Cookie' ) ) {
590                            unset( $req_headers[ $key ] );
591                        }
592                    }
593                }
594            }
595        );
596        return $hooks;
597    }
598
599    /**
600     * Classify a PHP shutdown into a probe verdict. Returns `fatal` only
601     * for the engine-fatal error mask; anything else becomes
602     * `ok-shutdown`, signalling that the bootstrap reached PHP shutdown
603     * without a captured fatal but didn't reach the wp_loaded/admin_init
604     * verdict point (typical of a plugin calling `exit` during init).
605     *
606     * Pure helper so the probe endpoint's shutdown handler can be
607     * exercised without firing PHP shutdown in tests.
608     *
609     * @param array|null $error Result of `error_get_last()`.
610     * @return array Probe verdict.
611     */
612    public static function classify_shutdown( $error ) {
613        if ( is_array( $error ) && 0 !== ( ( (int) ( $error['type'] ?? 0 ) ) & self::SHUTDOWN_FATAL_MASK ) ) {
614            return array(
615                'status'  => 'fatal',
616                'errno'   => (int) $error['type'],
617                'message' => (string) ( $error['message'] ?? '' ),
618                'file'    => (string) ( $error['file'] ?? '' ),
619                'line'    => (int) ( $error['line'] ?? 0 ),
620            );
621        }
622        return array(
623            'status' => 'ok-shutdown',
624            'reason' => 'Probe reached shutdown without a captured fatal; bootstrap exited before wp_loaded/admin_init (likely a plugin-initiated exit/redirect during init).',
625        );
626    }
627
628    /**
629     * Transient key for a probe token. Shared with the endpoint.
630     *
631     * @param string $token Random probe token.
632     * @return string
633     */
634    public static function transient_key( $token ) {
635        return 'pcg_probe_' . md5( (string) $token );
636    }
637}