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