PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-page-cache-detector.php

class-page-cache-detector.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.2, at includes/class-page-cache-detector.php

920 lines 32.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Page_Cache_Detector — read-only answer to "who owns the page cache on this
4 * site right now, and would installing xSpeed's drop-in take it from them?"
5 *
6 * Nothing in this class writes. It reads the active-plugin list, the two
7 * wp-content drop-ins, wp-config.php's WP_CACHE define, and the artifacts
8 * catalogued in Cache_Plugin_Catalog, then classifies the result into one
9 * explicit ownership state. Callers decide what to do with that; the detector
10 * never decides for them and never touches a file.
11 *
12 * Why a state and not a boolean: "another cache plugin is active" and "another
13 * cache plugin's page cache is live" are different facts, and so is "there is
14 * a drop-in here and we have no idea whose it is". Collapsing them into
15 * `$conflicts ? no : yes` is what let xSpeed back up and overwrite a foreign
16 * advanced-cache.php.
17 *
18 * Hard rules this class keeps:
19 * - never include, require, or execute a foreign plugin file;
20 * - never treat a loose substring ("cache") as proof of ownership;
21 * - an unreadable or unrecognized shared artifact is a BLOCKER, never a pass.
22 *
23 * @package XSpeed
24 */
25
26 namespace XSpeed;
27
28 defined( 'ABSPATH' ) || exit;
29
30 final class Page_Cache_Detector {
31
32 /** Nothing owns the page cache; the field is clear. */
33 public const STATE_UNCLAIMED = 'unclaimed';
34 /** Our own drop-in is installed. */
35 public const STATE_XSPEED_OWNED = 'xspeed-owned';
36 /** An identified foreign page cache is installed and serving. */
37 public const STATE_FOREIGN_LIVE = 'foreign-live';
38 /** Foreign artifacts remain but nothing is wired up to serve from them. */
39 public const STATE_FOREIGN_RESIDUAL = 'foreign-residual';
40 /** A page-cache-capable plugin is active with no drop-in evidence — it may cache at server level (LiteSpeed) or have caching switched off. Unprovable either way from here. */
41 public const STATE_POSSIBLE_LIVE = 'possible-live';
42 /** More than one foreign page cache is in play. */
43 public const STATE_CONTESTED = 'contested';
44 /** A drop-in (or a live WP_CACHE) exists that we cannot attribute to anyone. */
45 public const STATE_UNKNOWN_OCCUPIED = 'unknown-occupied';
46 /**
47 * A drop-in is present that nothing is running to defend: it is empty, or
48 * unnameable with every page-cache plugin switched off. We may take the
49 * field, but the field is NOT clear -- a file is still there, and
50 * is_field_clear() must keep saying so to the host plugins that ask. (#391)
51 */
52 public const STATE_ABANDONED = 'abandoned';
53 /** We could not read what we needed to decide. */
54 public const STATE_UNAVAILABLE = 'unavailable';
55
56 /** Drop-in owner classifications. */
57 public const OWNER_NONE = 'none';
58 public const OWNER_XSPEED = 'xspeed';
59 public const OWNER_FOREIGN = 'foreign';
60 public const OWNER_UNKNOWN = 'unknown';
61 /** The file is present but holds nothing — empty, or whitespace only. */
62 public const OWNER_ABANDONED = 'abandoned';
63 /** Our own row in the plugin catalog, keyed by this exact file name. */
64 private const SELF_PLUGIN_FILE = 'xspeed/xspeed.php';
65
66 /*
67 * Blocker codes, not sentences.
68 *
69 * The detector says what it found; the caller says it in its own words and
70 * its own textdomain. A code also survives being stored in a transaction
71 * record and compared later, which a translated string does not.
72 */
73
74 /** An identified foreign plugin owns advanced-cache.php. */
75 public const BLOCKER_FOREIGN_DROPIN = 'foreign_dropin';
76 /** advanced-cache.php is there and readable, but nobody claims it. */
77 public const BLOCKER_UNKNOWN_DROPIN = 'unknown_dropin';
78 /** advanced-cache.php is there and cannot be read. */
79 public const BLOCKER_UNREADABLE_DROPIN = 'unreadable_dropin';
80 /** A page-cache-capable plugin is active with no drop-in evidence. */
81 public const BLOCKER_ACTIVE_PAGE_CACHE = 'active_page_cache';
82 /** More than one plugin owns, or could own, the page cache. */
83 public const BLOCKER_MULTIPLE_PAGE_CACHES = 'multiple_page_caches';
84 /** WP_CACHE is true with no drop-in to explain it. */
85 public const BLOCKER_WP_CACHE_ORPHANED = 'wp_cache_orphaned';
86 /** wp-config.php defines WP_CACHE more than once. */
87 public const BLOCKER_WP_CACHE_DUPLICATE = 'wp_cache_duplicate';
88 /** WP_CACHE's value is an expression we cannot evaluate by reading it. */
89 public const BLOCKER_WP_CACHE_DYNAMIC = 'wp_cache_dynamic';
90 /** WP_CACHE is defined inside a conditional. */
91 public const BLOCKER_WP_CACHE_CONDITIONAL = 'wp_cache_conditional';
92 /** wp-config.php could not be read at all. */
93 public const BLOCKER_WP_CONFIG_UNREADABLE = 'wp_config_unreadable';
94
95 /** Note codes. Informational; a note never blocks. */
96 public const NOTE_OBJECT_CACHE_PRESENT = 'object_cache_present';
97 public const NOTE_RESIDUAL_CACHE_FILES = 'residual_cache_files';
98
99 /**
100 * @var array|null Memoized report for this request.
101 */
102 private static $report = null;
103
104 /**
105 * Full evidence report. Read-only; safe to call from a REST GET, the
106 * health card, or CLI.
107 *
108 * @return array{
109 * scope:string,
110 * multisite:bool,
111 * plugins:array<int,array>,
112 * dropin:array,
113 * object_dropin:array,
114 * wp_cache:array,
115 * revision:string
116 * }
117 */
118 public static function inspect( bool $fresh = true ): array {
119 if ( ! $fresh && null !== self::$report ) {
120 return self::$report;
121 }
122
123 $multisite = function_exists( 'is_multisite' ) && is_multisite();
124 $report = array(
125 'scope' => $multisite ? 'site-and-network' : 'site',
126 'multisite' => $multisite,
127 'plugins' => self::inspect_plugins(),
128 'dropin' => self::inspect_dropin(),
129 'object_dropin' => self::inspect_object_dropin(),
130 'wp_cache' => self::inspect_wp_cache(),
131 );
132
133 /*
134 * A fingerprint of everything the decision rests on. An acquisition
135 * transaction records the revision it inspected and re-checks it
136 * immediately before writing, so a plugin activated (or a drop-in
137 * dropped in) between the two loses the race instead of getting
138 * silently overwritten.
139 */
140 $report['revision'] = self::revision_of( $report );
141
142 self::$report = $report;
143 return $report;
144 }
145
146 /**
147 * Mandatory fresh evidence path for a caller that may write afterwards.
148 *
149 * Acquisition code must use this method immediately before comparing the
150 * revision and touching shared page-cache state. The named method makes a
151 * safety rescan visible at the call site; inspect() is fresh by default too.
152 */
153 public static function inspect_fresh(): array {
154 return self::inspect( true );
155 }
156
157 /**
158 * Per-plugin evidence. One row per CATALOGUED plugin with at least one
159 * signal present — an inactive plugin that left artifacts behind still
160 * gets a row, with `active` false.
161 *
162 * @return array<int,array>
163 */
164 private static function inspect_plugins(): array {
165 if ( ! function_exists( 'is_plugin_active' ) && defined( 'ABSPATH' ) && file_exists( ABSPATH . 'wp-admin/includes/plugin.php' ) ) {
166 require_once ABSPATH . 'wp-admin/includes/plugin.php';
167 }
168
169 $plugin_dir = defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : ( defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/plugins' : '' );
170
171 $out = array();
172 foreach ( Cache_Plugin_Catalog::all() as $file => $entry ) {
173 $signals = self::signals_for( $entry );
174 $installed = '' !== $plugin_dir && is_file( $plugin_dir . '/' . $file );
175 $network_active = function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $file );
176 $active = function_exists( 'is_plugin_active' ) ? (bool) is_plugin_active( $file ) : false;
177 $site_active = function_exists( 'get_option' ) && in_array( $file, (array) get_option( 'active_plugins', array() ), true );
178 if ( $active && ! $network_active ) {
179 $site_active = true;
180 }
181 $active = $active || $site_active || $network_active;
182 if ( ! $active && empty( $signals ) ) {
183 continue;
184 }
185
186 $out[] = array(
187 'plugin' => $file,
188 'label' => $entry['label'],
189 'active' => $active,
190 'installed' => $installed,
191 'site_active' => $site_active,
192 'network_active' => $network_active,
193 'activation_scope' => $site_active && $network_active ? 'site-and-network' : ( $network_active ? 'network' : ( $site_active ? 'site' : 'inactive' ) ),
194 'capabilities' => $entry['capabilities'],
195 'page_cache' => in_array( Cache_Plugin_Catalog::CAP_PAGE_CACHE, $entry['capabilities'], true ),
196 'signals' => $signals,
197 );
198 }
199 return $out;
200 }
201
202 /**
203 * Which of a catalog entry's signals are present. Constants and classes
204 * are checked without autoloading; options are read through get_option;
205 * paths are stat'd under wp-content. No file is opened.
206 *
207 * @return string[] e.g. ['constant:W3TC_DIR', 'path:cache/page_enhanced']
208 */
209 private static function signals_for( array $entry ): array {
210 $signals = $entry['signals'] ?? array();
211 $found = array();
212
213 foreach ( (array) ( $signals['constants'] ?? array() ) as $constant ) {
214 if ( defined( $constant ) ) {
215 $found[] = 'constant:' . $constant;
216 }
217 }
218 foreach ( (array) ( $signals['classes'] ?? array() ) as $class ) {
219 // Second arg false: never trigger an autoloader for foreign code.
220 if ( class_exists( $class, false ) ) {
221 $found[] = 'class:' . $class;
222 }
223 }
224 foreach ( (array) ( $signals['options'] ?? array() ) as $option ) {
225 if ( function_exists( 'get_option' ) ) {
226 $value = get_option( $option, null );
227 if ( null !== $value && false !== $value ) {
228 $found[] = 'option:' . $option;
229 }
230 }
231 }
232 foreach ( (array) ( $signals['paths'] ?? array() ) as $path ) {
233 if ( defined( 'WP_CONTENT_DIR' ) && file_exists( WP_CONTENT_DIR . '/' . ltrim( (string) $path, '/' ) ) ) {
234 $found[] = 'path:' . $path;
235 }
236 }
237
238 return $found;
239 }
240
241 /**
242 * advanced-cache.php state: does it exist, whose is it, and what does it
243 * hash to. The hash is the compare-and-swap token for an acquisition —
244 * a writer that finds a different hash than it inspected must abort.
245 */
246 private static function inspect_dropin(): array {
247 $target = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/advanced-cache.php' : '';
248 $state = array(
249 'path' => $target,
250 'exists' => false,
251 'owner' => self::OWNER_NONE,
252 'plugin' => null,
253 'label' => null,
254 'hash' => null,
255 'readable' => true,
256 );
257
258 if ( '' === $target || ! file_exists( $target ) ) {
259 return $state;
260 }
261
262 $state['exists'] = true;
263 $contents = self::read( $target );
264 if ( null === $contents ) {
265 // Present but unreadable. That is strictly worse than a known
266 // foreign drop-in — we cannot even name what we would destroy.
267 $state['readable'] = false;
268 $state['owner'] = self::OWNER_UNKNOWN;
269 return $state;
270 }
271
272 $state['hash'] = hash( 'sha256', $contents );
273
274 /*
275 * An EMPTY file owns nothing. WP Rocket truncates advanced-cache.php
276 * to 0 bytes on deactivate, and a whitespace-only file is the same
277 * nothing. Both matched no vendor signature and fell through to
278 * OWNER_UNKNOWN below, which blocks acquisition permanently — a site
279 * could be left with the source plugin off, xSpeed refused, and no
280 * page cache at all, clearable only over SSH. There is nothing here
281 * to break and nobody to ask. (#391)
282 */
283 if ( '' === trim( $contents ) ) {
284 $state['owner'] = self::OWNER_ABANDONED;
285 return $state;
286 }
287
288 if ( self::has_xspeed_signature( $contents ) ) {
289 $state['owner'] = self::OWNER_XSPEED;
290 $state['label'] = 'xSpeed';
291 return $state;
292 }
293
294 $owner = Cache_Plugin_Catalog::identify_dropin( $contents );
295 if ( null !== $owner ) {
296 $entry = Cache_Plugin_Catalog::get( $owner );
297 $state['owner'] = self::OWNER_FOREIGN;
298 $state['plugin'] = $owner;
299 $state['label'] = self::dropin_label( $entry, $owner );
300 return $state;
301 }
302
303 $state['owner'] = self::OWNER_UNKNOWN;
304 return $state;
305 }
306
307 /**
308 * object-cache.php state. Informational only: a persistent object cache
309 * sits beside a page cache rather than competing with it, so this must
310 * never block a page-cache install — it is reported so the UI can say
311 * "Redis is here and we left it alone".
312 */
313 private static function inspect_object_dropin(): array {
314 $target = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
315 $state = array(
316 'path' => $target,
317 'exists' => false,
318 'readable' => true,
319 'plugin' => null,
320 'label' => null,
321 'hash' => null,
322 );
323
324 if ( '' === $target || ! file_exists( $target ) ) {
325 return $state;
326 }
327
328 $state['exists'] = true;
329 $contents = self::read( $target );
330 if ( null === $contents ) {
331 $state['readable'] = false;
332 return $state;
333 }
334
335 $state['hash'] = hash( 'sha256', $contents );
336 $owner = Cache_Plugin_Catalog::identify_object_dropin( $contents );
337 if ( null !== $owner ) {
338 $entry = Cache_Plugin_Catalog::get( $owner );
339 $state['plugin'] = $owner;
340 $state['label'] = self::dropin_label( $entry, $owner );
341 }
342
343 return $state;
344 }
345
346 /**
347 * What a DROP-IN is allowed to say about its owner.
348 *
349 * A plugin row can be labelled precisely — it is either on disk or it is
350 * not. A drop-in cannot: Swift Performance Lite and the commercial build
351 * write the same banner, so attributing one to the catalog entry that
352 * happens to sort first reported a commercial install as "Lite". Entries
353 * with that problem declare a `family` label covering both, and this is
354 * the only place it is used.
355 *
356 * @param array|null $entry Catalog entry, or null when there is none.
357 * @param string $plugin Plugin file, used as the last-resort label.
358 */
359 private static function dropin_label( ?array $entry, string $plugin ): string {
360 if ( null === $entry ) {
361 return $plugin;
362 }
363 return (string) ( $entry['family'] ?? $entry['label'] ?? $plugin );
364 }
365
366 /**
367 * WP_CACHE as written in wp-config.php, plus the runtime value.
368 *
369 * The literal matters more than the runtime constant: a define wrapped in
370 * a conditional, or two competing defines, cannot be safely rewritten by a
371 * regex, and a writer that tries anyway can silently disable another
372 * plugin's cache (or its own).
373 *
374 * state is one of: undefined | true | false | duplicate | dynamic |
375 * unreadable.
376 */
377 private static function inspect_wp_cache(): array {
378 $runtime = defined( 'WP_CACHE' ) ? (bool) constant( 'WP_CACHE' ) : null;
379 $path = Cache::wp_config_path();
380 $blank = array(
381 'path' => $path,
382 'readable' => false,
383 'state' => 'unreadable',
384 'runtime' => $runtime,
385 'defines' => 0,
386 'hash' => null,
387 );
388
389 if ( '' === $path ) {
390 return $blank;
391 }
392
393 $config = self::read( $path );
394 if ( null === $config ) {
395 return $blank;
396 }
397
398 /*
399 * One parser, shared with the writer.
400 *
401 * The detector used to carry its own token scan. Two scans of the same
402 * grammar drift, and the pair that must never disagree is exactly this
403 * one: the reader decides whether a rewrite is safe and the writer
404 * performs it. xspeed_parse_wp_cache_defines() is a plain function in
405 * its own file so both can reach it before either class loads.
406 */
407 if ( ! function_exists( 'xspeed_parse_wp_cache_defines' ) ) {
408 require_once __DIR__ . '/wp-cache-constant.php';
409 }
410 $parsed = xspeed_parse_wp_cache_defines( $config );
411
412 return array(
413 'path' => $path,
414 'readable' => true,
415 'state' => (string) $parsed['state'],
416 'runtime' => $runtime,
417 'defines' => count( $parsed['defines'] ),
418 'hash' => hash( 'sha256', $config ),
419 );
420 }
421
422 /**
423 * Is it safe to install, activate, or promote a page cache right now?
424 *
425 * The one call most callers need. True only when nothing owns the page
426 * cache and nothing about the site's state is unreadable or ambiguous.
427 * `foreign-residual` passes because the artifacts left behind are inert —
428 * no drop-in, no active plugin — and refusing there would strand every
429 * site that ever tried another cache plugin.
430 */
431 /**
432 * Is some OTHER page-cache plugin active right now?
433 *
434 * "Is anything running that replacing this file would break?" is the
435 * question that decides an acquisition, and it has to exclude US. We are
436 * in the catalog too and we are nearly always active while asking, so
437 * counting ourselves answered yes on every site -- which is why an
438 * unnameable drop-in could never be recognised as abandoned. (#391, #393)
439 *
440 * Deliberately narrow, so callers that need only this (Cache::dropin_owner())
441 * do not have to run the whole of classify() -- which reads wp-config.php
442 * and the drop-in, and would couple an ownership answer to failures that
443 * have nothing to do with it.
444 */
445 public static function another_page_cache_is_active(): bool {
446 foreach ( self::inspect()['plugins'] as $plugin ) {
447 if ( self::SELF_PLUGIN_FILE === (string) $plugin['plugin'] ) {
448 continue;
449 }
450 if ( ! empty( $plugin['page_cache'] ) && ! empty( $plugin['active'] ) ) {
451 return true;
452 }
453 }
454 return false;
455 }
456
457 public static function is_field_clear(): bool {
458 $verdict = self::classify();
459
460 if ( ! empty( $verdict['blockers'] ) ) {
461 return false;
462 }
463
464 return in_array(
465 $verdict['state'],
466 array( self::STATE_UNCLAIMED, self::STATE_FOREIGN_RESIDUAL ),
467 true
468 );
469 }
470
471 /**
472 * Labels of every ACTIVE plugin that can write a page cache.
473 *
474 * For a screen that wants to say what it found rather than only that it
475 * found something.
476 *
477 * @return string[]
478 */
479 public static function active_page_caches(): array {
480 $out = array();
481 foreach ( self::inspect()['plugins'] as $plugin ) {
482 if ( $plugin['page_cache'] && $plugin['active'] ) {
483 $out[] = (string) $plugin['label'];
484 }
485 }
486 return $out;
487 }
488
489 /**
490 * Who owns wp-content/advanced-cache.php, as a plugin label, or null when
491 * nobody does — or when we cannot tell.
492 */
493 public static function dropin_owner_label(): ?string {
494 $dropin = self::inspect()['dropin'];
495 return is_string( $dropin['label'] ) ? $dropin['label'] : null;
496 }
497
498 /**
499 * What the dashboard must tell the user BEFORE it turns page caching on.
500 *
501 * Enabling writes wp-content/advanced-cache.php, which WordPress gives to
502 * exactly one plugin. When a file is already there, the write REPLACES it
503 * — so the dashboard says whose file it is BEFORE the click rather than
504 * taking it silently:
505 *
506 * - `exists` a drop-in is on disk right now
507 * - `replaceable` enabling would overwrite it
508 * - `label` who it belongs to, when that can be named
509 * ("WP Rocket"), null when it genuinely cannot
510 *
511 * Ownership is no longer what decides this. A competitor's live drop-in
512 * used to be refused outright, which left a user who had asked for our
513 * cache unable to get it — turning the page cache on is the instruction
514 * to serve pages from cache, and that cannot be done without this file.
515 * So a foreign drop-in is replaceable like any other, and the prompt is
516 * how the user is told what they are taking over.
517 *
518 * Two states still disclose nothing. A drop-in we already own is a plain
519 * re-enable with nothing to replace, and an UNREADABLE one is refused by
520 * install_dropin() — promising a replacement that the writer will then
521 * refuse is the split brain this method exists to avoid.
522 *
523 * @return array{exists:bool,replaceable:bool,owner:string,label:string|null}
524 */
525 public static function dropin_disclosure(): array {
526 $dropin = self::inspect()['dropin'];
527 $owner = (string) $dropin['owner'];
528
529 /*
530 * `replaceable` is a PROMISE, kept by install_dropin(), so this list
531 * must stay in step with the refusals there: everything except our
532 * own file and one we cannot read.
533 */
534 $replaceable = (bool) $dropin['exists']
535 && self::OWNER_XSPEED !== $owner
536 && $dropin['readable'];
537
538 return array(
539 'exists' => (bool) $dropin['exists'],
540 'replaceable' => $replaceable,
541 'owner' => $owner,
542 'label' => is_string( $dropin['label'] ) ? $dropin['label'] : null,
543 );
544 }
545
546 /**
547 * Classify the report into one ownership state plus the reasons behind it.
548 *
549 * Blockers and notes are CODES with the evidence attached, never rendered
550 * sentences — see the BLOCKER_* constants. Cache::ownership_blocker_message()
551 * is what turns one into words.
552 *
553 * @param array|null $report Report from inspect(); re-inspected when null.
554 * @return array{state:string,blockers:array<int,array>,notes:array<int,array>,revision:string}
555 */
556 public static function classify( ?array $report = null ): array {
557 $report = $report ?? self::inspect();
558 $blockers = array();
559 $notes = array();
560
561 $dropin = $report['dropin'];
562 $object_dropin = $report['object_dropin'];
563 $wp_cache = $report['wp_cache'];
564
565 if ( $object_dropin['exists'] ) {
566 // Informational only. A persistent object cache sits BESIDE a page
567 // cache; it competes for nothing and must never block.
568 $notes[] = array(
569 'code' => self::NOTE_OBJECT_CACHE_PRESENT,
570 'plugin' => $object_dropin['plugin'],
571 'label' => $object_dropin['label'],
572 );
573 }
574
575 foreach ( self::residual_plugins( $report['plugins'] ) as $plugin ) {
576 $notes[] = array(
577 'code' => self::NOTE_RESIDUAL_CACHE_FILES,
578 'plugin' => $plugin['plugin'],
579 'label' => $plugin['label'],
580 );
581 }
582
583 /*
584 * Everything that owns, or could own, the page cache — keyed by PLUGIN
585 * FILE so one plugin counts once. A live competitor is normally both
586 * active and the drop-in's owner; counting those as two put the
587 * ordinary single-competitor site in `contested` and told the user
588 * "more than one page-caching plugin is in play" about one plugin.
589 */
590 $owners = array();
591 $active = array();
592 foreach ( $report['plugins'] as $plugin ) {
593 if ( $plugin['page_cache'] && $plugin['active'] ) {
594 $active[ (string) $plugin['plugin'] ] = $plugin;
595 $owners[ (string) $plugin['plugin'] ] = (string) $plugin['label'];
596 }
597 }
598 if ( self::OWNER_FOREIGN === $dropin['owner'] ) {
599 $key = (string) ( $dropin['plugin'] ?? $dropin['label'] );
600 $owners[ $key ] = (string) $dropin['label'];
601 $blockers[] = array(
602 'code' => self::BLOCKER_FOREIGN_DROPIN,
603 'plugin' => $dropin['plugin'],
604 'label' => $dropin['label'],
605 );
606 } elseif ( self::OWNER_UNKNOWN === $dropin['owner'] ) {
607 /*
608 * A file we cannot name blocks only while a page cache is
609 * actually running. With every candidate switched off there is
610 * nothing to break by replacing it, and refusing anyway strands
611 * the site with no cache and no route back that is not SSH.
612 *
613 * An UNREADABLE file is different and still blocks outright: we
614 * cannot even see what we would destroy. (#391, #393)
615 */
616 if ( ! $dropin['readable'] ) {
617 $blockers[] = array(
618 'code' => self::BLOCKER_UNREADABLE_DROPIN,
619 'plugin' => null,
620 'label' => null,
621 );
622 } elseif ( self::another_page_cache_is_active() ) {
623 $blockers[] = array(
624 'code' => self::BLOCKER_UNKNOWN_DROPIN,
625 'plugin' => null,
626 'label' => null,
627 );
628 }
629 }
630
631 $wp_cache_blocker = array(
632 'unreadable' => self::BLOCKER_WP_CONFIG_UNREADABLE,
633 'duplicate' => self::BLOCKER_WP_CACHE_DUPLICATE,
634 'dynamic' => self::BLOCKER_WP_CACHE_DYNAMIC,
635 'conditional' => self::BLOCKER_WP_CACHE_CONDITIONAL,
636 );
637 if ( isset( $wp_cache_blocker[ $wp_cache['state'] ] ) ) {
638 $blockers[] = array(
639 'code' => $wp_cache_blocker[ $wp_cache['state'] ],
640 'plugin' => null,
641 'label' => null,
642 );
643 }
644
645 // Order matters: the most specific unsafe state wins.
646 if ( 'unreadable' === $wp_cache['state'] || ! $dropin['readable'] ) {
647 return self::verdict( self::STATE_UNAVAILABLE, $blockers, $notes, $report );
648 }
649
650 if ( count( $owners ) > 1 || ( self::OWNER_XSPEED === $dropin['owner'] && ! empty( $active ) ) ) {
651 /*
652 * `plugin` and `label` stay null — "multiple" has no single owner
653 * to name. `plugins` and `labels` carry every owner found, in the
654 * same key order, so a consumer can subtract ITSELF and name what
655 * is left. Without them every contested site read the same
656 * anonymous sentence.
657 */
658 $blockers[] = array(
659 'code' => self::BLOCKER_MULTIPLE_PAGE_CACHES,
660 'plugin' => null,
661 'label' => null,
662 'plugins' => array_keys( $owners ),
663 'labels' => array_values( $owners ),
664 );
665 return self::verdict( self::STATE_CONTESTED, $blockers, $notes, $report );
666 }
667
668 if ( self::OWNER_UNKNOWN === $dropin['owner'] ) {
669 // Live competitor -> occupied and refused. Nothing running ->
670 // abandoned: acquirable, but still not a clear field. Ourselves
671 // excluded: $active counts US too, and we are active while
672 // asking, so this could never be false. (#391/#393)
673 return self::verdict(
674 self::another_page_cache_is_active() ? self::STATE_UNKNOWN_OCCUPIED : self::STATE_ABANDONED,
675 $blockers,
676 $notes,
677 $report
678 );
679 }
680
681 if ( self::OWNER_ABANDONED === $dropin['owner'] ) {
682 return self::verdict( self::STATE_ABANDONED, $blockers, $notes, $report );
683 }
684
685 if ( self::OWNER_FOREIGN === $dropin['owner'] ) {
686 return self::verdict( self::STATE_FOREIGN_LIVE, $blockers, $notes, $report );
687 }
688
689 if ( self::OWNER_XSPEED === $dropin['owner'] ) {
690 return self::verdict( self::STATE_XSPEED_OWNED, $blockers, $notes, $report );
691 }
692
693 if ( ! empty( $active ) ) {
694 $first = reset( $active );
695 $blockers[] = array(
696 'code' => self::BLOCKER_ACTIVE_PAGE_CACHE,
697 'plugin' => $first['plugin'],
698 'label' => $first['label'],
699 );
700 return self::verdict( self::STATE_POSSIBLE_LIVE, $blockers, $notes, $report );
701 }
702
703 /*
704 * A WP_CACHE we cannot rewrite is not a clear field. `duplicate`,
705 * `dynamic` and `conditional` already added a blocker above, but the
706 * ladder used to fall past them to `unclaimed` — a state documented as
707 * "field clear: yes". is_field_clear() was safe either way because it
708 * checks blockers first, but a caller branching on the STATE read a
709 * doubly-defined or expression-valued config as a clean site.
710 */
711 if ( in_array( $wp_cache['state'], array( 'duplicate', 'dynamic', 'conditional' ), true ) ) {
712 return self::verdict( self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report );
713 }
714
715 /*
716 * No drop-in and nothing active, but WP_CACHE is true — something
717 * enabled page caching and we cannot say what. Review, not "clear".
718 */
719 if ( 'true' === $wp_cache['state'] ) {
720 $blockers[] = array(
721 'code' => self::BLOCKER_WP_CACHE_ORPHANED,
722 'plugin' => null,
723 'label' => null,
724 );
725 return self::verdict( self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report );
726 }
727
728 foreach ( $notes as $note ) {
729 if ( self::NOTE_RESIDUAL_CACHE_FILES === $note['code'] ) {
730 // Residual foreign artifacts with nothing live: safe to
731 // proceed, worth saying out loud.
732 return self::verdict( self::STATE_FOREIGN_RESIDUAL, $blockers, $notes, $report );
733 }
734 }
735
736 return self::verdict( self::STATE_UNCLAIMED, $blockers, $notes, $report );
737 }
738
739 /**
740 * Inactive page-cache plugins whose artifacts are their OWN.
741 *
742 * Builds that share a signal set — Swift Performance Lite and the
743 * commercial build share their constants, options row and cache dir —
744 * would otherwise each be reported as having "left cache files behind",
745 * including the one that was never on this site. Blaming an active
746 * sibling was already handled; a deactivated Lite with the commercial
747 * build absent still produced two notes, because nothing was active to
748 * explain the signals away (PR #295 review).
749 *
750 * So a signal is credited to a plugin only when no plugin with stronger
751 * standing also carries it — active over installed-but-inactive over not
752 * on disk at all — and only a page-cache plugin can explain a page-cache
753 * artifact. Two absent twins sharing every signal cannot be told apart
754 * and are both reported; that is the honest answer.
755 *
756 * @param array<int,array> $plugins Rows from inspect_plugins().
757 * @return array<int,array> The rows that earn a residual note.
758 */
759 private static function residual_plugins( array $plugins ): array {
760 $standing = static function ( array $plugin ): int {
761 if ( $plugin['active'] ) {
762 return 2;
763 }
764 return ! empty( $plugin['installed'] ) ? 1 : 0;
765 };
766
767 $out = array();
768 foreach ( $plugins as $plugin ) {
769 if ( ! $plugin['page_cache'] || $plugin['active'] || empty( $plugin['signals'] ) ) {
770 continue;
771 }
772
773 $explained = array();
774 foreach ( $plugins as $other ) {
775 if ( ! $other['page_cache'] || $other['plugin'] === $plugin['plugin'] || $standing( $other ) <= $standing( $plugin ) ) {
776 continue;
777 }
778 $explained = array_merge( $explained, (array) $other['signals'] );
779 }
780
781 if ( array_diff( (array) $plugin['signals'], $explained ) ) {
782 $out[] = $plugin;
783 }
784 }
785
786 return $out;
787 }
788
789 private static function verdict( string $state, array $blockers, array $notes, array $report ): array {
790 return array(
791 'state' => $state,
792 'blockers' => self::unique_entries( $blockers ),
793 'notes' => self::unique_entries( $notes ),
794 'revision' => (string) ( $report['revision'] ?? '' ),
795 );
796 }
797
798 /**
799 * Deduplicate blocker/note entries by their whole shape.
800 *
801 * array_unique() compares string casts, which is a notice-and-nonsense
802 * combination for arrays, and SORT_REGULAR compares loosely enough to
803 * collapse findings about two different plugins. Encoding the entry is the
804 * only comparison that means "the same finding about the same plugin".
805 *
806 * @param array<int,array> $entries
807 * @return array<int,array>
808 */
809 private static function unique_entries( array $entries ): array {
810 $seen = array();
811 $out = array();
812 foreach ( $entries as $entry ) {
813 $key = (string) wp_json_encode( $entry );
814 if ( isset( $seen[ $key ] ) ) {
815 continue;
816 }
817 $seen[ $key ] = true;
818 $out[] = $entry;
819 }
820 return $out;
821 }
822
823 /**
824 * May xSpeed install its page-cache artifacts right now?
825 *
826 * True only for states where nothing else owns, or could own, the page
827 * cache. `foreign-residual` passes because the artifacts are inert — no
828 * drop-in, no active plugin — and refusing there would strand every site
829 * that ever tried another cache plugin.
830 */
831 public static function can_acquire( ?array $verdict = null ): bool {
832 $verdict = $verdict ?? self::classify();
833
834 /*
835 * Both conditions, not either. A blocker can be raised in an otherwise
836 * clear state — a duplicate or expression-valued WP_CACHE define is
837 * nobody's page cache, but it is also not something a regex may
838 * rewrite, so the field being empty does not make the write safe.
839 */
840 if ( ! empty( $verdict['blockers'] ) ) {
841 return false;
842 }
843
844 return in_array(
845 $verdict['state'],
846 array( self::STATE_UNCLAIMED, self::STATE_XSPEED_OWNED, self::STATE_FOREIGN_RESIDUAL, self::STATE_ABANDONED ),
847 true
848 );
849 }
850
851 /**
852 * Fingerprint of the evidence, so a writer can prove nothing moved
853 * between inspection and the write.
854 */
855 public static function revision_of( array $report ): string {
856 $material = $report;
857 unset( $material['revision'] );
858 return hash( 'sha256', (string) wp_json_encode( $material ) );
859 }
860
861 /** Exact xSpeed banner line, never a loose substring in code or data. */
862 private static function has_xspeed_signature( string $contents ): bool {
863 foreach ( token_get_all( $contents ) as $token ) {
864 if ( ! is_array( $token ) || ! in_array( $token[0], array( T_COMMENT, T_DOC_COMMENT ), true ) ) {
865 continue;
866 }
867 if ( preg_match( '/^(?:\\s*[\\/#*]+\\s*)XSPEED_DROPIN\\s*$/mi', $token[1] ) ) {
868 return true;
869 }
870 }
871 return false;
872 }
873
874 /**
875 * Read a file for inspection. Returns null on any failure — callers treat
876 * null as "unknown", never as "empty".
877 */
878 private static function read( string $path ): ?string {
879 if ( ! is_readable( $path ) ) {
880 return null;
881 }
882 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read-only inspection of a local file; WP_Filesystem would need credentials we must not prompt for on a GET.
883 $contents = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer here ("unknown"), not an error to surface.
884 return is_string( $contents ) ? $contents : null;
885 }
886
887 /**
888 * Drop the memoized report. Anything that changes plugin state or writes
889 * a drop-in must call this.
890 */
891 public static function invalidate(): void {
892 /*
893 * Drop PHP's stat cache with our own memo. Both describe the same
894 * files, and the caller invalidating us has just changed them --
895 * often from inside another plugin's deactivation hook, in the same
896 * request. file_exists()/filesize()/is_readable() would otherwise
897 * keep answering from before the change, so a drop-in truncated to
898 * 0 bytes a moment ago still reads as the source plugin's live cache
899 * and the handover refuses. (#391)
900 *
901 * No unit test: file_put_contents() clears the entry for the path it
902 * writes, so a single-process test cannot reproduce a stale stat --
903 * the real case is another plugin's teardown writing through a
904 * different path string. Verified end to end against a live WP Rocket
905 * install instead.
906 */
907 clearstatcache();
908 self::$report = null;
909 Cache_Plugin_Catalog::invalidate();
910 }
911
912 /**
913 * Bootstrap hooks. Call once from Plugin::init().
914 */
915 public static function boot(): void {
916 add_action( 'activated_plugin', array( __CLASS__, 'invalidate' ) );
917 add_action( 'deactivated_plugin', array( __CLASS__, 'invalidate' ) );
918 }
919 }
920