Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 82
0.00% covered (danger)
0.00%
0 / 5
CRAP
n/a
0 / 0
pcg_maybe_handle_probe
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
380
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 / 15
0.00% covered (danger)
0.00%
0 / 1
12
pcg_probe_respond
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    // Mixed-case random tokens from `wp_generate_password`; we can't
24    // `sanitize_key` (which lowercases) and must validate with a regex.
25    $raw_token = (string) wp_unslash( $_GET['token'] ?? '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated via regex on the next line.
26    $token     = preg_match( '/^[A-Za-z0-9]+$/', $raw_token ) ? $raw_token : '';
27    if ( '' === $token ) {
28        pcg_probe_bail_error( 'Missing or malformed probe token.', 400 );
29    }
30
31    $key     = PCG_Load_Tester::transient_key( $token );
32    $payload = get_transient( $key );
33    delete_transient( $key );
34
35    if ( ! is_array( $payload ) || ! isset( $payload['plugins'] ) || ! isset( $payload['mode'] ) ) {
36        pcg_probe_bail_error( 'Invalid or expired probe token.', 403 );
37    }
38    $plugin_mains = is_array( $payload['plugins'] ) ? array_values(
39        array_filter(
40            array_map( static fn( $p ) => (string) $p, $payload['plugins'] ),
41            static fn( $p ) => '' !== $p
42        )
43    ) : array();
44    $mode         = (string) $payload['mode'];
45    if ( empty( $plugin_mains ) || ! in_array( $mode, array( PCG_Load_Tester::MODE_ACTIVATION, PCG_Load_Tester::MODE_UPDATE ), true ) ) {
46        pcg_probe_bail_error( 'Invalid or expired probe token.', 403 );
47    }
48
49    // Gate per mode: activation probes need pcg_guard_activation, update
50    // probes need pcg_guard_updates. Otherwise enabling either flow would
51    // pull in the other as an unintended dependency.
52    $is_update_mode = PCG_Load_Tester::MODE_UPDATE === $mode;
53    $gate_filter    = $is_update_mode ? 'pcg_guard_updates' : 'pcg_guard_activation';
54    $gate_default   = ! $is_update_mode;
55    if ( ! apply_filters( $gate_filter, $gate_default ) ) {
56        pcg_probe_bail_error( 'Plugin Conflicts Guardian is disabled.', 403 );
57    }
58
59    // Drop unreadable entries instead of bailing on the first one. Bailing
60    // would emit `error`, which the activation guard treats as a non-block
61    // and lets the activation through — masking a fatal in a later
62    // readable plugin. Only bail when nothing readable remains.
63    $plugin_mains = array_values(
64        array_filter(
65            $plugin_mains,
66            static fn( $p ) => is_file( $p ) && is_readable( $p )
67        )
68    );
69    if ( empty( $plugin_mains ) ) {
70        pcg_probe_bail_error( 'No probe targets are readable.', 404 );
71    }
72
73    // Tell WP's fatal handler to stand down so ours can emit JSON.
74    if ( ! defined( 'WP_SANDBOX_SCRAPING' ) ) {
75        define( 'WP_SANDBOX_SCRAPING', true );
76    }
77
78    // Swallow plugin output so the JSON response isn't corrupted.
79    ob_start();
80
81    register_shutdown_function( 'pcg_probe_shutdown' );
82
83    // Activation: load each plugin to exercise its load path. Update: skip;
84    // re-requiring an already-loaded plugin would fatal with
85    // "Cannot redeclare". The shutdown handler catches either way.
86    if ( PCG_Load_Tester::MODE_ACTIVATION === $mode ) {
87        foreach ( $plugin_mains as $plugin_main ) {
88            try {
89                require_once $plugin_main;
90            } catch ( \Throwable $t ) {
91                pcg_probe_respond(
92                    array(
93                        'status'  => 'throwable',
94                        'plugin'  => $plugin_main,
95                        'class'   => get_class( $t ),
96                        'message' => $t->getMessage(),
97                        'file'    => $t->getFile(),
98                        'line'    => $t->getLine(),
99                    )
100                );
101            }
102        }
103    }
104
105    // Admin probe: defer until admin_init has fired so admin-time hook fatals
106    // surface. Front-end probe: emit on wp_loaded once init has fired.
107    $is_admin_probe = '1' === sanitize_text_field( wp_unslash( $_GET['pcg_admin'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token already validated above.
108    add_action( $is_admin_probe ? 'admin_init' : 'wp_loaded', 'pcg_probe_emit_ok', PHP_INT_MAX );
109}
110
111/**
112 * Emit a clean "ok" verdict once the full bootstrap completed.
113 */
114function pcg_probe_emit_ok() {
115    pcg_probe_respond( array( 'status' => 'ok' ) );
116}
117
118/**
119 * Shutdown handler: on fatal, emit JSON with the captured error.
120 */
121function pcg_probe_shutdown() {
122    $error = error_get_last();
123    if ( ! is_array( $error ) ) {
124        return;
125    }
126    $fatal_mask = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR;
127    if ( 0 === ( $error['type'] & $fatal_mask ) ) {
128        return;
129    }
130    pcg_probe_respond(
131        array(
132            'status'  => 'fatal',
133            'errno'   => (int) $error['type'],
134            'message' => (string) $error['message'],
135            'file'    => (string) $error['file'],
136            'line'    => (int) $error['line'],
137        )
138    );
139}
140
141/**
142 * Emit a JSON response and terminate.
143 *
144 * @param array $payload JSON-serializable payload.
145 * @param int   $status  HTTP status code.
146 * @return never
147 */
148function pcg_probe_respond( $payload, $status = 200 ) {
149    while ( ob_get_level() > 0 ) {
150        ob_end_clean();
151    }
152    wp_send_json( $payload, (int) $status, JSON_UNESCAPED_SLASHES );
153    exit;
154}
155
156/**
157 * Emit an `error` verdict with the given reason + HTTP status, and terminate.
158 *
159 * @param string $reason Human-readable reason for the failure.
160 * @param int    $status HTTP status code.
161 * @return never
162 */
163function pcg_probe_bail_error( $reason, $status ) {
164    pcg_probe_respond(
165        array(
166            'status' => 'error',
167            'reason' => $reason,
168        ),
169        $status
170    );
171}