diff --git a/AGENTS.md b/AGENTS.md index dd8ccc5..4508e44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. | | `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`. | @@ -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 `
', $this->markup ) + ); + + $this->assertStringContainsString( 'Ours.', $filtered ); + $this->assertStringContainsString( $foreign, $filtered ); + $this->assertStringNotContainsString( 'error_scrape', $filtered ); + } + + /** + * Core's second wording for the same conflict. Its fatal-error handler pauses the plugin the + * activation sandbox died in, so the standalone reappears on the plugins list with a Resume + * link; pressing it re-runs the same sandbox into the same re-declaration, and core reports + * "could not be resumed" instead of "could not be activated". Matching only the activation + * wording leaves the owner reading core's useless sentence on the second screen after having + * been given a real explanation on the first. + */ + public function test_it_replaces_the_resume_wording(): void { + $this->arrange_resume_error(); + + $markup = $this->resume_error_notice(); + $rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) ); + + $filtered = $rewriter->rewrite( $markup ); + + $this->assertStringContainsString( 'Ours.', $filtered ); + $this->assertStringNotContainsString( self::RESUME_TEXT, $filtered ); + $this->assertStringStartsWith( '', $filtered );
+ }
+
+ /**
+ * The resume redirect carries no `plugin` argument at all — `resume_plugin()` appends only an
+ * `_error_nonce`, minted from `plugin-resume-error_` and the basename — so the nonce is the one
+ * thing on the request naming a plugin, and a nonce signed for somebody else's names somebody
+ * else's.
+ */
+ public function test_it_leaves_the_resume_markup_alone_for_a_plugin_no_sub_plugin_claims(): void {
+ $this->arrange_resume_error();
+
+ $markup = $this->resume_error_notice();
+
+ $this->assertStringContainsString(
+ 'Ours.',
+ $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) )
+ ->rewrite( $markup )
+ );
+
+ $_GET['_error_nonce'] = wp_create_nonce( 'plugin-resume-error_akismet/akismet.php' );
+
+ $rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) );
+
+ $this->assertSame( $markup, $rewriter->rewrite( $markup ) );
+ }
+
+ /**
+ * The canary for the two constants above. Both are transcriptions of sentences that live in
+ * somebody else's source file, and the rewrite is a `str_replace()` against them — so the day
+ * core rewords either one, the replacement silently stops happening and every other test in this
+ * class goes on passing, because they all match our transcription against our transcription.
+ *
+ * Read out of `wp-admin/plugins.php` rather than through `__()`, which would hand back whatever
+ * string was passed in and assert nothing at all. The failure this catches is benign on a site —
+ * no match means core's own wording stands — but it is invisible without this, and the whole
+ * class exists to replace that wording.
+ */
+ public function test_core_still_words_the_fatal_the_way_this_class_expects(): void {
+ $plugins_screen = ABSPATH . 'wp-admin/plugins.php';
+
+ $this->assertFileExists( $plugins_screen );
+
+ $source = (string) file_get_contents( $plugins_screen );
+
+ $this->assertStringContainsString(
+ self::ACTIVATION_TEXT,
+ $source,
+ 'WordPress reworded the activation fatal. The rewrite no longer matches, and it fails silently.'
+ );
+ $this->assertStringContainsString(
+ self::RESUME_TEXT,
+ $source,
+ 'WordPress reworded the resume fatal. The rewrite no longer matches, and it fails silently.'
+ );
+ }
+
+ /**
+ * The resume lookup is the only one that has to try every registered standalone in turn, because
+ * the resume redirect carries no `plugin` argument to narrow it with — and every miss fires
+ * core's `wp_verify_nonce_failed`, which security plugins hook to count and rate-limit failed
+ * nonces. So it may only run on the request core dispatches the resume wording on. Here the
+ * request is an ordinary activation error for somebody else's plugin, which is the shape a site
+ * produces whenever any plugin fatals on activation: the rewrite declines, and it declines
+ * without asking a single question about our standalone's nonce.
+ */
+ public function test_it_does_not_hunt_for_a_resume_nonce_outside_a_resume_error(): void {
+ $asked = [];
+
+ add_action(
+ 'wp_verify_nonce_failed',
+ static function ( $nonce, $action ) use ( &$asked ) {
+ $asked[] = $action;
+ },
+ 10,
+ 2
+ );
+
+ // Somebody else's plugin fataled on activation: error=true, their basename, their nonce.
+ $_GET['error'] = 'true';
+ $_GET['plugin'] = 'akismet/akismet.php';
+ $_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_akismet/akismet.php' );
+
+ $markup = $this->activation_error_notice();
+ $rewriter = $this->make_rewriter( $this->standalone_owner() );
+
+ $this->assertSame( $markup, $rewriter->rewrite( $markup ) );
+ $this->assertSame(
+ [],
+ $asked,
+ 'A plugins screen about somebody else\'s plugin must not spend a nonce check per'
+ . ' registered standalone, one failed-nonce action each.'
+ );
+
+ // The recorder works: the same rewrite on a real resume request does ask, so the assertion
+ // above is about the gate rather than about a listener that never attached.
+ $this->arrange_resume_error();
+ $_GET['_error_nonce'] = wp_create_nonce( 'plugin-resume-error_akismet/akismet.php' );
+
+ $this->make_rewriter( $this->standalone_owner() )->rewrite( $this->resume_error_notice() );
+
+ $this->assertSame( [ 'plugin-resume-error_' . self::STANDALONE ], $asked );
}
/**
@@ -124,7 +307,7 @@ public function test_it_uses_the_sub_plugin_whose_standalone_the_request_names()
$_GET['plugin'] = 'give-fee-recovery/give-fee-recovery.php';
$_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_give-fee-recovery/give-fee-recovery.php' );
- $filtered = $rewriter->rewrite( self::MARKUP );
+ $filtered = $rewriter->rewrite( $this->activation_error_notice() );
$this->assertStringContainsString( 'The right one.', $filtered );
$this->assertStringNotContainsString( 'The wrong one.', $filtered );
@@ -154,7 +337,7 @@ public function test_it_rewrites_for_a_standalone_whose_basename_sanitizing_woul
$_GET['plugin'] = $standalone;
$_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_' . $standalone );
- $this->assertStringContainsString( 'Ours.', $rewriter->rewrite( self::MARKUP ) );
+ $this->assertStringContainsString( 'Ours.', $rewriter->rewrite( $this->activation_error_notice() ) );
}
/**
@@ -182,7 +365,7 @@ public function test_it_leaves_the_markup_alone_outside_the_admin(): void {
set_current_screen( 'front' );
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $this->assertSame( $this->markup, $rewriter->rewrite( $this->markup ) );
}
/**
@@ -197,7 +380,7 @@ public function test_it_leaves_the_markup_alone_off_the_plugins_screen(): void {
set_current_screen( 'dashboard' );
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $this->assertSame( $this->markup, $rewriter->rewrite( $this->markup ) );
}
/**
@@ -211,13 +394,14 @@ public function test_it_rewrites_the_markup_on_the_network_plugins_screen(): voi
set_current_screen( 'plugins-network' );
- $this->assertStringContainsString( 'Ours.', $rewriter->rewrite( self::MARKUP ) );
+ $this->assertStringContainsString( 'Ours.', $rewriter->rewrite( $this->markup ) );
}
/**
* The nonce is valid here, so the ownership lookup is the only thing that can stop the rewrite:
* an activation error for a plugin this library knows nothing about keeps core's wording, which
- * for that plugin is the accurate one.
+ * for that plugin is the accurate one — and keeps the sandbox iframe, which for that plugin is
+ * the only diagnostic anyone has.
*/
public function test_it_leaves_the_markup_alone_for_a_plugin_no_sub_plugin_claims(): void {
$this->assert_the_arrangement_rewrites();
@@ -227,7 +411,12 @@ public function test_it_leaves_the_markup_alone_for_a_plugin_no_sub_plugin_claim
$_GET['plugin'] = 'akismet/akismet.php';
$_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_akismet/akismet.php' );
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $markup = $this->activation_error_notice();
+
+ $filtered = $rewriter->rewrite( $markup );
+
+ $this->assertSame( $markup, $filtered );
+ $this->assertStringContainsString( 'error_scrape', $filtered );
}
/**
@@ -242,7 +431,7 @@ public function test_it_leaves_the_markup_alone( callable $arrange ): void {
$arrange();
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $this->assertSame( $this->markup, $rewriter->rewrite( $this->markup ) );
}
/**
@@ -299,11 +488,31 @@ static function (): void {
];
}
+ /**
+ * A notice carrying neither of core's two sentences was authored by something else, and this
+ * library has nothing to say about it. The iframe stays with it: a notice nobody explained is
+ * bad, and a notice nobody explained with its one diagnostic quietly deleted is worse.
+ */
+ public function test_it_leaves_a_notice_holding_neither_sentence_alone(): void {
+ $this->assert_the_arrangement_rewrites();
+
+ $rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) );
+
+ $markup = str_replace( self::ACTIVATION_TEXT, 'Something else went wrong.', $this->markup );
+
+ $filtered = $rewriter->rewrite( $markup );
+
+ $this->assertSame( $markup, $filtered );
+ $this->assertStringContainsString( 'error_scrape', $filtered );
+ }
+
/**
* The message is sanitised before it is checked for emptiness, and this is the case that pins
* the order: `wp_kses_post( '' )` is the empty string, so swapping core's
* wording for it would leave an empty notice box where the explanation should be. Leaving
- * core's sentence in place is the better of the two bad outcomes.
+ * core's sentence in place is the better of the two bad outcomes — and the iframe stays under
+ * it, because a screen still showing core's wording is a screen this library did not improve,
+ * and its raw fatal is the only thing left explaining anything.
*/
public function test_a_message_that_sanitises_away_leaves_the_markup_alone(): void {
$this->assert_the_arrangement_rewrites();
@@ -312,7 +521,10 @@ public function test_a_message_that_sanitises_away_leaves_the_markup_alone(): vo
$this->standalone_owner( [ 'conflict_notice_message' => static fn() => '' ] )
);
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $filtered = $rewriter->rewrite( $this->markup );
+
+ $this->assertSame( $this->markup, $filtered );
+ $this->assertStringContainsString( 'error_scrape', $filtered );
}
/**
@@ -323,7 +535,7 @@ public function test_a_whitespace_only_message_leaves_the_markup_alone(): void {
$rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => " \n\t" ] ) );
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $this->assertSame( $this->markup, $rewriter->rewrite( $this->markup ) );
}
/**
@@ -342,7 +554,7 @@ public function test_it_strips_unsafe_markup_from_the_replacement_but_keeps_a_li
)
);
- $filtered = $rewriter->rewrite( self::MARKUP );
+ $filtered = $rewriter->rewrite( $this->markup );
$this->assertStringContainsString( 'the docs', $filtered );
$this->assertStringNotContainsString( 'onclick', $filtered );
@@ -366,7 +578,78 @@ private function assert_the_arrangement_rewrites(): void {
$this->assertStringContainsString(
'Ours.',
$this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) )
- ->rewrite( self::MARKUP )
+ ->rewrite( $this->markup )
+ );
+ }
+
+ /**
+ * Turn the request in setUp() into the one core redirects to when recovery mode cannot resume a
+ * plugin: `resume_plugin()` appends an `_error_nonce` for `plugin-resume-error_ ' . self::CORE_TEXT . ' ', $filtered );
+ // Core's own diagnostic goes with the sentence it explained. Left behind, the iframe
+ // re-includes the standalone in a sandbox and prints the re-declaration fatal underneath
+ // the friendly message that just said there was nothing wrong.
+ $this->assertStringNotContainsString( 'error_scrape', $filtered );
+
+ // The notice box stays core's to draw — its id, its classes, its wrapper. Only the sentence
+ // inside belongs to this library.
+ $this->assertStringStartsWith( ' ', $filtered );
+ }
+
+ /**
+ * The activation-error notice as `wp-admin/plugins.php` assembles it: core's sentence, the
+ * `error_scrape` iframe it appends whenever the request carries a verifying `_error_nonce`, and
+ * core's own notice box around both.
+ *
+ * @return string
+ */
+ private function activation_error_notice(): string {
+ $plugin = isset( $_GET['plugin'] ) && is_string( $_GET['plugin'] ) ? $_GET['plugin'] : '';
+ $nonce = isset( $_GET['_error_nonce'] ) && is_string( $_GET['_error_nonce'] ) ? $_GET['_error_nonce'] : '';
+
+ $iframe_url = add_query_arg(
+ [
+ 'action' => 'error_scrape',
+ 'plugin' => urlencode( $plugin ),
+ '_wpnonce' => urlencode( $nonce ),
+ ],
+ admin_url( 'plugins.php' )
+ );
+
+ return wp_get_admin_notice(
+ self::CORE_TEXT
+ . '