Skip to content
Merged
20 changes: 16 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`.
| `src/Plugin/` | `Deactivator` (turns the standalone off), `Checker` (answers whether a plugin is active in either scope, and whether it is network-active — `is_plugin_active_for_network()`, which is `false` off a network so no caller needs an `is_multisite()` guard), `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Contracts\Deactivator_Interface`, `Contracts\Checker_Interface`. The only files that touch WordPress plugin functions. |
| `src/Registry/` | `Registrar` (holds registered `Sub_Plugin` objects), `Reader` (the registration buffer, drained into the registrar on the way past; the object every pass reads the registry through), `Contracts\Registrar_Interface`. |
| `src/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. |
| `src/Conflict/` | `Detector` (whether a standalone is in the way), `Resolver` (which policy branch to take), `Gatekeeper` (which requests, and which users, may have one resolved), `Redirector` (where the user lands afterwards), `Rewriter` (rewrites the activation-error screen for a registered standalone), `Contracts\Resolver_Interface`. |
| `src/Conflict/` | `Detector` (whether a standalone is in the way), `Resolver` (which policy branch to take), `Gatekeeper` (which requests, and which users, may have one resolved), `Redirector` (where the user lands afterwards), `Rewriter` (rewrites the activation- and resume-error screens for a registered standalone), `Contracts\Resolver_Interface`. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `src/Traits/` | `Guards_Hook_Prefix` (a missing prefix warns and stands down rather than throwing), `Guards_Plugin_Capability` (which capability a plugin act asks for, shared by the conflict gate and the notice queue). Cross-cutting only: a trait used by one folder lives in that folder. |
| `src/Notices/` | `Writer` (what a notice says, stored under `slug:type` — `merge`, `conflict`, `stranding`, `dependency`), `Presenter` (who may consume it, render-then-clear), `Store` (keeps it), `Renderer` (draws it, `notice-error` for `dependency` and `notice-warning` for the rest), `Contracts\Writer_Interface`. |
| `src/Contracts/`, `src/Exceptions/` | `Provider_Interface`, `Activator_Interface`, `Config_Exception`. |
Expand Down Expand Up @@ -415,9 +415,21 @@ takes an **untyped** argument: a filter receives whatever the filter before it r
the screen least able to afford a second one. The rewrite refuses unless the screen is `plugins` or
`plugins-network` — `wp-admin/network/plugins.php` is a one-line require of the other and draws the
identical error, and on a default multisite it is the only screen an absorbed standalone can be
reactivated from — the `plugin` query arg names a registered standalone and `_error_nonce` verifies.
And it sanitises with `wp_kses_post()` *before* testing for emptiness, since a message that filters
down to nothing must leave core's wording standing rather than blank the notice box.
reactivated from — `_error_nonce` verifies for a registered standalone, and one of core's two
sentences is still there to swap out. And it sanitises with `wp_kses_post()` *before* testing for
emptiness, since a message that filters down to nothing must leave core's wording standing rather
than blank the notice box.

**Two sentences, and the sandbox iframe comes out with whichever one is rewritten.** The resume screen
is not the activation screen's sequel: `plugin_sandbox_scrape()` defines `WP_SANDBOX_SCRAPING`, which
stands the fatal-error handler down, so a failed activation pauses nothing. What pauses a standalone
is a fatal on an ordinary request, and the Resume link appears in recovery mode. That redirect carries
no `plugin` arg, so the nonce is tried against every registered standalone — behind `error=resuming`,
since otherwise somebody else's failed activation fires `wp_verify_nonce_failed` once per sub-plugin.
Core's `error_scrape` iframe re-runs the fatal with `display_errors` on, so it goes with a sentence
that was replaced and stays on a notice that was not, where it is the only diagnostic left. Core has
stripped that iframe itself since 6.4 — `wp_admin_notice()` echoes through `wp_kses_post()`, which
allows no `<iframe>` — so the removal is forward-looking until core stops sanitising its own notice.

