diff --git a/projects/packages/jetpack-mu-wpcom/changelog/try-plugin-conflicts-guardian b/projects/packages/jetpack-mu-wpcom/changelog/try-plugin-conflicts-guardian new file mode 100644 index 000000000000..e08bed7a43c6 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/try-plugin-conflicts-guardian @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Plugin Conflicts Guardian: new pre-flight check that blocks a plugin activation (via plugins.php or update.php) when a short-lived HTTP probe captures a fatal during load or the init cycle; gated behind the pcg_guard_activation filter. diff --git a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php index 8fa91bb100cf..7e069c2825d7 100644 --- a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php +++ b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php @@ -287,6 +287,7 @@ public static function load_features() { require_once __DIR__ . '/features/logo-tool/logo-tool.php'; require_once __DIR__ . '/features/marketplace-products-updater/class-marketplace-products-updater.php'; require_once __DIR__ . '/features/media/heif-support.php'; + require_once __DIR__ . '/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php'; require_once __DIR__ . '/features/post-categories/quick-actions.php'; require_once __DIR__ . '/features/post-like-from-email/post-like-from-email.php'; require_once __DIR__ . '/features/site-editor-dashboard-link/site-editor-dashboard-link.php'; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/README.md b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/README.md new file mode 100644 index 000000000000..5e2493547941 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/README.md @@ -0,0 +1,82 @@ +# Plugin Conflicts Guardian + +Pre-flight plugin-activation check. When an admin clicks Activate (or finishes an Upload Plugin install), this feature loads the plugin in an isolated HTTP request and refuses the activation if that probe captures a fatal — the site stays up instead of entering recovery mode. + +Ships dark: gated behind `apply_filters( 'pcg_guard_activation', false )`. Set the filter to `true` to enable both the activation and update guards. + +## Files + +| File | Role | +| --- | --- | +| `plugin-conflicts-guardian.php` | Bootstrap. Wires the requires for the other files. | +| `class-pcg-load-tester.php` | Client: fires the probe HTTP request and parses the verdict. | +| `probe-endpoint.php` | Server: handles `?pcg_probe=1`, requires the plugin, captures any fatal. | +| `activation-guard.php` | Hooks `load-plugins.php` / `load-update.php` and blocks failing activations. | +| `update-guard.php` | Hooks `upgrader_source_selection` to refuse installs/updates with PHP parse errors. | + +## Activation flow + +1. Admin submits an Activate request (`plugins.php?action=activate`, `…=activate-selected`, or `update.php?action=activate-plugin`). +2. `activation-guard.php` intercepts on `load-plugins.php` / `load-update.php` priority 0, verifies the nonce, and for each plugin calls `PCG_Load_Tester::test()`. +3. The load tester stashes the plugin path in a short-lived transient keyed by a random token, then `wp_remote_get`s `?pcg_probe=1&token=…` on this same site. +4. `probe-endpoint.php` runs synchronously at require time (already inside `plugins_loaded` priority 10 via `load_features()`), validates + consumes the token, defines `WP_SANDBOX_SCRAPING` so core's fatal handler steps aside, arms a shutdown handler, and `require`s the plugin's main file. +5. Two probes fire in parallel via `\WpOrg\Requests\Requests::request_multiple()`: one against `home_url('/')` (front-end) and one against `admin_url('index.php')` with `pcg_admin=1` and the admin's WP auth cookies forwarded so `auth_redirect()` clears. The admin probe defers its verdict to `admin_init` priority `PHP_INT_MAX`; the front-end probe emits on `wp_loaded`. A captured `fatal` / `throwable` from either probe wins; otherwise the front-end verdict is returned. A 302 on the admin probe (cookies missing/expired) becomes a distinct `ok-inconclusive` status that's still treated as a non-blocking pass — that way transport quirks don't break activation, but the signal can be measured separately from a clean `ok`. +6. If any plugin failed, the guard stashes reasons in a per-user transient and redirects to `plugins.php?pcg_blocked=1`; the admin notice reads the transient and renders it. + +``` + Admin click Activate + │ + ▼ + activation-guard.php ──► verify nonce + capability + │ + ▼ + PCG_Load_Tester::test() + │ + │ stash {path} in transient (random token) + ▼ + GET /?pcg_probe=1&token=… ◄── HTTP self-request + │ + ▼ + probe-endpoint.php + validate + consume token + define WP_SANDBOX_SCRAPING + register shutdown handler + require $plugin_main + │ + ├───► fatal / throwable ──► {status: fatal|throwable} (HTTP 200) + │ │ + │ ▼ + │ Guard stashes reason, + │ 302 → plugins.php?pcg_blocked=1 + │ + └───► clean load + │ + ▼ + wait for init / admin_init / wp_loaded + │ + ▼ + {status: ok} (HTTP 200) + │ + ▼ + Guard hands off to core activate_plugin() +``` + +## Why HTTP, not a CLI subprocess + +Atomic and some managed hosts sandbox web-PHP so `proc_open` can't find/exec a CLI binary (`open_basedir` + restricted exec). A separate HTTP request is isolated from the admin request: if the plugin fatals, the probe 500s but the parent sees JSON via the shutdown handler, and the admin page keeps rendering. + +## Limitations + +- Only catches errors hit while `require`-ing the main file and during `plugins_loaded` / `init` / `admin_init` callbacks. Errors that surface only on later hooks (e.g. `template_redirect`, REST) are invisible. +- The probe endpoint is wired up via jetpack-mu-wpcom's `load_features()` at `plugins_loaded` priority 10, so plugin callbacks registered for `plugins_loaded` at priority < 10 will have already fired before the plugin under test is `require`d. Fatals from those earlier-priority callbacks are missed. Hooking the probe handler earlier would require splitting it out of `load_features()`. +- Other active plugins are live during the probe, so cross-plugin conflicts CAN surface (a full SHORTINIT sandbox would avoid that, but isn't portable here). + +## Update flow (syntax-only) + +`update-guard.php` hooks `upgrader_source_selection` after WP extracts the install/update zip and before it copies files over the live plugin. It tokenizes every `.php` in the source with `token_get_all(…, TOKEN_PARSE)`. If any file fails to parse, it returns a `WP_Error` whose message names the first parse error and whose `$data['errors']` array carries the full list, aborting the operation without touching the live files. + +The scan has an 8-second wall-clock budget (`PCG_UPDATE_GUARD_BUDGET_SECONDS`). Big packages (WooCommerce, Yoast, etc.) can have thousands of PHP files and we'd rather not blow the cron / request timeout. On bail with no errors found we don't fail-closed — we let the install/update through and `error_log` the slug + action so we can see how often this fires and on which packages. + +Loaded unconditionally (not gated on `is_admin()`) so cron auto-updates also hit the gate. + +Why not the load probe at this stage: during an *update* the active version is already loaded in the probe request, so `require`-ing the new main file would always fatal with "Cannot redeclare class/function". Parse errors are the high-frequency release failure mode; runtime errors still trip on the next Activate click. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/activation-guard.php b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/activation-guard.php new file mode 100644 index 000000000000..528164058339 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/activation-guard.php @@ -0,0 +1,174 @@ + sanitize_text_field( (string) $b ), $bulk_raw ) + ) + ); + $nonce_action = 'bulk-plugins'; + } else { + // Single-plugin path (plugins.php Activate link / update.php post-upload link). + $plugin = sanitize_text_field( wp_unslash( $_REQUEST['plugin'] ?? '' ) ); + $plugins_to_check = '' !== $plugin ? array( $plugin ) : array(); + $nonce_action = 'activate-plugin_' . $plugin; + } + if ( empty( $plugins_to_check ) ) { + return; + } + + // Verify the nonce up front so we don't run probes for a request core + // will reject anyway. check_admin_referer() die()s on a bad nonce, so + // we don't need to check its return value. + if ( ! isset( $_REQUEST['_wpnonce'] ) ) { + return; + } + check_admin_referer( $nonce_action ); + + $blocked = pcg_guard_evaluate_plugins( $plugins_to_check ); + if ( empty( $blocked ) ) { + return; + } + + set_transient( + 'pcg_guard_notice_' . get_current_user_id(), + $blocked, + MINUTE_IN_SECONDS + ); + + wp_safe_redirect( self_admin_url( 'plugins.php?pcg_blocked=1' ) ); + exit; +} + +/** + * Probe each plugin; return map of basename => reason for those that failed. + * + * @param string[] $plugins Plugin basenames (e.g. "akismet/akismet.php"). + * @return array + */ +function pcg_guard_evaluate_plugins( $plugins ) { + $blocked = array(); + $tester = new PCG_Load_Tester(); + + foreach ( $plugins as $plugin ) { + if ( 0 !== validate_file( $plugin ) ) { + continue; + } + if ( is_plugin_active( $plugin ) ) { + continue; + } + $path = WP_PLUGIN_DIR . '/' . ltrim( $plugin, '/' ); + if ( ! is_file( $path ) ) { + continue; + } + $result = $tester->test( $path ); + $status = (string) ( $result['status'] ?? '' ); + if ( 'fatal' === $status || 'throwable' === $status ) { + $blocked[ $plugin ] = pcg_guard_format_block_reason( $result ); + } + } + + return $blocked; +} + +/** + * Build a human-readable sentence describing the captured fatal, e.g. + * "PCG fatal (in pcg-fatal-tester.php, line 6)." for the admin notice. + * + * @param array $result Probe result from PCG_Load_Tester::test(). + * @return string + */ +function pcg_guard_format_block_reason( $result ) { + $message = trim( (string) ( $result['message'] ?? '' ) ); + + $where = ''; + if ( ! empty( $result['file'] ) ) { + $file = basename( (string) $result['file'] ); + $line = (int) ( $result['line'] ?? 0 ); + $where = $line > 0 + ? sprintf( + /* translators: location fragment, e.g. "in plugin.php, line 42". 1: file name, 2: line number. */ + __( 'in %1$s, line %2$d', 'jetpack-mu-wpcom' ), + $file, + $line + ) + : sprintf( + /* translators: location fragment without a line number, e.g. "in plugin.php". %s: file name. */ + __( 'in %s', 'jetpack-mu-wpcom' ), + $file + ); + } + + if ( '' !== $message ) { + return '' !== $where ? sprintf( '%s (%s).', $message, $where ) : $message . '.'; + } + if ( '' !== $where ) { + return sprintf( + /* translators: %s: location fragment from the strings above, which already begins with "in". */ + __( 'A fatal PHP error was detected %s.', 'jetpack-mu-wpcom' ), + $where + ); + } + return __( 'A fatal PHP error was detected.', 'jetpack-mu-wpcom' ); +} + +/** + * Render the admin notice. Messages are pulled from a per-user transient + * set by the guard before the redirect. + */ +function pcg_guard_render_block_notice() { + if ( empty( $_GET['pcg_blocked'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for rendering a flash notice. + return; + } + $key = 'pcg_guard_notice_' . get_current_user_id(); + $messages = get_transient( $key ); + delete_transient( $key ); + + if ( ! is_array( $messages ) || empty( $messages ) ) { + return; + } + ?> +
+

+ +

+
+ 'error', + 'reason' => 'Plugin main file not found for load probe.', + ); + } + + $front = $this->prepare_probe( $plugin_main, home_url( '/' ), false ); + $admin = $this->prepare_probe( $plugin_main, admin_url( 'index.php' ), true ); + + try { + $responses = \WpOrg\Requests\Requests::request_multiple( + array( + 'front' => $front['request'], + 'admin' => $admin['request'], + ), + array( + 'timeout' => self::PROBE_TIMEOUT, + 'redirects' => 0, + ) + ); + } catch ( \Throwable $t ) { + return array( + 'status' => 'error', + 'reason' => sprintf( 'Probe request failed: %s', $t->getMessage() ), + ); + } finally { + delete_transient( self::transient_key( $front['token'] ) ); + delete_transient( self::transient_key( $admin['token'] ) ); + } + + $front_result = $this->parse_response( $responses['front'], $plugin_main, false ); + $admin_result = $this->parse_response( $responses['admin'], $plugin_main, true ); + + // fatal/throwable wins; an inconclusive `error` from one probe must + // not shadow a real fatal from the other. Front-end is the canonical + // "site works" signal when neither probe captured a fatal. + if ( $this->is_block( $front_result ) ) { + return $front_result; + } + if ( $this->is_block( $admin_result ) ) { + return $admin_result; + } + return $front_result; + } + + /** + * Whether a verdict is a captured fatal that should block the activation. + * + * @param array $result Probe verdict. + * @return bool + */ + protected function is_block( $result ) { + $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : ''; + return 'fatal' === $status || 'throwable' === $status; + } + + /** + * Stash a probe transient and build the request descriptor for + * `Requests::request_multiple`. + * + * @param string $plugin_main Absolute path to the plugin's main PHP file. + * @param string $base_url Base URL to probe (front-end or admin). + * @param bool $is_admin Adds `pcg_admin=1` and forwards auth cookies. + * @return array{token:string,request:array} + */ + protected function prepare_probe( $plugin_main, $base_url, $is_admin ) { + $token = wp_generate_password( 32, false ); + set_transient( self::transient_key( $token ), $plugin_main, self::TOKEN_LIFETIME ); + + $query = array( + 'pcg_probe' => '1', + 'token' => $token, + ); + $headers = array(); + if ( $is_admin ) { + $query['pcg_admin'] = '1'; + $cookie_header = $this->collect_auth_cookie_header(); + if ( '' !== $cookie_header ) { + $headers['Cookie'] = $cookie_header; + } + } + + return array( + 'token' => $token, + 'request' => array( + 'url' => add_query_arg( $query, $base_url ), + 'type' => 'GET', + 'headers' => $headers, + ), + ); + } + + /** + * Translate a `Requests::request_multiple` response into a probe verdict. + * + * @param mixed $response A `WpOrg\Requests\Response`, or an exception + * thrown for that single request. + * @param string $plugin_main Plugin main file (for fallback diagnostics). + * @param bool $is_admin True when this was the admin probe. + * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int} + */ + protected function parse_response( $response, $plugin_main, $is_admin ) { + if ( $response instanceof \Throwable ) { + return array( + 'status' => 'error', + 'reason' => sprintf( 'Probe request failed: %s', $response->getMessage() ), + ); + } + + $code = (int) ( $response->status_code ?? 0 ); + $body = (string) ( $response->body ?? '' ); + + $decoded = json_decode( $body, true ); + if ( is_array( $decoded ) && isset( $decoded['status'] ) ) { + return $decoded; + } + + // Admin probe bounced to login (no/expired cookie). Distinct status so + // we can measure how often it fires; treated as ok by callers. + if ( $is_admin && ( 301 === $code || 302 === $code ) ) { + return array( + 'status' => 'ok-inconclusive', + 'reason' => 'Admin probe redirected; treating as inconclusive ok.', + ); + } + + if ( 500 === $code ) { + return array( + 'status' => 'fatal', + 'message' => 'Probe request returned HTTP 500 without a JSON verdict; the plugin likely fatals during load.', + 'file' => basename( $plugin_main ), + 'line' => 0, + ); + } + + // Probe endpoint always emits JSON; a 2xx without one means the + // bootstrap was terminated mid-flight (exit/die during load/init/admin_init). + // Block, since the same termination would affect matching future requests. + if ( $code >= 200 && $code < 300 ) { + return array( + 'status' => 'fatal', + 'message' => sprintf( + 'Probe completed without a verdict (HTTP %d, non-JSON body). The plugin may have terminated the request during load, init, or admin_init.', + $code + ), + 'file' => basename( $plugin_main ), + 'line' => 0, + ); + } + + return array( + 'status' => 'error', + 'reason' => sprintf( 'Probe returned HTTP %d without a verdict payload.', $code ), + ); + } + + /** + * `Cookie:` header from the current request's WP auth cookies, so the + * admin loopback authenticates as the same user. Empty if none found. + * + * @return string + */ + protected function collect_auth_cookie_header() { + if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) { + return ''; + } + $pairs = array(); + foreach ( $_COOKIE as $name => $value ) { + if ( ! is_string( $name ) || ! is_string( $value ) ) { + continue; + } + if ( ! str_starts_with( $name, 'wordpress_' ) && ! str_starts_with( $name, 'wp-' ) ) { + continue; + } + $pairs[] = $name . '=' . wp_unslash( $value ); + } + return implode( '; ', $pairs ); + } + + /** + * Transient key for a probe token. Shared with the endpoint. + * + * @param string $token Random probe token. + * @return string + */ + public static function transient_key( $token ) { + return 'pcg_probe_' . md5( (string) $token ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php new file mode 100644 index 000000000000..1a42ca6b3883 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php @@ -0,0 +1,26 @@ + 'throwable', + 'class' => get_class( $t ), + 'message' => $t->getMessage(), + 'file' => basename( $t->getFile() ), + 'line' => $t->getLine(), + ) + ); + } + + // Admin probe: defer until admin_init has fired so admin-time hook fatals + // surface. Front-end probe: emit on wp_loaded once init has fired. + $is_admin_probe = '1' === sanitize_text_field( wp_unslash( $_GET['pcg_admin'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token already validated above. + add_action( $is_admin_probe ? 'admin_init' : 'wp_loaded', 'pcg_probe_emit_ok', PHP_INT_MAX ); +} + +/** + * Emit a clean "ok" verdict once the full bootstrap completed. + */ +function pcg_probe_emit_ok() { + pcg_probe_respond( array( 'status' => 'ok' ) ); +} + +/** + * Shutdown handler: on fatal, emit JSON with the captured error. + */ +function pcg_probe_shutdown() { + $error = error_get_last(); + if ( ! is_array( $error ) ) { + return; + } + $fatal_mask = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR; + if ( 0 === ( $error['type'] & $fatal_mask ) ) { + return; + } + pcg_probe_respond( + array( + 'status' => 'fatal', + 'errno' => (int) $error['type'], + 'message' => (string) $error['message'], + 'file' => basename( (string) $error['file'] ), + 'line' => (int) $error['line'], + ) + ); +} + +/** + * Emit a JSON response and terminate. + * + * @param array $payload JSON-serializable payload. + * @param int $status HTTP status code. + * @return never + */ +function pcg_probe_respond( $payload, $status = 200 ) { + while ( ob_get_level() > 0 ) { + ob_end_clean(); + } + wp_send_json( $payload, (int) $status, JSON_UNESCAPED_SLASHES ); + exit; +} + +/** + * Emit an `error` verdict with the given reason + HTTP status, and terminate. + * + * @param string $reason Human-readable reason for the failure. + * @param int $status HTTP status code. + * @return never + */ +function pcg_probe_bail_error( $reason, $status ) { + pcg_probe_respond( + array( + 'status' => 'error', + 'reason' => $reason, + ), + $status + ); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-guard.php b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-guard.php new file mode 100644 index 000000000000..c1f8ca1c9369 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-guard.php @@ -0,0 +1,122 @@ + $scan['errors'] ) + ); +} + +/** + * Tokenize every `.php` under $dir with TOKEN_PARSE and collect the failures. + * Bails out once the wall-clock budget is exceeded. + * + * @param string $dir Extracted package directory. + * @return array{errors:array,budget_exceeded:bool} + */ +function pcg_update_guard_scan_for_parse_errors( $dir ) { + $result = array( + 'errors' => array(), + 'budget_exceeded' => false, + ); + if ( '' === $dir || ! is_dir( $dir ) ) { + return $result; + } + + $started_at = microtime( true ); + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) + ); + foreach ( $iter as $path => $file ) { + if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) { + continue; + } + if ( ! is_readable( (string) $path ) ) { + continue; + } + if ( ( microtime( true ) - $started_at ) > PCG_UPDATE_GUARD_BUDGET_SECONDS ) { + $result['budget_exceeded'] = true; + return $result; + } + $code = file_get_contents( (string) $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local read inside a scan loop; WP_Filesystem is overkill here. + if ( false === $code ) { + continue; + } + try { + // @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown -- called only for the ParseError it throws under TOKEN_PARSE; tokens themselves are unused. + token_get_all( $code, TOKEN_PARSE ); + } catch ( \ParseError $e ) { + $result['errors'][] = array( + 'file' => (string) $path, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + ); + } catch ( \Throwable $e ) { + unset( $e ); + } + } + return $result; +} diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/plugin-conflicts-guardian/Plugin_Conflicts_Guardian_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/plugin-conflicts-guardian/Plugin_Conflicts_Guardian_Test.php new file mode 100644 index 000000000000..102dc55191ce --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/plugin-conflicts-guardian/Plugin_Conflicts_Guardian_Test.php @@ -0,0 +1,350 @@ +tmp_dir && is_dir( $this->tmp_dir ) ) { + $this->rrmdir( $this->tmp_dir ); + $this->tmp_dir = null; + } + // activation-guard.php registers these unconditionally on require_once + // at the top of the file; tear them down so leakage between tests + // doesn't fire wp_safe_redirect / unwanted notices. + remove_action( 'load-plugins.php', 'pcg_guard_maybe_block_activation', 0 ); + remove_action( 'load-update.php', 'pcg_guard_maybe_block_activation', 0 ); + remove_action( 'admin_notices', 'pcg_guard_render_block_notice' ); + remove_all_filters( 'pcg_guard_activation' ); + parent::tear_down(); + } + + /** + * The transient key is deterministic and namespaced with the pcg_probe_ prefix. + */ + public function test_transient_key_is_deterministic_and_namespaced() { + $token = 'abc123'; + $key = PCG_Load_Tester::transient_key( $token ); + + $this->assertSame( 'pcg_probe_' . md5( $token ), $key ); + $this->assertSame( $key, PCG_Load_Tester::transient_key( $token ) ); + } + + /** + * Different tokens produce different keys. + */ + public function test_transient_key_differs_per_token() { + $this->assertNotSame( + PCG_Load_Tester::transient_key( 'aaa' ), + PCG_Load_Tester::transient_key( 'bbb' ) + ); + } + + /** + * Scenarios for pcg_guard_format_block_reason. + * + * @return array,1:string}> + */ + public static function provide_block_reason_scenarios(): array { + return array( + 'message + file + line' => array( + array( + 'status' => 'fatal', + 'errno' => E_USER_ERROR, + 'message' => 'boom', + 'file' => '/var/www/plugins/foo/foo.php', + 'line' => 42, + ), + 'boom (in foo.php, line 42).', + ), + 'message + file (no line)' => array( + array( + 'status' => 'throwable', + 'class' => 'RuntimeException', + 'message' => 'nope', + 'file' => 'bar.php', + ), + 'nope (in bar.php).', + ), + 'message only' => array( + array( + 'message' => 'lonely message', + ), + 'lonely message.', + ), + 'no message but file + line' => array( + array( + 'status' => 'fatal', + 'errno' => E_ERROR, + 'file' => 'x.php', + 'line' => 7, + ), + 'A fatal PHP error was detected in x.php, line 7.', + ), + 'no message, no file → fallback' => array( + array( + 'status' => 'fatal', + 'errno' => E_ERROR, + 'message' => '', + ), + 'A fatal PHP error was detected.', + ), + 'line zero is omitted' => array( + array( + 'status' => 'fatal', + 'message' => 'oops', + 'file' => 'x.php', + 'line' => 0, + ), + 'oops (in x.php).', + ), + ); + } + + /** + * Pcg_guard_format_block_reason renders the probe result in a single human-readable line. + * + * @param array $result Probe result payload. + * @param string $expected Expected rendered reason. + * @dataProvider provide_block_reason_scenarios + */ + #[DataProvider( 'provide_block_reason_scenarios' )] + public function test_format_block_reason( array $result, string $expected ) { + $this->assertSame( $expected, pcg_guard_format_block_reason( $result ) ); + } + + /** + * A package with only valid PHP files scans clean. + */ + public function test_parse_error_scan_returns_empty_for_valid_files() { + $dir = $this->make_tmp_dir(); + file_put_contents( $dir . '/a.php', "assertSame( array(), $result['errors'] ); + $this->assertFalse( $result['budget_exceeded'] ); + } + + /** + * A file with a PHP parse error is reported with its path, line, and message. + */ + public function test_parse_error_scan_reports_parse_errors() { + $dir = $this->make_tmp_dir(); + file_put_contents( $dir . '/good.php', "assertCount( 1, $result['errors'] ); + $this->assertStringEndsWith( '/bad.php', $result['errors'][0]['file'] ); + $this->assertIsInt( $result['errors'][0]['line'] ); + $this->assertNotEmpty( $result['errors'][0]['message'] ); + $this->assertFalse( $result['budget_exceeded'] ); + } + + /** + * A missing or empty directory returns an empty result rather than failing. + */ + public function test_parse_error_scan_handles_missing_dir() { + $result = pcg_update_guard_scan_for_parse_errors( '' ); + $this->assertSame( array(), $result['errors'] ); + $this->assertFalse( $result['budget_exceeded'] ); + + $result = pcg_update_guard_scan_for_parse_errors( '/no/such/path/pcg-does-not-exist' ); + $this->assertSame( array(), $result['errors'] ); + $this->assertFalse( $result['budget_exceeded'] ); + } + + /** + * The filter returns the source unchanged when the guard is disabled. + */ + public function test_update_guard_check_passthrough_when_disabled() { + add_filter( 'pcg_guard_activation', '__return_false' ); + + $dir = $this->make_tmp_dir(); + file_put_contents( $dir . '/bad.php', " 'plugin', + 'action' => 'install', + ) + ); + + $this->assertSame( $dir, $result ); + } + + /** + * Non-plugin extensions (themes, core) are not inspected. + */ + public function test_update_guard_check_ignores_non_plugin_types() { + add_filter( 'pcg_guard_activation', '__return_true' ); + + $dir = $this->make_tmp_dir(); + file_put_contents( $dir . '/bad.php', " 'theme', + 'action' => 'install', + ) + ); + + $this->assertSame( $dir, $result ); + } + + /** + * Actions other than install/update are not inspected. + */ + public function test_update_guard_check_ignores_unrelated_actions() { + add_filter( 'pcg_guard_activation', '__return_true' ); + + $dir = $this->make_tmp_dir(); + file_put_contents( $dir . '/bad.php', " 'plugin', + 'action' => 'download', + ) + ); + + $this->assertSame( $dir, $result ); + } + + /** + * Clean plugin packages pass through untouched. + */ + public function test_update_guard_check_allows_clean_plugin_package() { + add_filter( 'pcg_guard_activation', '__return_true' ); + + $dir = $this->make_tmp_dir(); + file_put_contents( $dir . '/plugin.php', " 'plugin', + 'action' => 'install', + ) + ); + + $this->assertSame( $dir, $result ); + } + + /** + * Packages with parse errors are rejected with a descriptive WP_Error. + */ + public function test_update_guard_check_blocks_plugin_with_parse_error() { + add_filter( 'pcg_guard_activation', '__return_true' ); + + $dir = $this->make_tmp_dir(); + file_put_contents( $dir . '/plugin.php', " 'plugin', + 'action' => 'update', + ) + ); + + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'pcg_update_parse_error', $result->get_error_code() ); + $this->assertStringContainsString( 'update', $result->get_error_message() ); + $this->assertStringContainsString( 'plugin.php', $result->get_error_message() ); + } + + /** + * A pre-existing WP_Error from an earlier filter is returned untouched. + */ + public function test_update_guard_check_preserves_incoming_error() { + $incoming = new WP_Error( 'other_error', 'something else went wrong' ); + + $result = pcg_update_guard_check( + $incoming, + '/ignored', + null, + array( + 'type' => 'plugin', + 'action' => 'install', + ) + ); + + $this->assertSame( $incoming, $result ); + } + + /** + * Create a unique temp directory for a single test. + * + * @return string Absolute path. + */ + private function make_tmp_dir(): string { + $this->tmp_dir = rtrim( sys_get_temp_dir(), '/' ) . '/pcg-test-' . wp_generate_password( 8, false ); + mkdir( $this->tmp_dir, 0777, true ); + return $this->tmp_dir; + } + + /** + * Recursively delete a directory. Used only against paths we created in this test. + * + * @param string $dir Directory to remove. + */ + private function rrmdir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $iter as $path => $file ) { + if ( $file->isDir() ) { + rmdir( (string) $path ); + } else { + unlink( (string) $path ); + } + } + rmdir( $dir ); + } +}