PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 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 All 36 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.4.2, at includes/class-page-cache-detector.php

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