**`Boot\Scheduler` wires `wp_admin_notice_markup` as a named static callback, not a closure.** Both
admin-only hooks are `[ Absorber::class, … ]` pairs that resolve their own collaborator — the
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"wpautop",
"wpdb",
"WPMU",
"wpnonce",
"wpunit"
],
"ignoreWords": [
Expand Down
21 changes: 15 additions & 6 deletions docs/conflict-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,24 @@ name is never stood down at all —
The guard cannot help on the request that *activates* the standalone: WordPress includes the plugin
being activated **after** the bundled copy has loaded, so that re-declaration is a real fatal. Core
catches it in its activation sandbox and prints *"Plugin could not be activated because it triggered
a fatal error."*
a fatal error."*, with an iframe underneath that re-runs the activation with errors on display — so
the raw `Cannot redeclare …` prints inside the same notice box.

So the library filters `wp_admin_notice_markup` and swaps that sentence for the sub-plugin's
`conflict_notice_message`, falling back to a generic one naming the slug. That filter is what puts
the WordPress floor at 6.4.
So the library filters `wp_admin_notice_markup`, swaps that sentence for the sub-plugin's
`conflict_notice_message`, falling back to a generic one naming the slug, and takes the iframe out
with it. That filter is what puts the WordPress floor at 6.4.

Since WordPress 6.4 core sanitises its own notice on the way out, and that strips the iframe whether
or not this library does — so the removal only matters if core stops.

A standalone that fatals on an ordinary request is *paused* instead, and the **Resume** link that
appears fails identically — *"Plugin could not be resumed because it triggered a fatal error."* Both
sentences are rewritten. (The activation sandbox pauses nothing; that screen is reached separately.)

It touches nothing else: the markup comes back untouched unless the screen is `plugins`, or
`plugins-network` in the network admin; the `plugin` query arg names a registered standalone; and
`_error_nonce` verifies.
`plugins-network` in the network admin; `_error_nonce` verifies for a registered standalone, named by
the `plugin` query arg on the activation screen and by the nonce alone on the resume one; and one of
core's two sentences is still there to replace.

The replacement runs through `wp_kses_post()`, so a knowledge-base link survives, and a message that
filters down to nothing leaves core's wording standing. To keep core's wording throughout, remove
Expand Down
166 changes: 142 additions & 24 deletions src/Conflict/Rewriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
*
* The one conflict the load guard cannot prevent: core includes the plugin being activated *after*
* the bundled copy, so the re-declaration really does fatal and all this can do is reword core's
* sentence, with the sub-plugin's own `conflict_notice_message`. Not `final`: bound by class name.
* sentence, with the sub-plugin's own `conflict_notice_message`. Core's `error_scrape` iframe comes
* out with it, since it re-runs the same fatal with errors on display. Not `final`: bound by class
* name.
*
* @since 1.0.0
*/
Expand All @@ -40,8 +42,9 @@ public function __construct( Reader $registry ) {
/**
* The markup to print in place of the one WordPress was about to.
*
* Handed back untouched unless the request is a nonce-verified activation error, on a plugins
* screen, for a standalone this library has registered.
* Handed back untouched unless the request is a nonce-verified activation or resume error, on a
* plugins screen, for a standalone this library has registered — and unless one of the two
* sentences core reports a plugin fatal with is still there to swap out.
*
* @since 1.0.0
*
Expand All @@ -65,33 +68,21 @@ public function rewrite( string $markup ): string {
return $markup;
}

// phpcs:disable WordPress.Security.NonceVerification.Recommended -- verified below, before
// anything is acted on.
$basename = isset( $_GET['plugin'] ) ? wp_unslash( $_GET['plugin'] ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- this read *is* the nonce.
$nonce = isset( $_GET['_error_nonce'] )
? sanitize_text_field( wp_unslash( $_GET['_error_nonce'] ) )
: '';

// Unslashed and no further: core mints the nonce from the unslashed value verbatim, so
// sanitizing a folder name holding a '%xx' sequence would make both the nonce check and the
// registry lookup miss, silently. is_string() because '?plugin[]=x' arrives as an array.
if ( ! is_string( $basename ) || $basename === '' ) {
if ( $nonce === '' ) {
return $markup;
}

// Looked up first: there is no nonce work to do for a plugin this library does not own.
$sub_plugin = $this->find_by_standalone_basename( $basename );
$sub_plugin = $this->find_by_activation_error( $nonce ) ?? $this->find_by_resume_error( $nonce );

if ( $sub_plugin === null ) {
return $markup;
}

$nonce = isset( $_GET['_error_nonce'] )
? sanitize_text_field( wp_unslash( $_GET['_error_nonce'] ) )
: '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended

if ( ! wp_verify_nonce( $nonce, 'plugin-activation-error_' . $basename ) ) {
return $markup;
}

$message = $sub_plugin->get_conflict_notice_message(
sprintf(
'%s is bundled with this plugin and loads automatically. The standalone copy cannot'
Expand All @@ -108,10 +99,111 @@ public function rewrite( string $markup ): string {
return $markup;
}

// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- core's own string, matched on purpose.
$core_text = __( 'Plugin could not be activated because it triggered a <strong>fatal error</strong>.', 'default' );
foreach ( $this->sentences_core_reports_a_fatal_with() as $sentence ) {
if ( strpos( $markup, $sentence ) === false ) {
continue;
}

return $this->without_the_error_scrape( str_replace( $sentence, $message, $markup ) );
}

// Neither sentence is in there, so something else wrote this notice. Its iframe stays: with no
// explanation to put in front of it, core's diagnostic is the only thing saying anything.
return $markup;
}

/**
* The two sentences core reports a sandboxed plugin fatal with, exactly as it prints them.
*
* Read back through `__()` against core's own text domain rather than written out as literals: an
* English needle would match nothing on a site running WordPress in another language. Resume sits
* beside activation because a standalone that fatals on an ordinary request is paused by core --
* the activation sandbox pauses nothing, having defined WP_SANDBOX_SCRAPING -- and the Resume link
* then re-runs the same re-declaration.
*
* @since 1.0.0
*
* @return string[]
*/
private function sentences_core_reports_a_fatal_with(): array {
return [
// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- core's own string, matched on purpose.
__( 'Plugin could not be activated because it triggered a <strong>fatal error</strong>.', 'default' ),
// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- core's own string, matched on purpose.
__( 'Plugin could not be resumed because it triggered a <strong>fatal error</strong>.', 'default' ),
];
}

/**
* The registered sub-plugin an activation-error request names, if the nonce agrees.
*
* The `plugin` argument is unslashed and no further: core mints the nonce from the unslashed
* value verbatim, so sanitizing a folder name holding a '%xx' sequence would make both the nonce
* check and the registry lookup miss, silently. is_string() because '?plugin[]=x' arrives as an
* array, which wp_verify_nonce() would convert rather than refuse.
*
* @since 1.0.0
*
* @param string $nonce The `_error_nonce` this request carries.
*
* @return Sub_Plugin|null
*/
private function find_by_activation_error( string $nonce ): ?Sub_Plugin {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified below, before
// anything is acted on.
$basename = isset( $_GET['plugin'] ) ? wp_unslash( $_GET['plugin'] ) : '';

if ( ! is_string( $basename ) || $basename === '' ) {
return null;
}

// Looked up first: there is no nonce work to do for a plugin this library does not own.
$sub_plugin = $this->find_by_standalone_basename( $basename );

return str_replace( $core_text, $message, $markup );
if ( $sub_plugin === null ) {
return null;
}

return wp_verify_nonce( $nonce, 'plugin-activation-error_' . $basename ) ? $sub_plugin : null;
}

/**
* The registered sub-plugin a resume-error request is about, if the nonce agrees.
*
* Identified from the nonce alone, since core's resume redirect carries no `plugin` argument to
* read: every registered standalone is offered to it in turn and the one it was signed for
* answers. Gated on `error=resuming`, the request core dispatches that wording on, because
* otherwise somebody else's failed activation fires `wp_verify_nonce_failed` once per sub-plugin
* -- which security plugins hook to rate-limit.
*
* @since 1.0.0
*
* @param string $nonce The `_error_nonce` this request carries.
*
* @return Sub_Plugin|null
*/
private function find_by_resume_error( string $nonce ): ?Sub_Plugin {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- which screen this is, not
// an act to authorise; the nonce below is what authorises.
$error = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : '';

if ( $error !== 'resuming' ) {
return null;
}

foreach ( $this->registry->all() as $sub_plugin ) {
if ( ! $sub_plugin->has_standalone_plugin() ) {
continue;
}

$action = 'plugin-resume-error_' . $sub_plugin->get_standalone_plugin_basename();

if ( wp_verify_nonce( $nonce, $action ) ) {
return $sub_plugin;
}
}

return null;
}

/**
Expand All @@ -135,4 +227,30 @@ private function find_by_standalone_basename( string $basename ): ?Sub_Plugin {

return null;
}

/**
* The same notice with core's activation-sandbox iframe taken out of it.
*
* Core appends an `action=error_scrape` iframe to the sentence just replaced, and that request
* re-runs the sandbox with `display_errors` forced on -- so the raw `Cannot redeclare …` prints
* under the explanation, contradicting it. Matched on `error_scrape` inside the opening tag rather
* than on the element core built, which would mean reproducing `add_query_arg()`, `urlencode()`
* and `esc_url()` over a filterable URL and missing silently once one of them changed.
*
* Forward-looking as it stands: `wp_admin_notice()` has echoed through `wp_kses_post()` since 6.4,
* which allows no `<iframe>`, so core strips this one itself before it reaches a browser.
*
* @since 1.0.0
*
* @param string $markup Notice markup whose sentence has already been rewritten.
*
* @return string
*/
private function without_the_error_scrape( string $markup ): string {
$stripped = preg_replace( '#<iframe\b[^>]*\berror_scrape\b[^>]*>\s*</iframe>#i', '', $markup );

// Null means the match itself failed -- a backtrack limit on a notice far larger than core's.
// The reworded sentence is worth keeping on its own.
return is_string( $stripped ) ? $stripped : $markup;
}
}
15 changes: 10 additions & 5 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -935,10 +935,15 @@ load guard cannot prevent: the owner reinstalls the standalone and presses
Activate, WordPress includes it on top of the bundled copy, and the
re-declaration is a real fatal that core's sandbox reports as "the plugin
triggered a fatal error" — true, and useless. All the library gets to do is
reword the sentence. Driven through core's own filter dispatch rather than by
calling the rewriter, because the admin-only `add_filter()` is half of what has
to work. The notice box stays core's — its classes, its dismiss button, its
wrapper; only the sentence inside is ours.
reword the sentence — either of core's two, since the same box carries "could
not be resumed" out of recovery mode — and take core's `error_scrape` iframe
down with it, which would otherwise re-include the standalone in a sandbox and
print the re-declaration fatal under the message that just explained it away.
Driven through core's own filter dispatch rather than by calling the rewriter,
because the admin-only `add_filter()` is half of what has to work, and off a
fixture core's own `wp_get_admin_notice()` assembled, iframe included — an
invented notice would leave the removal proven by nothing. The notice box stays
core's: its id, its classes, its wrapper; only the sentence inside is ours.

```mermaid
sequenceDiagram
Expand All @@ -952,7 +957,7 @@ sequenceDiagram
WP->>WP: redirects to plugins.php with plugin and _error_nonce
WP->>Rw: wp_admin_notice_markup filter
Rw->>Rw: screen is plugins, arg names a registered standalone, nonce verifies
Rw-->>WP: core's sentence swapped for the host's, wrapper untouched
Rw-->>WP: core's sentence swapped for the host's, error_scrape iframe removed, wrapper untouched
```

#### `Scenario/HostTest.php` — the host's own wiring
Expand Down
Loading
Loading