PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 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.2.4, at includes/class-page-cache-detector.php

775 lines 27.0 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 /** We could not read what we needed to decide. */
47 public const STATE_UNAVAILABLE = 'unavailable';
48
49 /** Drop-in owner classifications. */
50 public const OWNER_NONE = 'none';
51 public const OWNER_XSPEED = 'xspeed';
52 public const OWNER_FOREIGN = 'foreign';
53 public const OWNER_UNKNOWN = 'unknown';
54
55 /*
56 * Blocker codes, not sentences.
57 *
58 * The detector says what it found; the caller says it in its own words and
59 * its own textdomain. A code also survives being stored in a transaction
60 * record and compared later, which a translated string does not.
61 */
62
63 /** An identified foreign plugin owns advanced-cache.php. */
64 public const BLOCKER_FOREIGN_DROPIN = 'foreign_dropin';
65 /** advanced-cache.php is there and readable, but nobody claims it. */
66 public const BLOCKER_UNKNOWN_DROPIN = 'unknown_dropin';
67 /** advanced-cache.php is there and cannot be read. */
68 public const BLOCKER_UNREADABLE_DROPIN = 'unreadable_dropin';
69 /** A page-cache-capable plugin is active with no drop-in evidence. */
70 public const BLOCKER_ACTIVE_PAGE_CACHE = 'active_page_cache';
71 /** More than one plugin owns, or could own, the page cache. */
72 public const BLOCKER_MULTIPLE_PAGE_CACHES = 'multiple_page_caches';
73 /** WP_CACHE is true with no drop-in to explain it. */
74 public const BLOCKER_WP_CACHE_ORPHANED = 'wp_cache_orphaned';
75 /** wp-config.php defines WP_CACHE more than once. */
76 public const BLOCKER_WP_CACHE_DUPLICATE = 'wp_cache_duplicate';
77 /** WP_CACHE's value is an expression we cannot evaluate by reading it. */
78 public const BLOCKER_WP_CACHE_DYNAMIC = 'wp_cache_dynamic';
79 /** WP_CACHE is defined inside a conditional. */
80 public const BLOCKER_WP_CACHE_CONDITIONAL = 'wp_cache_conditional';
81 /** wp-config.php could not be read at all. */
82 public const BLOCKER_WP_CONFIG_UNREADABLE = 'wp_config_unreadable';
83
84 /** Note codes. Informational; a note never blocks. */
85 public const NOTE_OBJECT_CACHE_PRESENT = 'object_cache_present';
86 public const NOTE_RESIDUAL_CACHE_FILES = 'residual_cache_files';
87
88 /**
89 * @var array|null Memoized report for this request.
90 */
91 private static $report = null;
92
93 /**
94 * Full evidence report. Read-only; safe to call from a REST GET, the
95 * health card, or CLI.
96 *
97 * @return array{
98 * scope:string,
99 * multisite:bool,
100 * plugins:array<int,array>,
101 * dropin:array,
102 * object_dropin:array,
103 * wp_cache:array,
104 * revision:string
105 * }
106 */
107 public static function inspect( bool $fresh = true ): array {
108 if ( ! $fresh && null !== self::$report ) {
109 return self::$report;
110 }
111
112 $multisite = function_exists( 'is_multisite' ) && is_multisite();
113 $report = array(
114 'scope' => $multisite ? 'site-and-network' : 'site',
115 'multisite' => $multisite,
116 'plugins' => self::inspect_plugins(),
117 'dropin' => self::inspect_dropin(),
118 'object_dropin' => self::inspect_object_dropin(),
119 'wp_cache' => self::inspect_wp_cache(),
120 );
121
122 /*
123 * A fingerprint of everything the decision rests on. An acquisition
124 * transaction records the revision it inspected and re-checks it
125 * immediately before writing, so a plugin activated (or a drop-in
126 * dropped in) between the two loses the race instead of getting
127 * silently overwritten.
128 */
129 $report['revision'] = self::revision_of( $report );
130
131 self::$report = $report;
132 return $report;
133 }
134
135 /**
136 * Mandatory fresh evidence path for a caller that may write afterwards.
137 *
138 * Acquisition code must use this method immediately before comparing the
139 * revision and touching shared page-cache state. The named method makes a
140 * safety rescan visible at the call site; inspect() is fresh by default too.
141 */
142 public static function inspect_fresh(): array {
143 return self::inspect( true );
144 }
145
146 /**
147 * Per-plugin evidence. One row per CATALOGUED plugin with at least one
148 * signal present — an inactive plugin that left artifacts behind still
149 * gets a row, with `active` false.
150 *
151 * @return array<int,array>
152 */
153 private static function inspect_plugins(): array {
154 if ( ! function_exists( 'is_plugin_active' ) && defined( 'ABSPATH' ) && file_exists( ABSPATH . 'wp-admin/includes/plugin.php' ) ) {
155 require_once ABSPATH . 'wp-admin/includes/plugin.php';
156 }
157
158 $plugin_dir = defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : ( defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/plugins' : '' );
159
160 $out = array();
161 foreach ( Cache_Plugin_Catalog::all() as $file => $entry ) {
162 $signals = self::signals_for( $entry );
163 $installed = '' !== $plugin_dir && is_file( $plugin_dir . '/' . $file );
164 $network_active = function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $file );
165 $active = function_exists( 'is_plugin_active' ) ? (bool) is_plugin_active( $file ) : false;
166 $site_active = function_exists( 'get_option' ) && in_array( $file, (array) get_option( 'active_plugins', array() ), true );
167 if ( $active && ! $network_active ) {
168 $site_active = true;
169 }
170 $active = $active || $site_active || $network_active;
171 if ( ! $active && empty( $signals ) ) {
172 continue;
173 }
174
175 $out[] = array(
176 'plugin' => $file,
177 'label' => $entry['label'],
178 'active' => $active,
179 'installed' => $installed,
180 'site_active' => $site_active,
181 'network_active' => $network_active,
182 'activation_scope' => $site_active && $network_active ? 'site-and-network' : ( $network_active ? 'network' : ( $site_active ? 'site' : 'inactive' ) ),
183 'capabilities' => $entry['capabilities'],
184 'page_cache' => in_array( Cache_Plugin_Catalog::CAP_PAGE_CACHE, $entry['capabilities'], true ),
185 'signals' => $signals,
186 );
187 }
188 return $out;
189 }
190
191 /**
192 * Which of a catalog entry's signals are present. Constants and classes
193 * are checked without autoloading; options are read through get_option;
194 * paths are stat'd under wp-content. No file is opened.
195 *
196 * @return string[] e.g. ['constant:W3TC_DIR', 'path:cache/page_enhanced']
197 */
198 private static function signals_for( array $entry ): array {
199 $signals = $entry['signals'] ?? array();
200 $found = array();
201
202 foreach ( (array) ( $signals['constants'] ?? array() ) as $constant ) {
203 if ( defined( $constant ) ) {
204 $found[] = 'constant:' . $constant;
205 }
206 }
207 foreach ( (array) ( $signals['classes'] ?? array() ) as $class ) {
208 // Second arg false: never trigger an autoloader for foreign code.
209 if ( class_exists( $class, false ) ) {
210 $found[] = 'class:' . $class;
211 }
212 }
213 foreach ( (array) ( $signals['options'] ?? array() ) as $option ) {
214 if ( function_exists( 'get_option' ) ) {
215 $value = get_option( $option, null );
216 if ( null !== $value && false !== $value ) {
217 $found[] = 'option:' . $option;
218 }
219 }
220 }
221 foreach ( (array) ( $signals['paths'] ?? array() ) as $path ) {
222 if ( defined( 'WP_CONTENT_DIR' ) && file_exists( WP_CONTENT_DIR . '/' . ltrim( (string) $path, '/' ) ) ) {
223 $found[] = 'path:' . $path;
224 }
225 }
226
227 return $found;
228 }
229
230 /**
231 * advanced-cache.php state: does it exist, whose is it, and what does it
232 * hash to. The hash is the compare-and-swap token for an acquisition —
233 * a writer that finds a different hash than it inspected must abort.
234 */
235 private static function inspect_dropin(): array {
236 $target = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/advanced-cache.php' : '';
237 $state = array(
238 'path' => $target,
239 'exists' => false,
240 'owner' => self::OWNER_NONE,
241 'plugin' => null,
242 'label' => null,
243 'hash' => null,
244 'readable' => true,
245 );
246
247 if ( '' === $target || ! file_exists( $target ) ) {
248 return $state;
249 }
250
251 $state['exists'] = true;
252 $contents = self::read( $target );
253 if ( null === $contents ) {
254 // Present but unreadable. That is strictly worse than a known
255 // foreign drop-in — we cannot even name what we would destroy.
256 $state['readable'] = false;
257 $state['owner'] = self::OWNER_UNKNOWN;
258 return $state;
259 }
260
261 $state['hash'] = hash( 'sha256', $contents );
262
263 if ( self::has_xspeed_signature( $contents ) ) {
264 $state['owner'] = self::OWNER_XSPEED;
265 $state['label'] = 'xSpeed';
266 return $state;
267 }
268
269 $owner = Cache_Plugin_Catalog::identify_dropin( $contents );
270 if ( null !== $owner ) {
271 $entry = Cache_Plugin_Catalog::get( $owner );
272 $state['owner'] = self::OWNER_FOREIGN;
273 $state['plugin'] = $owner;
274 $state['label'] = self::dropin_label( $entry, $owner );
275 return $state;
276 }
277
278 $state['owner'] = self::OWNER_UNKNOWN;
279 return $state;
280 }
281
282 /**
283 * object-cache.php state. Informational only: a persistent object cache
284 * sits beside a page cache rather than competing with it, so this must
285 * never block a page-cache install — it is reported so the UI can say
286 * "Redis is here and we left it alone".
287 */
288 private static function inspect_object_dropin(): array {
289 $target = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
290 $state = array(
291 'path' => $target,
292 'exists' => false,
293 'readable' => true,
294 'plugin' => null,
295 'label' => null,
296 'hash' => null,
297 );
298
299 if ( '' === $target || ! file_exists( $target ) ) {
300 return $state;
301 }
302
303 $state['exists'] = true;
304 $contents = self::read( $target );
305 if ( null === $contents ) {
306 $state['readable'] = false;
307 return $state;
308 }
309
310 $state['hash'] = hash( 'sha256', $contents );
311 $owner = Cache_Plugin_Catalog::identify_object_dropin( $contents );
312 if ( null !== $owner ) {
313 $entry = Cache_Plugin_Catalog::get( $owner );
314 $state['plugin'] = $owner;
315 $state['label'] = self::dropin_label( $entry, $owner );
316 }
317
318 return $state;
319 }
320
321 /**
322 * What a DROP-IN is allowed to say about its owner.
323 *
324 * A plugin row can be labelled precisely — it is either on disk or it is
325 * not. A drop-in cannot: Swift Performance Lite and the commercial build
326 * write the same banner, so attributing one to the catalog entry that
327 * happens to sort first reported a commercial install as "Lite". Entries
328 * with that problem declare a `family` label covering both, and this is
329 * the only place it is used.
330 *
331 * @param array|null $entry Catalog entry, or null when there is none.
332 * @param string $plugin Plugin file, used as the last-resort label.
333 */
334 private static function dropin_label( ?array $entry, string $plugin ): string {
335 if ( null === $entry ) {
336 return $plugin;
337 }
338 return (string) ( $entry['family'] ?? $entry['label'] ?? $plugin );
339 }
340
341 /**
342 * WP_CACHE as written in wp-config.php, plus the runtime value.
343 *
344 * The literal matters more than the runtime constant: a define wrapped in
345 * a conditional, or two competing defines, cannot be safely rewritten by a
346 * regex, and a writer that tries anyway can silently disable another
347 * plugin's cache (or its own).
348 *
349 * state is one of: undefined | true | false | duplicate | dynamic |
350 * unreadable.
351 */
352 private static function inspect_wp_cache(): array {
353 $runtime = defined( 'WP_CACHE' ) ? (bool) constant( 'WP_CACHE' ) : null;
354 $path = Cache::wp_config_path();
355 $blank = array(
356 'path' => $path,
357 'readable' => false,
358 'state' => 'unreadable',
359 'runtime' => $runtime,
360 'defines' => 0,
361 'hash' => null,
362 );
363
364 if ( '' === $path ) {
365 return $blank;
366 }
367
368 $config = self::read( $path );
369 if ( null === $config ) {
370 return $blank;
371 }
372
373 /*
374 * One parser, shared with the writer.
375 *
376 * The detector used to carry its own token scan. Two scans of the same
377 * grammar drift, and the pair that must never disagree is exactly this
378 * one: the reader decides whether a rewrite is safe and the writer
379 * performs it. xspeed_parse_wp_cache_defines() is a plain function in
380 * its own file so both can reach it before either class loads.
381 */
382 if ( ! function_exists( 'xspeed_parse_wp_cache_defines' ) ) {
383 require_once __DIR__ . '/wp-cache-constant.php';
384 }
385 $parsed = xspeed_parse_wp_cache_defines( $config );
386
387 return array(
388 'path' => $path,
389 'readable' => true,
390 'state' => (string) $parsed['state'],
391 'runtime' => $runtime,
392 'defines' => count( $parsed['defines'] ),
393 'hash' => hash( 'sha256', $config ),
394 );
395 }
396
397 /**
398 * Is it safe to install, activate, or promote a page cache right now?
399 *
400 * The one call most callers need. True only when nothing owns the page
401 * cache and nothing about the site's state is unreadable or ambiguous.
402 * `foreign-residual` passes because the artifacts left behind are inert —
403 * no drop-in, no active plugin — and refusing there would strand every
404 * site that ever tried another cache plugin.
405 */
406 public static function is_field_clear(): bool {
407 $verdict = self::classify();
408
409 if ( ! empty( $verdict['blockers'] ) ) {
410 return false;
411 }
412
413 return in_array(
414 $verdict['state'],
415 array( self::STATE_UNCLAIMED, self::STATE_FOREIGN_RESIDUAL ),
416 true
417 );
418 }
419
420 /**
421 * Labels of every ACTIVE plugin that can write a page cache.
422 *
423 * For a screen that wants to say what it found rather than only that it
424 * found something.
425 *
426 * @return string[]
427 */
428 public static function active_page_caches(): array {
429 $out = array();
430 foreach ( self::inspect()['plugins'] as $plugin ) {
431 if ( $plugin['page_cache'] && $plugin['active'] ) {
432 $out[] = (string) $plugin['label'];
433 }
434 }
435 return $out;
436 }
437
438 /**
439 * Who owns wp-content/advanced-cache.php, as a plugin label, or null when
440 * nobody does — or when we cannot tell.
441 */
442 public static function dropin_owner_label(): ?string {
443 $dropin = self::inspect()['dropin'];
444 return is_string( $dropin['label'] ) ? $dropin['label'] : null;
445 }
446
447 /**
448 * Classify the report into one ownership state plus the reasons behind it.
449 *
450 * Blockers and notes are CODES with the evidence attached, never rendered
451 * sentences — see the BLOCKER_* constants. Cache::ownership_blocker_message()
452 * is what turns one into words.
453 *
454 * @param array|null $report Report from inspect(); re-inspected when null.
455 * @return array{state:string,blockers:array<int,array>,notes:array<int,array>,revision:string}
456 */
457 public static function classify( ?array $report = null ): array {
458 $report = $report ?? self::inspect();
459 $blockers = array();
460 $notes = array();
461
462 $dropin = $report['dropin'];
463 $object_dropin = $report['object_dropin'];
464 $wp_cache = $report['wp_cache'];
465
466 if ( $object_dropin['exists'] ) {
467 // Informational only. A persistent object cache sits BESIDE a page
468 // cache; it competes for nothing and must never block.
469 $notes[] = array(
470 'code' => self::NOTE_OBJECT_CACHE_PRESENT,
471 'plugin' => $object_dropin['plugin'],
472 'label' => $object_dropin['label'],
473 );
474 }
475
476 foreach ( self::residual_plugins( $report['plugins'] ) as $plugin ) {
477 $notes[] = array(
478 'code' => self::NOTE_RESIDUAL_CACHE_FILES,
479 'plugin' => $plugin['plugin'],
480 'label' => $plugin['label'],
481 );
482 }
483
484 /*
485 * Everything that owns, or could own, the page cache — keyed by PLUGIN
486 * FILE so one plugin counts once. A live competitor is normally both
487 * active and the drop-in's owner; counting those as two put the
488 * ordinary single-competitor site in `contested` and told the user
489 * "more than one page-caching plugin is in play" about one plugin.
490 */
491 $owners = array();
492 $active = array();
493 foreach ( $report['plugins'] as $plugin ) {
494 if ( $plugin['page_cache'] && $plugin['active'] ) {
495 $active[ (string) $plugin['plugin'] ] = $plugin;
496 $owners[ (string) $plugin['plugin'] ] = (string) $plugin['label'];
497 }
498 }
499 if ( self::OWNER_FOREIGN === $dropin['owner'] ) {
500 $key = (string) ( $dropin['plugin'] ?? $dropin['label'] );
501 $owners[ $key ] = (string) $dropin['label'];
502 $blockers[] = array(
503 'code' => self::BLOCKER_FOREIGN_DROPIN,
504 'plugin' => $dropin['plugin'],
505 'label' => $dropin['label'],
506 );
507 } elseif ( self::OWNER_UNKNOWN === $dropin['owner'] ) {
508 $blockers[] = array(
509 'code' => $dropin['readable'] ? self::BLOCKER_UNKNOWN_DROPIN : self::BLOCKER_UNREADABLE_DROPIN,
510 'plugin' => null,
511 'label' => null,
512 );
513 }
514
515 $wp_cache_blocker = array(
516 'unreadable' => self::BLOCKER_WP_CONFIG_UNREADABLE,
517 'duplicate' => self::BLOCKER_WP_CACHE_DUPLICATE,
518 'dynamic' => self::BLOCKER_WP_CACHE_DYNAMIC,
519 'conditional' => self::BLOCKER_WP_CACHE_CONDITIONAL,
520 );
521 if ( isset( $wp_cache_blocker[ $wp_cache['state'] ] ) ) {
522 $blockers[] = array(
523 'code' => $wp_cache_blocker[ $wp_cache['state'] ],
524 'plugin' => null,
525 'label' => null,
526 );
527 }
528
529 // Order matters: the most specific unsafe state wins.
530 if ( 'unreadable' === $wp_cache['state'] || ! $dropin['readable'] ) {
531 return self::verdict( self::STATE_UNAVAILABLE, $blockers, $notes, $report );
532 }
533
534 if ( count( $owners ) > 1 || ( self::OWNER_XSPEED === $dropin['owner'] && ! empty( $active ) ) ) {
535 /*
536 * `plugin` and `label` stay null — "multiple" has no single owner
537 * to name. `plugins` and `labels` carry every owner found, in the
538 * same key order, so a consumer can subtract ITSELF and name what
539 * is left. Without them every contested site read the same
540 * anonymous sentence.
541 */
542 $blockers[] = array(
543 'code' => self::BLOCKER_MULTIPLE_PAGE_CACHES,
544 'plugin' => null,
545 'label' => null,
546 'plugins' => array_keys( $owners ),
547 'labels' => array_values( $owners ),
548 );
549 return self::verdict( self::STATE_CONTESTED, $blockers, $notes, $report );
550 }
551
552 if ( self::OWNER_UNKNOWN === $dropin['owner'] ) {
553 return self::verdict( self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report );
554 }
555
556 if ( self::OWNER_FOREIGN === $dropin['owner'] ) {
557 return self::verdict( self::STATE_FOREIGN_LIVE, $blockers, $notes, $report );
558 }
559
560 if ( self::OWNER_XSPEED === $dropin['owner'] ) {
561 return self::verdict( self::STATE_XSPEED_OWNED, $blockers, $notes, $report );
562 }
563
564 if ( ! empty( $active ) ) {
565 $first = reset( $active );
566 $blockers[] = array(
567 'code' => self::BLOCKER_ACTIVE_PAGE_CACHE,
568 'plugin' => $first['plugin'],
569 'label' => $first['label'],
570 );
571 return self::verdict( self::STATE_POSSIBLE_LIVE, $blockers, $notes, $report );
572 }
573
574 /*
575 * A WP_CACHE we cannot rewrite is not a clear field. `duplicate`,
576 * `dynamic` and `conditional` already added a blocker above, but the
577 * ladder used to fall past them to `unclaimed` — a state documented as
578 * "field clear: yes". is_field_clear() was safe either way because it
579 * checks blockers first, but a caller branching on the STATE read a
580 * doubly-defined or expression-valued config as a clean site.
581 */
582 if ( in_array( $wp_cache['state'], array( 'duplicate', 'dynamic', 'conditional' ), true ) ) {
583 return self::verdict( self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report );
584 }
585
586 /*
587 * No drop-in and nothing active, but WP_CACHE is true — something
588 * enabled page caching and we cannot say what. Review, not "clear".
589 */
590 if ( 'true' === $wp_cache['state'] ) {
591 $blockers[] = array(
592 'code' => self::BLOCKER_WP_CACHE_ORPHANED,
593 'plugin' => null,
594 'label' => null,
595 );
596 return self::verdict( self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report );
597 }
598
599 foreach ( $notes as $note ) {
600 if ( self::NOTE_RESIDUAL_CACHE_FILES === $note['code'] ) {
601 // Residual foreign artifacts with nothing live: safe to
602 // proceed, worth saying out loud.
603 return self::verdict( self::STATE_FOREIGN_RESIDUAL, $blockers, $notes, $report );
604 }
605 }
606
607 return self::verdict( self::STATE_UNCLAIMED, $blockers, $notes, $report );
608 }
609
610 /**
611 * Inactive page-cache plugins whose artifacts are their OWN.
612 *
613 * Builds that share a signal set — Swift Performance Lite and the
614 * commercial build share their constants, options row and cache dir —
615 * would otherwise each be reported as having "left cache files behind",
616 * including the one that was never on this site. Blaming an active
617 * sibling was already handled; a deactivated Lite with the commercial
618 * build absent still produced two notes, because nothing was active to
619 * explain the signals away (PR #295 review).
620 *
621 * So a signal is credited to a plugin only when no plugin with stronger
622 * standing also carries it — active over installed-but-inactive over not
623 * on disk at all — and only a page-cache plugin can explain a page-cache
624 * artifact. Two absent twins sharing every signal cannot be told apart
625 * and are both reported; that is the honest answer.
626 *
627 * @param array<int,array> $plugins Rows from inspect_plugins().
628 * @return array<int,array> The rows that earn a residual note.
629 */
630 private static function residual_plugins( array $plugins ): array {
631 $standing = static function ( array $plugin ): int {
632 if ( $plugin['active'] ) {
633 return 2;
634 }
635 return ! empty( $plugin['installed'] ) ? 1 : 0;
636 };
637
638 $out = array();
639 foreach ( $plugins as $plugin ) {
640 if ( ! $plugin['page_cache'] || $plugin['active'] || empty( $plugin['signals'] ) ) {
641 continue;
642 }
643
644 $explained = array();
645 foreach ( $plugins as $other ) {
646 if ( ! $other['page_cache'] || $other['plugin'] === $plugin['plugin'] || $standing( $other ) <= $standing( $plugin ) ) {
647 continue;
648 }
649 $explained = array_merge( $explained, (array) $other['signals'] );
650 }
651
652 if ( array_diff( (array) $plugin['signals'], $explained ) ) {
653 $out[] = $plugin;
654 }
655 }
656
657 return $out;
658 }
659
660 private static function verdict( string $state, array $blockers, array $notes, array $report ): array {
661 return array(
662 'state' => $state,
663 'blockers' => self::unique_entries( $blockers ),
664 'notes' => self::unique_entries( $notes ),
665 'revision' => (string) ( $report['revision'] ?? '' ),
666 );
667 }
668
669 /**
670 * Deduplicate blocker/note entries by their whole shape.
671 *
672 * array_unique() compares string casts, which is a notice-and-nonsense
673 * combination for arrays, and SORT_REGULAR compares loosely enough to
674 * collapse findings about two different plugins. Encoding the entry is the
675 * only comparison that means "the same finding about the same plugin".
676 *
677 * @param array<int,array> $entries
678 * @return array<int,array>
679 */
680 private static function unique_entries( array $entries ): array {
681 $seen = array();
682 $out = array();
683 foreach ( $entries as $entry ) {
684 $key = (string) wp_json_encode( $entry );
685 if ( isset( $seen[ $key ] ) ) {
686 continue;
687 }
688 $seen[ $key ] = true;
689 $out[] = $entry;
690 }
691 return $out;
692 }
693
694 /**
695 * May xSpeed install its page-cache artifacts right now?
696 *
697 * True only for states where nothing else owns, or could own, the page
698 * cache. `foreign-residual` passes because the artifacts are inert — no
699 * drop-in, no active plugin — and refusing there would strand every site
700 * that ever tried another cache plugin.
701 */
702 public static function can_acquire( ?array $verdict = null ): bool {
703 $verdict = $verdict ?? self::classify();
704
705 /*
706 * Both conditions, not either. A blocker can be raised in an otherwise
707 * clear state — a duplicate or expression-valued WP_CACHE define is
708 * nobody's page cache, but it is also not something a regex may
709 * rewrite, so the field being empty does not make the write safe.
710 */
711 if ( ! empty( $verdict['blockers'] ) ) {
712 return false;
713 }
714
715 return in_array(
716 $verdict['state'],
717 array( self::STATE_UNCLAIMED, self::STATE_XSPEED_OWNED, self::STATE_FOREIGN_RESIDUAL ),
718 true
719 );
720 }
721
722 /**
723 * Fingerprint of the evidence, so a writer can prove nothing moved
724 * between inspection and the write.
725 */
726 public static function revision_of( array $report ): string {
727 $material = $report;
728 unset( $material['revision'] );
729 return hash( 'sha256', (string) wp_json_encode( $material ) );
730 }
731
732 /** Exact xSpeed banner line, never a loose substring in code or data. */
733 private static function has_xspeed_signature( string $contents ): bool {
734 foreach ( token_get_all( $contents ) as $token ) {
735 if ( ! is_array( $token ) || ! in_array( $token[0], array( T_COMMENT, T_DOC_COMMENT ), true ) ) {
736 continue;
737 }
738 if ( preg_match( '/^(?:\\s*[\\/#*]+\\s*)XSPEED_DROPIN\\s*$/mi', $token[1] ) ) {
739 return true;
740 }
741 }
742 return false;
743 }
744
745 /**
746 * Read a file for inspection. Returns null on any failure — callers treat
747 * null as "unknown", never as "empty".
748 */
749 private static function read( string $path ): ?string {
750 if ( ! is_readable( $path ) ) {
751 return null;
752 }
753 // 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.
754 $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.
755 return is_string( $contents ) ? $contents : null;
756 }
757
758 /**
759 * Drop the memoized report. Anything that changes plugin state or writes
760 * a drop-in must call this.
761 */
762 public static function invalidate(): void {
763 self::$report = null;
764 Cache_Plugin_Catalog::invalidate();
765 }
766
767 /**
768 * Bootstrap hooks. Call once from Plugin::init().
769 */
770 public static function boot(): void {
771 add_action( 'activated_plugin', array( __CLASS__, 'invalidate' ) );
772 add_action( 'deactivated_plugin', array( __CLASS__, 'invalidate' ) );
773 }
774 }
775