Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.52% covered (danger)
6.52%
6 / 92
14.29% covered (danger)
14.29%
1 / 7
CRAP
n/a
0 / 0
pcg_maybe_handle_probe
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
462
pcg_probe_emit_ok
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
pcg_probe_shutdown
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
pcg_probe_already_emitted
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
pcg_probe_respond
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
pcg_probe_pending_key
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
pcg_probe_bail_error
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * HTTP probe endpoint for the Plugin Conflicts Guardian.
4 *
5 * Handles `?pcg_probe=1&token=…` requests fired by PCG_Load_Tester.
6 *
7 * @package automattic/jetpack-mu-wpcom
8 */
9
10// Run inline: we're already inside `plugins_loaded` (load_features() priority 10),
11// so registering a hook at priority 0 would be too late.
12pcg_maybe_handle_probe();
13
14/**
15 * Entry point. Bails when the request isn't a probe.
16 */
17function pcg_maybe_handle_probe() {
18    $probe_flag = sanitize_text_field( wp_unslash( $_GET['pcg_probe'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token is the nonce, validated below.
19    if ( '1' !== $probe_flag ) {
20        return;
21    }
22
23    // Stamp the response the instant we recognise a probe request — before
24    // any bail, redirect, or plugin load. PCG_Load_Tester::parse_response()
25    // uses this header to tell "our endpoint ran but emitted no JSON verdict"
26    // (marker-present, non-JSON body — `ok-inconclusive`, non-blocking and
27    // logged) apart from "the loopback never reached us at all" (no marker —
28    // `error`, also non-blocking and logged). Under the "only block on a
29    // captured fatal" policy, neither blocks; the marker just lets us bucket
30    // the two in the `Probe anomaly allowed` log so we can size each class.
31    if ( ! headers_sent() ) {
32        header( 'X-PCG-Probe: 1' );
33    }
34
35    // Mixed-case random tokens from `wp_generate_password`; we can't
36    // `sanitize_key` (which lowercases) and must validate with a regex.
37    $raw_token = (string) wp_unslash( $_GET['token'] ?? '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated via regex on the next line.
38    $token     = preg_match( '/^[A-Za-z0-9]+$/', $raw_token ) ? $raw_token : '';
39    if ( '' === $token ) {
40        pcg_probe_bail_error( 'Missing or malformed probe token.', 400 );
41        return;
42    }
43
44    $key     = PCG_Load_Tester::transient_key( $token );
45    $payload = get_transient( $key );
46
47    if ( ! is_array( $payload ) || ! isset( $payload['plugins'] ) || ! isset( $payload['mode'] ) ) {
48        pcg_probe_bail_error( 'Invalid or expired probe token.', 403 );
49        return;
50    }
51
52    // Defer deletion until we actually emit a verdict. If something redirects
53    // (e.g. force_ssl_admin's http->https bounce) before admin_init, Requests
54    // follows with the same token; deleting upfront would make the follow-up
55    // fail with "Invalid or expired probe token". The 30s transient TTL caps
56    // lingering entries when no terminal response runs.
57    pcg_probe_pending_key( $key );
58    $plugin_mains = is_array( $payload['plugins'] ) ? array_values(
59        array_filter(
60            array_map( static fn( $p ) => (string) $p, $payload['plugins'] ),
61            static fn( $p ) => '' !== $p
62        )
63    ) : array();
64    $mode         = (string) $payload['mode'];
65    if ( empty( $plugin_mains ) || ! in_array( $mode, array( PCG_Load_Tester::MODE_ACTIVATION, PCG_Load_Tester::MODE_UPDATE ), true ) ) {
66        pcg_probe_bail_error( 'Invalid or expired probe token.', 403 );
67        return;
68    }
69
70    // Gate per mode: activation probes need pcg_guard_activation, update
71    // probes need pcg_guard_updates. Otherwise enabling either flow would
72    // pull in the other as an unintended dependency.
73    $is_update_mode = PCG_Load_Tester::MODE_UPDATE === $mode;
74    $gate_filter    = $is_update_mode ? 'pcg_guard_updates' : 'pcg_guard_activation';
75    if ( ! apply_filters( $gate_filter, true ) ) {
76        pcg_probe_bail_error( 'Plugin Conflicts Guardian is disabled.', 403 );
77        return;
78    }
79
80    // Drop unreadable entries instead of bailing on the first one. Bailing
81    // would emit `error`, which the activation guard treats as a non-block
82    // and lets the activation through — masking a fatal in a later
83    // readable plugin. Only bail when nothing readable remains.
84    $plugin_mains = array_values(
85        array_filter(
86            $plugin_mains,
87            static fn( $p ) => is_file( $p ) && is_readable( $p )
88        )
89    );
90    if ( empty( $plugin_mains ) ) {
91        pcg_probe_bail_error( 'No probe targets are readable.', 404 );
92        return;
93    }
94
95    // Tell WP's fatal handler to stand down so ours can emit JSON.
96    if ( ! defined( 'WP_SANDBOX_SCRAPING' ) ) {
97        define( 'WP_SANDBOX_SCRAPING', true );
98    }
99
100    // Swallow plugin output so the JSON response isn't corrupted.
101    ob_start();
102
103    register_shutdown_function( 'pcg_probe_shutdown' );
104
105    // Activation: load each candidate. Update: skip (already loaded by
106    // WP's bootstrap; re-requiring would fatal). Confirmation probes
107    // also skip — the early hook injected them into active_plugins so
108    // wp-settings.php loaded them at real-activation timing.
109    $is_confirm = true === ( $payload['confirm'] ?? false );
110    if ( PCG_Load_Tester::MODE_ACTIVATION === $mode && ! $is_confirm ) {
111        foreach ( $plugin_mains as $plugin_main ) {
112            try {
113                require_once $plugin_main;
114            } catch ( \Throwable $t ) {
115                pcg_probe_respond(
116                    array(
117                        'status'  => 'throwable',
118                        'plugin'  => $plugin_main,
119                        'class'   => get_class( $t ),
120                        'message' => $t->getMessage(),
121                        'file'    => $t->getFile(),
122                        'line'    => $t->getLine(),
123                    )
124                );
125            }
126        }
127    }
128
129    // Admin probe: defer until admin_init has fired so admin-time hook fatals
130    // surface. Front-end probe: emit on wp_loaded once init has fired.
131    $is_admin_probe = '1' === sanitize_text_field( wp_unslash( $_GET['pcg_admin'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token already validated above.
132    add_action( $is_admin_probe ? 'admin_init' : 'wp_loaded', 'pcg_probe_emit_ok', PHP_INT_MAX );
133}
134
135/**
136 * Emit a clean "ok" verdict once the full bootstrap completed.
137 */
138function pcg_probe_emit_ok() {
139    pcg_probe_respond( array( 'status' => 'ok' ) );
140}
141
142/**
143 * Shutdown handler: always emits a JSON verdict.
144 *
145 * On a captured engine fatal, emits status=fatal. On any other shutdown
146 * — including a plugin that called `exit`/`die` cleanly during init —
147 * emits status=ok-shutdown so the client can distinguish "bootstrap
148 * died silently" (previously seen as "marker present, no JSON body"
149 * — historically the biggest false-positive class) from a real
150 * captured fatal.
151 *
152 * Returning silently on re-entry preserves the original verdict.
153 * `wp_send_json` calls `exit`, which fires this handler again on the
154 * shutdown phase; without the guard the second pass would over-write
155 * the response that was just sent.
156 */
157function pcg_probe_shutdown() {
158    // Check-only (no `true` arg): `pcg_probe_respond` is the single
159    // canonical marker. If we marked here, the very next call to
160    // `pcg_probe_respond` would observe the flag and bail without
161    // emitting — and the shutdown verdict would be lost. The role of
162    // this guard is to bail when respond has *already* emitted (the
163    // throwable catch in the require loop, or the post-exit shutdown
164    // re-entry), not to claim ownership preemptively.
165    if ( pcg_probe_already_emitted() ) {
166        return;
167    }
168    pcg_probe_respond( PCG_Load_Tester::classify_shutdown( error_get_last() ) );
169}
170
171/**
172 * Module-local "we've already emitted a verdict" flag, shared between
173 * `pcg_probe_respond` and `pcg_probe_shutdown`. Reading sets nothing;
174 * writing flips the flag to true permanently. This is the re-entry
175 * guard that keeps a single probe request from emitting two verdicts:
176 *
177 *   - `pcg_probe_respond` calls `wp_send_json` then `exit`. The `exit`
178 *     triggers the shutdown phase, which fires the registered shutdown
179 *     handler a second time. Without this guard, the handler would
180 *     re-emit (or attempt to) and corrupt the response.
181 *   - A `\Throwable` caught in the require loop emits via
182 *     `pcg_probe_respond`; the subsequent shutdown handler must stay
183 *     silent rather than overwrite with `ok-shutdown`.
184 *
185 * @param bool $mark_now Pass true to flip the flag to its terminal value.
186 * @return bool Whether a verdict has been emitted (or just-now claimed).
187 */
188function pcg_probe_already_emitted( $mark_now = false ) {
189    static $emitted = false;
190    if ( $emitted ) {
191        return true;
192    }
193    if ( $mark_now ) {
194        $emitted = true;
195    }
196    return false;
197}
198
199/**
200 * Emit a JSON response and terminate.
201 *
202 * Returns silently if a verdict was already emitted — the most likely
203 * caller in that state is the shutdown phase re-running after our own
204 * `exit`, and the original verdict has already been written.
205 *
206 * @param array $payload JSON-serializable payload.
207 * @param int   $status  HTTP status code.
208 * @return void
209 */
210function pcg_probe_respond( $payload, $status = 200 ) {
211    if ( pcg_probe_already_emitted( true ) ) {
212        return;
213    }
214    $key = pcg_probe_pending_key();
215    if ( '' !== $key ) {
216        delete_transient( $key );
217    }
218    while ( ob_get_level() > 0 ) {
219        ob_end_clean();
220    }
221    wp_send_json( $payload, (int) $status, JSON_UNESCAPED_SLASHES );
222    exit;
223}
224
225/**
226 * Get/set the transient key to delete when we emit a verdict.
227 *
228 * @param string|null $set Key to remember; omit to read the current value.
229 * @return string
230 */
231function pcg_probe_pending_key( $set = null ) {
232    static $key = '';
233    if ( null !== $set ) {
234        $key = (string) $set;
235    }
236    return $key;
237}
238
239/**
240 * Emit an `error` verdict with the given reason + HTTP status. Returns
241 * silently on the re-entry path (a verdict was already emitted), and
242 * normally terminates via `pcg_probe_respond` → `wp_send_json` → `exit`
243 * on first call. The signature is `@return void` rather than `never`
244 * so static analyzers don't flag the silent-return branch as an
245 * unannotated escape; callers must add an explicit `return;` after
246 * invoking this when they want to stop the surrounding flow.
247 *
248 * @param string $reason Human-readable reason for the failure.
249 * @param int    $status HTTP status code.
250 * @return void
251 */
252function pcg_probe_bail_error( $reason, $status ) {
253    pcg_probe_respond(
254        array(
255            'status' => 'error',
256            'reason' => $reason,
257        ),
258        $status
259    );
260}