class-page-cache-safety.php
1099 lines
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * Page Cache Safety — a portable, read-only answer to "does this WordPress |
| 5 | * site already have a page cache, and whose is it?" |
| 6 | * |
| 7 | * Copy this ONE file into any WPDeveloper plugin that needs to decide whether |
| 8 | * to offer, install, or activate a caching plugin. It depends on WordPress and |
| 9 | * nothing else — no composer package, no xSpeed, no autoloader. That is the |
| 10 | * point: the decision has to be answerable on a site where xSpeed is not |
| 11 | * installed yet. |
| 12 | * |
| 13 | * Usage: |
| 14 | * |
| 15 | * require_once __DIR__ . '/page-cache-safety/class-page-cache-safety.php'; |
| 16 | * |
| 17 | * if ( \WPDeveloper\PageCacheSafety\Detector::is_field_clear() ) { |
| 18 | * // Nothing owns the page cache. Safe to promote / install / activate. |
| 19 | * } else { |
| 20 | * $verdict = \WPDeveloper\PageCacheSafety\Detector::classify(); |
| 21 | * // $verdict['state'] — one of the STATE_* constants |
| 22 | * // $verdict['blockers'] — [ ['code' => …, 'plugin' => …, 'label' => …], … ] |
| 23 | * } |
| 24 | * |
| 25 | * Blockers carry message CODES, not sentences: the host plugin owns its own |
| 26 | * textdomain, so it renders the wording. See BLOCKER_* below. |
| 27 | * |
| 28 | * What this file will never do: write anything, include or execute another |
| 29 | * plugin's PHP, or treat a loose substring like "cache" as proof of ownership. |
| 30 | * An unreadable or unattributable artifact is reported as a blocker, never as |
| 31 | * a pass — "we could not tell" and "the field is clear" are different answers |
| 32 | * and only one of them licenses an install. |
| 33 | * |
| 34 | * Source of truth: the xSpeed Free repo, page-cache-safety/. It is deliberately |
| 35 | * NOT loaded by xSpeed and NOT shipped in its zip — it exists to be copied. |
| 36 | * Improve it there and re-copy; do not fork it per plugin. |
| 37 | * |
| 38 | * @package WPDeveloper\PageCacheSafety |
| 39 | * @version 1.4.1 |
| 40 | */ |
| 41 | |
| 42 | namespace WPDeveloper\PageCacheSafety; |
| 43 | |
| 44 | defined('ABSPATH') || exit; |
| 45 | |
| 46 | /* |
| 47 | * Two plugins on the same site can each carry a copy of this file. First one |
| 48 | * loaded wins, which is fine — the classes are pure functions over site state, |
| 49 | * so any version answers the same question. VERSION is here so a host that |
| 50 | * cares can log which copy it got. |
| 51 | */ |
| 52 | if (! class_exists(__NAMESPACE__ . '\\Detector')) { |
| 53 | |
| 54 | final class Detector |
| 55 | { |
| 56 | |
| 57 | public const VERSION = '1.4.1'; |
| 58 | |
| 59 | /* Ownership states. */ |
| 60 | |
| 61 | /** Nothing owns the page cache. */ |
| 62 | public const STATE_UNCLAIMED = 'unclaimed'; |
| 63 | /** A page cache we can name is installed and serving. */ |
| 64 | public const STATE_FOREIGN_LIVE = 'foreign-live'; |
| 65 | /** Cache files remain but nothing is wired up to serve them. */ |
| 66 | public const STATE_FOREIGN_RESIDUAL = 'foreign-residual'; |
| 67 | /** 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. */ |
| 68 | public const STATE_POSSIBLE_LIVE = 'possible-live'; |
| 69 | /** More than one page cache is in play. */ |
| 70 | public const STATE_CONTESTED = 'contested'; |
| 71 | /** A drop-in, or a live WP_CACHE, that we cannot attribute to anyone. */ |
| 72 | public const STATE_UNKNOWN_OCCUPIED = 'unknown-occupied'; |
| 73 | /** We could not read what we needed to decide. */ |
| 74 | public const STATE_UNAVAILABLE = 'unavailable'; |
| 75 | |
| 76 | /* Blocker codes. The host plugin supplies the wording. */ |
| 77 | |
| 78 | /** A named plugin owns wp-content/advanced-cache.php. `plugin` + `label` are set. */ |
| 79 | public const BLOCKER_FOREIGN_DROPIN = 'foreign_dropin'; |
| 80 | /** A drop-in is installed that we cannot attribute. */ |
| 81 | public const BLOCKER_UNKNOWN_DROPIN = 'unknown_dropin'; |
| 82 | /** A drop-in is installed and could not be read. */ |
| 83 | public const BLOCKER_UNREADABLE_DROPIN = 'unreadable_dropin'; |
| 84 | /** A page-cache-capable plugin is active; its cache may or may not be on. `plugin` + `label` are set. */ |
| 85 | public const BLOCKER_ACTIVE_PAGE_CACHE = 'active_page_cache'; |
| 86 | /** Two or more page caches. */ |
| 87 | public const BLOCKER_MULTIPLE_PAGE_CACHES = 'multiple_page_caches'; |
| 88 | /** WP_CACHE is true but no drop-in owner was found. */ |
| 89 | public const BLOCKER_WP_CACHE_ORPHANED = 'wp_cache_orphaned'; |
| 90 | /** wp-config.php defines WP_CACHE more than once. */ |
| 91 | public const BLOCKER_WP_CACHE_DUPLICATE = 'wp_cache_duplicate'; |
| 92 | /** WP_CACHE is set from an expression, so it cannot be rewritten safely. */ |
| 93 | public const BLOCKER_WP_CACHE_DYNAMIC = 'wp_cache_dynamic'; |
| 94 | /** wp-config.php could not be read. */ |
| 95 | public const BLOCKER_WP_CONFIG_UNREADABLE = 'wp_config_unreadable'; |
| 96 | |
| 97 | /* Drop-in owner classifications. */ |
| 98 | |
| 99 | public const OWNER_NONE = 'none'; |
| 100 | public const OWNER_KNOWN = 'known'; |
| 101 | public const OWNER_UNKNOWN = 'unknown'; |
| 102 | |
| 103 | /** Capability keys. */ |
| 104 | public const CAP_PAGE_CACHE = 'page-cache'; |
| 105 | public const CAP_OBJECT_CACHE = 'object-cache'; |
| 106 | public const CAP_MINIFY = 'minify'; |
| 107 | |
| 108 | /** |
| 109 | * @var array|null Memoized report for this request. |
| 110 | */ |
| 111 | private static $report = null; |
| 112 | |
| 113 | /** |
| 114 | * Is it safe to promote, install, or activate a page-caching plugin? |
| 115 | * |
| 116 | * The one call most hosts need. True only when nothing owns the page |
| 117 | * cache and nothing about the site's state is unreadable or ambiguous. |
| 118 | */ |
| 119 | public static function is_field_clear(): bool |
| 120 | { |
| 121 | $verdict = self::classify(); |
| 122 | |
| 123 | if (! empty($verdict['blockers'])) { |
| 124 | return false; |
| 125 | } |
| 126 | |
| 127 | return in_array( |
| 128 | $verdict['state'], |
| 129 | array(self::STATE_UNCLAIMED, self::STATE_FOREIGN_RESIDUAL), |
| 130 | true |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * Labels of every ACTIVE plugin that can write a page cache. Handy for |
| 136 | * a promo screen that wants to say what it found. |
| 137 | * |
| 138 | * @return string[] |
| 139 | */ |
| 140 | public static function active_page_caches(): array |
| 141 | { |
| 142 | $out = array(); |
| 143 | foreach (self::inspect()['plugins'] as $plugin) { |
| 144 | if ($plugin['page_cache'] && $plugin['active']) { |
| 145 | $out[] = $plugin['label']; |
| 146 | } |
| 147 | } |
| 148 | return $out; |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Who owns wp-content/advanced-cache.php, as a plugin label, or null |
| 153 | * when nobody does (or we cannot tell). |
| 154 | */ |
| 155 | public static function dropin_owner_label(): ?string |
| 156 | { |
| 157 | $dropin = self::inspect()['dropin']; |
| 158 | return is_string($dropin['label']) ? $dropin['label'] : null; |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * The verdict: one state plus the reasons behind it. |
| 163 | * |
| 164 | * @return array{state:string,blockers:array<int,array>,notes:array<int,array>,revision:string} |
| 165 | */ |
| 166 | public static function classify(): array |
| 167 | { |
| 168 | $report = self::inspect(); |
| 169 | $dropin = $report['dropin']; |
| 170 | $wp_cache = $report['wp_cache']; |
| 171 | $blockers = array(); |
| 172 | $notes = array(); |
| 173 | |
| 174 | if ($report['object_dropin']['exists']) { |
| 175 | // Informational only. A persistent object cache sits BESIDE a |
| 176 | // page cache; it competes for nothing and must never block. |
| 177 | $notes[] = array( |
| 178 | 'code' => 'object_cache_present', |
| 179 | 'plugin' => $report['object_dropin']['plugin'], |
| 180 | 'label' => $report['object_dropin']['label'], |
| 181 | ); |
| 182 | } |
| 183 | |
| 184 | $active_page_caches = array(); |
| 185 | foreach ($report['plugins'] as $plugin) { |
| 186 | if ($plugin['page_cache'] && $plugin['active']) { |
| 187 | $active_page_caches[] = $plugin; |
| 188 | } |
| 189 | } |
| 190 | foreach (self::residual_plugins($report['plugins']) as $plugin) { |
| 191 | $notes[] = array( |
| 192 | 'code' => 'residual_cache_files', |
| 193 | 'plugin' => $plugin['plugin'], |
| 194 | 'label' => $plugin['label'], |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | if (self::OWNER_KNOWN === $dropin['owner']) { |
| 199 | $blockers[] = array( |
| 200 | 'code' => self::BLOCKER_FOREIGN_DROPIN, |
| 201 | 'plugin' => $dropin['plugin'], |
| 202 | 'label' => $dropin['label'], |
| 203 | ); |
| 204 | } elseif (self::OWNER_UNKNOWN === $dropin['owner']) { |
| 205 | $blockers[] = array( |
| 206 | 'code' => $dropin['readable'] ? self::BLOCKER_UNKNOWN_DROPIN : self::BLOCKER_UNREADABLE_DROPIN, |
| 207 | 'plugin' => null, |
| 208 | 'label' => null, |
| 209 | ); |
| 210 | } |
| 211 | |
| 212 | if ('unreadable' === $wp_cache['state']) { |
| 213 | $blockers[] = array( |
| 214 | 'code' => self::BLOCKER_WP_CONFIG_UNREADABLE, |
| 215 | 'plugin' => null, |
| 216 | 'label' => null, |
| 217 | ); |
| 218 | } elseif ('duplicate' === $wp_cache['state']) { |
| 219 | $blockers[] = array( |
| 220 | 'code' => self::BLOCKER_WP_CACHE_DUPLICATE, |
| 221 | 'plugin' => null, |
| 222 | 'label' => null, |
| 223 | ); |
| 224 | } elseif ('dynamic' === $wp_cache['state']) { |
| 225 | $blockers[] = array( |
| 226 | 'code' => self::BLOCKER_WP_CACHE_DYNAMIC, |
| 227 | 'plugin' => null, |
| 228 | 'label' => null, |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | // Most specific unsafe state wins. |
| 233 | if ('unreadable' === $wp_cache['state'] || ! $dropin['readable']) { |
| 234 | return self::verdict(self::STATE_UNAVAILABLE, $blockers, $notes, $report); |
| 235 | } |
| 236 | |
| 237 | /* |
| 238 | * Keyed by plugin file so one plugin counts once. A live |
| 239 | * competitor is normally both active and the drop-in's owner; |
| 240 | * counting those separately put the ordinary single-competitor |
| 241 | * site in `contested` with a "more than one page cache" blocker. |
| 242 | */ |
| 243 | $owners = array(); |
| 244 | foreach ($active_page_caches as $active) { |
| 245 | $owners[(string) $active['plugin']] = true; |
| 246 | } |
| 247 | if (self::OWNER_KNOWN === $dropin['owner']) { |
| 248 | $owners[(string) ($dropin['plugin'] ?? $dropin['label'])] = true; |
| 249 | } |
| 250 | if (count($owners) > 1) { |
| 251 | $blockers[] = array( |
| 252 | 'code' => self::BLOCKER_MULTIPLE_PAGE_CACHES, |
| 253 | 'plugin' => null, |
| 254 | 'label' => null, |
| 255 | ); |
| 256 | return self::verdict(self::STATE_CONTESTED, $blockers, $notes, $report); |
| 257 | } |
| 258 | |
| 259 | if (self::OWNER_UNKNOWN === $dropin['owner']) { |
| 260 | return self::verdict(self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report); |
| 261 | } |
| 262 | if (self::OWNER_KNOWN === $dropin['owner']) { |
| 263 | return self::verdict(self::STATE_FOREIGN_LIVE, $blockers, $notes, $report); |
| 264 | } |
| 265 | |
| 266 | if (! empty($active_page_caches)) { |
| 267 | $blockers[] = array( |
| 268 | 'code' => self::BLOCKER_ACTIVE_PAGE_CACHE, |
| 269 | 'plugin' => $active_page_caches[0]['plugin'], |
| 270 | 'label' => $active_page_caches[0]['label'], |
| 271 | ); |
| 272 | return self::verdict(self::STATE_POSSIBLE_LIVE, $blockers, $notes, $report); |
| 273 | } |
| 274 | |
| 275 | /* |
| 276 | * No drop-in, nothing active, but WP_CACHE is on. Something |
| 277 | * enabled page caching and we cannot say what. Review, not clear. |
| 278 | */ |
| 279 | /* |
| 280 | * A WP_CACHE we cannot rewrite is not a clear field. `duplicate` and |
| 281 | * `dynamic` already add a blocker above, but the ladder used to fall |
| 282 | * past them to `unclaimed` — a state the README documents as "field |
| 283 | * clear: yes". is_field_clear() was safe (it checks blockers first), |
| 284 | * but any host branching on the STATE, as the README invites, read a |
| 285 | * doubly-defined or expression-valued config as a clean site. |
| 286 | */ |
| 287 | if (in_array($wp_cache['state'], array('duplicate', 'dynamic'), true)) { |
| 288 | return self::verdict(self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report); |
| 289 | } |
| 290 | |
| 291 | if ('true' === $wp_cache['state']) { |
| 292 | $blockers[] = array( |
| 293 | 'code' => self::BLOCKER_WP_CACHE_ORPHANED, |
| 294 | 'plugin' => null, |
| 295 | 'label' => null, |
| 296 | ); |
| 297 | return self::verdict(self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report); |
| 298 | } |
| 299 | |
| 300 | foreach ($notes as $note) { |
| 301 | if ('residual_cache_files' === $note['code']) { |
| 302 | return self::verdict(self::STATE_FOREIGN_RESIDUAL, $blockers, $notes, $report); |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | return self::verdict(self::STATE_UNCLAIMED, $blockers, $notes, $report); |
| 307 | } |
| 308 | |
| 309 | /** |
| 310 | * Inactive page-cache plugins whose artifacts are their OWN. |
| 311 | * |
| 312 | * Builds that share a signal set (Swift Performance Lite and the |
| 313 | * commercial build share their constants, options row and cache dir) |
| 314 | * would otherwise each be reported as having left cache files behind, |
| 315 | * including the one that was never on this site. A signal is credited |
| 316 | * to a plugin only when no plugin of stronger standing also carries |
| 317 | * it (active over installed-but-inactive over not on disk), and only a |
| 318 | * page-cache plugin can explain a page-cache artifact. Two absent |
| 319 | * twins cannot be told apart and are both reported. |
| 320 | * |
| 321 | * Mirrors the same helper in xSpeed's own detector. |
| 322 | * |
| 323 | * @param array<int,array> $plugins Rows from inspect_plugins(). |
| 324 | * @return array<int,array> |
| 325 | */ |
| 326 | private static function residual_plugins(array $plugins): array |
| 327 | { |
| 328 | $standing = static function (array $plugin): int { |
| 329 | if ($plugin['active']) { |
| 330 | return 2; |
| 331 | } |
| 332 | return ! empty($plugin['installed']) ? 1 : 0; |
| 333 | }; |
| 334 | |
| 335 | $out = array(); |
| 336 | foreach ($plugins as $plugin) { |
| 337 | if (! $plugin['page_cache'] || $plugin['active'] || empty($plugin['signals'])) { |
| 338 | continue; |
| 339 | } |
| 340 | |
| 341 | $explained = array(); |
| 342 | foreach ($plugins as $other) { |
| 343 | if (! $other['page_cache'] || $other['plugin'] === $plugin['plugin'] || $standing($other) <= $standing($plugin)) { |
| 344 | continue; |
| 345 | } |
| 346 | $explained = array_merge($explained, (array) $other['signals']); |
| 347 | } |
| 348 | |
| 349 | if (array_diff((array) $plugin['signals'], $explained)) { |
| 350 | $out[] = $plugin; |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | return $out; |
| 355 | } |
| 356 | |
| 357 | /** |
| 358 | * The raw evidence, for a host that wants to render its own summary. |
| 359 | * |
| 360 | * @return array{scope:string,multisite:bool,plugins:array<int,array>,dropin:array,object_dropin:array,wp_cache:array,revision:string} |
| 361 | */ |
| 362 | public static function inspect(bool $fresh = true): array |
| 363 | { |
| 364 | if (! $fresh && null !== self::$report) { |
| 365 | return self::$report; |
| 366 | } |
| 367 | |
| 368 | $multisite = function_exists('is_multisite') && is_multisite(); |
| 369 | $report = array( |
| 370 | 'scope' => $multisite ? 'site-and-network' : 'site', |
| 371 | 'multisite' => $multisite, |
| 372 | 'plugins' => self::inspect_plugins(), |
| 373 | 'dropin' => self::inspect_dropin(), |
| 374 | 'object_dropin' => self::inspect_object_dropin(), |
| 375 | 'wp_cache' => self::inspect_wp_cache(), |
| 376 | ); |
| 377 | |
| 378 | // Fingerprint of every fact the verdict rests on. A host that acts |
| 379 | // on a verdict can re-inspect immediately before acting and bail |
| 380 | // if this changed. |
| 381 | $report['revision'] = hash('sha256', (string) wp_json_encode($report)); |
| 382 | |
| 383 | self::$report = $report; |
| 384 | return $report; |
| 385 | } |
| 386 | |
| 387 | /** Mandatory fresh evidence path for callers that may write afterwards. */ |
| 388 | public static function inspect_fresh(): array |
| 389 | { |
| 390 | return self::inspect(true); |
| 391 | } |
| 392 | |
| 393 | /** Drop the memoized report — call after activating or deactivating a plugin. */ |
| 394 | public static function invalidate(): void |
| 395 | { |
| 396 | self::$report = null; |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * Every catalogued plugin, keyed by its main file path as it appears |
| 401 | * in `active_plugins`. Folder-only checks would false-positive on a |
| 402 | * plugin merely present on disk. |
| 403 | * |
| 404 | * Filterable so a host can describe a plugin this copy predates. |
| 405 | * |
| 406 | * @return array<string,array> |
| 407 | */ |
| 408 | public static function catalog(): array |
| 409 | { |
| 410 | $catalog = array( |
| 411 | /* |
| 412 | * xSpeed itself. The question this file answers is "does this |
| 413 | * site already have a page cache, and whose is it?" — and xSpeed |
| 414 | * is one. Leaving it out did not change any host's decision, but |
| 415 | * a site running xSpeed reported an unattributable drop-in, so a |
| 416 | * host rendering the reason described the plugin it installed |
| 417 | * itself as an unidentified page cache. |
| 418 | */ |
| 419 | 'xspeed/xspeed.php' => array( |
| 420 | 'label' => 'xSpeed Cache', |
| 421 | 'capabilities' => array(self::CAP_PAGE_CACHE), |
| 422 | // Drop-in token only, deliberately: no constants, options or |
| 423 | // paths. Those are residual-evidence signals, and xSpeed's are |
| 424 | // present on any site running it — including, awkwardly, this |
| 425 | // file's own test suite — which would report a site as holding |
| 426 | // leftovers from the very plugin asking the question. Naming |
| 427 | // the drop-in owner is all that was missing; an ACTIVE xSpeed |
| 428 | // is already caught by is_plugin_active() on the key above. |
| 429 | 'dropin' => array('XSPEED_DROPIN'), |
| 430 | ), |
| 431 | 'wp-rocket/wp-rocket.php' => array( |
| 432 | 'label' => 'WP Rocket', |
| 433 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 434 | 'constants' => array('WP_ROCKET_VERSION'), |
| 435 | 'paths' => array('cache/wp-rocket'), |
| 436 | 'dropin' => array('WP Rocket', 'WP_ROCKET'), |
| 437 | ), |
| 438 | 'litespeed-cache/litespeed-cache.php' => array( |
| 439 | 'label' => 'LiteSpeed Cache', |
| 440 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_OBJECT_CACHE, self::CAP_MINIFY), |
| 441 | 'constants' => array('LSCWP_V', 'LSCWP_DIR'), |
| 442 | 'options' => array('litespeed.conf.cache'), |
| 443 | 'paths' => array('litespeed', 'cache/litespeed'), |
| 444 | 'dropin' => array('LiteSpeed_Cache', 'LSCWP'), |
| 445 | ), |
| 446 | 'w3-total-cache/w3-total-cache.php' => array( |
| 447 | 'label' => 'W3 Total Cache', |
| 448 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_OBJECT_CACHE, self::CAP_MINIFY), |
| 449 | 'constants' => array('W3TC_DIR', 'W3TC_VERSION'), |
| 450 | 'paths' => array('w3tc-config/master.php', 'cache/page_enhanced'), |
| 451 | 'dropin' => array('W3TC', 'w3-total-cache'), |
| 452 | ), |
| 453 | 'wp-super-cache/wp-cache.php' => array( |
| 454 | 'label' => 'WP Super Cache', |
| 455 | 'capabilities' => array(self::CAP_PAGE_CACHE), |
| 456 | 'constants' => array('WPCACHEHOME'), |
| 457 | 'paths' => array('wp-cache-config.php', 'cache/supercache'), |
| 458 | 'dropin' => array('WP SUPER CACHE', 'wp-cache-phase1'), |
| 459 | ), |
| 460 | 'wp-fastest-cache/wpFastestCache.php' => array( |
| 461 | 'label' => 'WP Fastest Cache', |
| 462 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 463 | 'classes' => array('WpFastestCache'), |
| 464 | 'options' => array('WpFastestCache'), |
| 465 | 'paths' => array('cache/all', 'cache/wpfc-minified'), |
| 466 | 'dropin' => array('WpFastestCache', 'wpFastestCache'), |
| 467 | ), |
| 468 | 'swift-performance-lite/performance.php' => array( |
| 469 | // Both builds share drop-in markers, constants and options |
| 470 | // row, and identify() returns the first match — so a |
| 471 | // commercial install was reported as "Lite". Neither entry |
| 472 | // claims an edition it cannot tell apart. |
| 473 | 'label' => 'Swift Performance', |
| 474 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 475 | 'constants' => array('SWIFT_PERFORMANCE_VER', 'SWIFT_PERFORMANCE_DIR'), |
| 476 | 'options' => array('swift_performance_options'), |
| 477 | 'paths' => array('cache/swift-performance'), |
| 478 | 'dropin' => array('Swift Performance', 'swift_performance'), |
| 479 | ), |
| 480 | 'swift-performance/performance.php' => array( |
| 481 | 'label' => 'Swift Performance', |
| 482 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 483 | 'constants' => array('SWIFT_PERFORMANCE_VER', 'SWIFT_PERFORMANCE_DIR'), |
| 484 | 'options' => array('swift_performance_options'), |
| 485 | 'paths' => array('cache/swift-performance'), |
| 486 | 'dropin' => array('Swift Performance', 'swift_performance'), |
| 487 | ), |
| 488 | 'cache-enabler/cache-enabler.php' => array( |
| 489 | 'label' => 'Cache Enabler', |
| 490 | 'capabilities' => array(self::CAP_PAGE_CACHE), |
| 491 | 'constants' => array('CACHE_ENABLER_DIR', 'CACHE_ENABLER_VERSION'), |
| 492 | 'classes' => array('Cache_Enabler_Engine'), |
| 493 | 'paths' => array('cache/cache-enabler'), |
| 494 | 'dropin' => array('Cache_Enabler', 'cache-enabler'), |
| 495 | ), |
| 496 | 'comet-cache/comet-cache.php' => array( |
| 497 | 'label' => 'Comet Cache', |
| 498 | 'capabilities' => array(self::CAP_PAGE_CACHE), |
| 499 | 'paths' => array('cache/comet-cache'), |
| 500 | 'dropin' => array('comet-cache', 'ZenCache', 'Quick Cache'), |
| 501 | ), |
| 502 | 'hummingbird-performance/wp-hummingbird.php' => array( |
| 503 | 'label' => 'Hummingbird', |
| 504 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 505 | 'constants' => array('WPHB_ADVANCED_CACHE', 'WPHB_VERSION'), |
| 506 | 'paths' => array('wphb-cache'), |
| 507 | 'dropin' => array('Hummingbird', 'WPHB'), |
| 508 | ), |
| 509 | 'sg-cachepress/sg-cachepress.php' => array( |
| 510 | 'label' => 'SG Optimizer', |
| 511 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 512 | 'constants' => array('SiteGround_Optimizer\\VERSION'), |
| 513 | 'dropin' => array('SG CachePress', 'SiteGround'), |
| 514 | ), |
| 515 | 'breeze/breeze.php' => array( |
| 516 | 'label' => 'Breeze', |
| 517 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 518 | 'constants' => array('BREEZE_VERSION', 'BREEZE_CACHE_DIR'), |
| 519 | 'paths' => array('cache/breeze-minification'), |
| 520 | 'dropin' => array('Breeze', 'breeze-cache'), |
| 521 | ), |
| 522 | 'autoptimize/autoptimize.php' => array( |
| 523 | // Minification only. Present so a host can say "we saw it |
| 524 | // and it is not a page cache" instead of guessing. |
| 525 | 'label' => 'Autoptimize', |
| 526 | 'capabilities' => array(self::CAP_MINIFY), |
| 527 | 'constants' => array('AUTOPTIMIZE_PLUGIN_VERSION'), |
| 528 | 'paths' => array('cache/autoptimize'), |
| 529 | ), |
| 530 | 'flying-press/flying-press.php' => array( |
| 531 | 'label' => 'FlyingPress', |
| 532 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 533 | 'constants' => array('FLYING_PRESS_VERSION', 'FLYING_PRESS_CACHE_DIR'), |
| 534 | 'paths' => array('cache/flying-press'), |
| 535 | 'dropin' => array('FlyingPress', 'flying-press'), |
| 536 | ), |
| 537 | 'nitropack/main.php' => array( |
| 538 | 'label' => 'NitroPack', |
| 539 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 540 | 'constants' => array('NITROPACK_VERSION'), |
| 541 | 'dropin' => array('NitroPack', 'nitropack'), |
| 542 | ), |
| 543 | 'wp-optimize/wp-optimize.php' => array( |
| 544 | 'label' => 'WP-Optimize', |
| 545 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 546 | 'constants' => array('WPO_VERSION', 'WPO_CACHE_DIR'), |
| 547 | 'paths' => array('cache/wpo-cache'), |
| 548 | 'dropin' => array('WP-Optimize', 'WPO_CACHE'), |
| 549 | ), |
| 550 | 'jetpack-boost/jetpack-boost.php' => array( |
| 551 | 'label' => 'Jetpack Boost', |
| 552 | 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY), |
| 553 | 'paths' => array('boost-cache'), |
| 554 | 'dropin' => array('Jetpack Boost', 'jetpack-boost'), |
| 555 | ), |
| 556 | 'redis-cache/redis-cache.php' => array( |
| 557 | // Object cache only — owns object-cache.php, never |
| 558 | // advanced-cache.php. Catalogued so the object-cache |
| 559 | // drop-in can be NAMED rather than reported as unknown. |
| 560 | 'label' => 'Redis Object Cache', |
| 561 | 'capabilities' => array(self::CAP_OBJECT_CACHE), |
| 562 | 'constants' => array('WP_REDIS_VERSION'), |
| 563 | 'object_dropin' => array('Redis Object Cache', 'WP_Redis'), |
| 564 | ), |
| 565 | 'memcached/object-cache.php' => array( |
| 566 | 'label' => 'Memcached Object Cache', |
| 567 | 'capabilities' => array(self::CAP_OBJECT_CACHE), |
| 568 | 'object_dropin' => array('Memcached', 'memcache'), |
| 569 | ), |
| 570 | ); |
| 571 | |
| 572 | if (function_exists('apply_filters')) { |
| 573 | $filtered = apply_filters('page_cache_safety_catalog', $catalog); |
| 574 | if (is_array($filtered)) { |
| 575 | $catalog = $filtered; |
| 576 | } |
| 577 | } |
| 578 | |
| 579 | return $catalog; |
| 580 | } |
| 581 | |
| 582 | /** |
| 583 | * One row per catalogued plugin that is active or has left evidence. |
| 584 | * |
| 585 | * @return array<int,array> |
| 586 | */ |
| 587 | private static function inspect_plugins(): array |
| 588 | { |
| 589 | if (! function_exists('is_plugin_active') && defined('ABSPATH') && file_exists(ABSPATH . 'wp-admin/includes/plugin.php')) { |
| 590 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 591 | } |
| 592 | |
| 593 | $plugin_dir = defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR : (defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR . '/plugins' : ''); |
| 594 | |
| 595 | $out = array(); |
| 596 | foreach (self::catalog() as $file => $entry) { |
| 597 | $signals = self::signals_for($entry); |
| 598 | $installed = '' !== $plugin_dir && is_file($plugin_dir . '/' . $file); |
| 599 | $network_active = function_exists('is_plugin_active_for_network') && is_plugin_active_for_network($file); |
| 600 | $active = function_exists('is_plugin_active') ? (bool) is_plugin_active($file) : false; |
| 601 | $site_active = function_exists('get_option') && in_array($file, (array) get_option('active_plugins', array()), true); |
| 602 | if ($active && ! $network_active) { |
| 603 | $site_active = true; |
| 604 | } |
| 605 | $active = $active || $site_active || $network_active; |
| 606 | if (! $active && empty($signals)) { |
| 607 | continue; |
| 608 | } |
| 609 | |
| 610 | $capabilities = (array) ($entry['capabilities'] ?? array()); |
| 611 | $out[] = array( |
| 612 | 'plugin' => $file, |
| 613 | 'label' => (string) ($entry['label'] ?? $file), |
| 614 | 'active' => $active, |
| 615 | 'installed' => $installed, |
| 616 | 'site_active' => $site_active, |
| 617 | 'network_active' => $network_active, |
| 618 | 'activation_scope' => $site_active && $network_active ? 'site-and-network' : ($network_active ? 'network' : ($site_active ? 'site' : 'inactive')), |
| 619 | 'capabilities' => $capabilities, |
| 620 | 'page_cache' => in_array(self::CAP_PAGE_CACHE, $capabilities, true), |
| 621 | 'signals' => $signals, |
| 622 | ); |
| 623 | } |
| 624 | return $out; |
| 625 | } |
| 626 | |
| 627 | /** |
| 628 | * Which of an entry's signals are present. Constants and classes are |
| 629 | * checked WITHOUT autoloading, options through get_option, paths by |
| 630 | * stat under wp-content. No foreign file is opened or executed. |
| 631 | * |
| 632 | * @return string[] |
| 633 | */ |
| 634 | private static function signals_for(array $entry): array |
| 635 | { |
| 636 | $found = array(); |
| 637 | |
| 638 | foreach ((array) ($entry['constants'] ?? array()) as $constant) { |
| 639 | if (defined($constant)) { |
| 640 | $found[] = 'constant:' . $constant; |
| 641 | } |
| 642 | } |
| 643 | foreach ((array) ($entry['classes'] ?? array()) as $class) { |
| 644 | if (class_exists($class, false)) { |
| 645 | $found[] = 'class:' . $class; |
| 646 | } |
| 647 | } |
| 648 | foreach ((array) ($entry['options'] ?? array()) as $option) { |
| 649 | if (function_exists('get_option')) { |
| 650 | $value = get_option($option, null); |
| 651 | if (null !== $value && false !== $value) { |
| 652 | $found[] = 'option:' . $option; |
| 653 | } |
| 654 | } |
| 655 | } |
| 656 | foreach ((array) ($entry['paths'] ?? array()) as $path) { |
| 657 | if (defined('WP_CONTENT_DIR') && file_exists(WP_CONTENT_DIR . '/' . ltrim((string) $path, '/'))) { |
| 658 | $found[] = 'path:' . $path; |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | return $found; |
| 663 | } |
| 664 | |
| 665 | /** |
| 666 | * advanced-cache.php: present, whose, and what it hashes to. |
| 667 | */ |
| 668 | private static function inspect_dropin(): array |
| 669 | { |
| 670 | $target = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR . '/advanced-cache.php' : ''; |
| 671 | $state = array( |
| 672 | 'path' => $target, |
| 673 | 'exists' => false, |
| 674 | 'owner' => self::OWNER_NONE, |
| 675 | 'plugin' => null, |
| 676 | 'label' => null, |
| 677 | 'hash' => null, |
| 678 | 'readable' => true, |
| 679 | ); |
| 680 | |
| 681 | if ('' === $target || ! file_exists($target)) { |
| 682 | return $state; |
| 683 | } |
| 684 | |
| 685 | $state['exists'] = true; |
| 686 | $contents = self::read($target); |
| 687 | if (null === $contents) { |
| 688 | $state['readable'] = false; |
| 689 | $state['owner'] = self::OWNER_UNKNOWN; |
| 690 | return $state; |
| 691 | } |
| 692 | |
| 693 | $state['hash'] = hash('sha256', $contents); |
| 694 | $owner = self::identify($contents, 'dropin'); |
| 695 | |
| 696 | if (null !== $owner) { |
| 697 | $catalog = self::catalog(); |
| 698 | $state['owner'] = self::OWNER_KNOWN; |
| 699 | $state['plugin'] = $owner; |
| 700 | $state['label'] = (string) ($catalog[$owner]['label'] ?? $owner); |
| 701 | return $state; |
| 702 | } |
| 703 | |
| 704 | $state['owner'] = self::OWNER_UNKNOWN; |
| 705 | return $state; |
| 706 | } |
| 707 | |
| 708 | /** |
| 709 | * object-cache.php. Reported, never a blocker: a persistent object |
| 710 | * cache is a different layer from a page cache. |
| 711 | */ |
| 712 | private static function inspect_object_dropin(): array |
| 713 | { |
| 714 | $target = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR . '/object-cache.php' : ''; |
| 715 | $state = array( |
| 716 | 'path' => $target, |
| 717 | 'exists' => false, |
| 718 | 'readable' => true, |
| 719 | 'plugin' => null, |
| 720 | 'label' => null, |
| 721 | 'hash' => null, |
| 722 | ); |
| 723 | |
| 724 | if ('' === $target || ! file_exists($target)) { |
| 725 | return $state; |
| 726 | } |
| 727 | |
| 728 | $state['exists'] = true; |
| 729 | $contents = self::read($target); |
| 730 | if (null === $contents) { |
| 731 | $state['readable'] = false; |
| 732 | return $state; |
| 733 | } |
| 734 | |
| 735 | $state['hash'] = hash('sha256', $contents); |
| 736 | |
| 737 | // Its own token list first; several page-cache plugins ship both |
| 738 | // drop-ins and mark them with the same string, hence the fallback. |
| 739 | $owner = self::identify($contents, 'object_dropin'); |
| 740 | if (null === $owner) { |
| 741 | $owner = self::identify($contents, 'dropin'); |
| 742 | } |
| 743 | if (null !== $owner) { |
| 744 | $catalog = self::catalog(); |
| 745 | $state['plugin'] = $owner; |
| 746 | $state['label'] = (string) ($catalog[$owner]['label'] ?? $owner); |
| 747 | } |
| 748 | |
| 749 | return $state; |
| 750 | } |
| 751 | |
| 752 | /** |
| 753 | * Match catalog candidates only in structured, anchored locations. The |
| 754 | * file is tokenized but never included or executed. Null means unknown. |
| 755 | */ |
| 756 | private static function identify(string $contents, string $key): ?string |
| 757 | { |
| 758 | if ('' === $contents) { |
| 759 | return null; |
| 760 | } |
| 761 | $evidence = self::signature_evidence($contents); |
| 762 | $owners = array(); |
| 763 | foreach (self::catalog() as $file => $entry) { |
| 764 | foreach ((array) ($entry[$key] ?? array()) as $token) { |
| 765 | if (self::has_anchored_signature((string) $token, $evidence)) { |
| 766 | $owners[$file] = true; |
| 767 | break; |
| 768 | } |
| 769 | } |
| 770 | } |
| 771 | if (1 === count($owners)) { |
| 772 | return (string) array_key_first($owners); |
| 773 | } |
| 774 | if (count($owners) > 1) { |
| 775 | $labels = array(); |
| 776 | $catalog = self::catalog(); |
| 777 | foreach (array_keys($owners) as $owner) { |
| 778 | $labels[] = (string) ($catalog[$owner]['label'] ?? $owner); |
| 779 | } |
| 780 | if (1 === count(array_unique($labels))) { |
| 781 | return (string) array_key_first($owners); |
| 782 | } |
| 783 | } |
| 784 | return null; |
| 785 | } |
| 786 | |
| 787 | /** @return array{comment_lines:string[],identifiers:string[]} */ |
| 788 | private static function signature_evidence(string $contents): array |
| 789 | { |
| 790 | $comments = array(); |
| 791 | $identifiers = array(); |
| 792 | foreach (token_get_all($contents) as $token) { |
| 793 | if (! is_array($token)) { |
| 794 | continue; |
| 795 | } |
| 796 | if (in_array($token[0], array(T_COMMENT, T_DOC_COMMENT), true)) { |
| 797 | foreach (preg_split('/\\R/', $token[1]) ?: array() as $line) { |
| 798 | $line = preg_replace('/^\\s*(?:\/\\*+|\\*|\/\/|#)\\s*/', '', $line); |
| 799 | $line = preg_replace('/\\s*\\*\\/\\s*$/', '', (string) $line); |
| 800 | if ('' !== trim((string) $line)) { |
| 801 | $comments[] = trim((string) $line); |
| 802 | } |
| 803 | } |
| 804 | continue; |
| 805 | } |
| 806 | if (T_STRING === $token[0] || (defined('T_NAME_QUALIFIED') && T_NAME_QUALIFIED === $token[0]) || (defined('T_NAME_FULLY_QUALIFIED') && T_NAME_FULLY_QUALIFIED === $token[0])) { |
| 807 | foreach (explode('\\', trim($token[1], '\\')) as $identifier) { |
| 808 | if ('' !== $identifier) { |
| 809 | $identifiers[] = strtolower($identifier); |
| 810 | } |
| 811 | } |
| 812 | } |
| 813 | } |
| 814 | return array( |
| 815 | 'comment_lines' => $comments, |
| 816 | 'identifiers' => array_values(array_unique($identifiers)), |
| 817 | ); |
| 818 | } |
| 819 | |
| 820 | private static function has_anchored_signature(string $candidate, array $evidence): bool |
| 821 | { |
| 822 | $candidate = trim($candidate); |
| 823 | if ('' === $candidate) { |
| 824 | return false; |
| 825 | } |
| 826 | if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $candidate) && in_array(strtolower($candidate), $evidence['identifiers'], true)) { |
| 827 | return true; |
| 828 | } |
| 829 | $pattern = '/^' . preg_quote($candidate, '/') . '(?:\\s+(?:v(?:ersion)?\\s*)?\\d[A-Za-z0-9._-]*)?\\s*$/i'; |
| 830 | foreach ($evidence['comment_lines'] as $line) { |
| 831 | if (preg_match($pattern, $line)) { |
| 832 | return true; |
| 833 | } |
| 834 | } |
| 835 | return false; |
| 836 | } |
| 837 | |
| 838 | /** |
| 839 | * WP_CACHE as WRITTEN in wp-config.php, plus the runtime value. |
| 840 | * |
| 841 | * The literal is what matters: a value behind an expression, or two |
| 842 | * competing defines, cannot be rewritten safely, and a host that acts |
| 843 | * on "WP_CACHE is false" without noticing it is `getenv(...)` will get |
| 844 | * it wrong. |
| 845 | * |
| 846 | * @return array{path:string,readable:bool,state:string,runtime:?bool,defines:int,hash:?string} |
| 847 | */ |
| 848 | private static function inspect_wp_cache(): array |
| 849 | { |
| 850 | $runtime = defined('WP_CACHE') ? (bool) constant('WP_CACHE') : null; |
| 851 | $path = self::wp_config_path(); |
| 852 | |
| 853 | if ('' === $path) { |
| 854 | return array( |
| 855 | 'path' => $path, |
| 856 | 'readable' => false, |
| 857 | 'state' => 'unreadable', |
| 858 | 'runtime' => $runtime, |
| 859 | 'defines' => 0, |
| 860 | 'hash' => null, |
| 861 | ); |
| 862 | } |
| 863 | |
| 864 | $config = self::read($path); |
| 865 | if (null === $config) { |
| 866 | return array( |
| 867 | 'path' => $path, |
| 868 | 'readable' => false, |
| 869 | 'state' => 'unreadable', |
| 870 | 'runtime' => $runtime, |
| 871 | 'defines' => 0, |
| 872 | 'hash' => null, |
| 873 | ); |
| 874 | } |
| 875 | |
| 876 | $defines = self::wp_cache_defines($config); |
| 877 | $count = count($defines); |
| 878 | |
| 879 | if (! $count) { |
| 880 | return array( |
| 881 | 'path' => $path, |
| 882 | 'readable' => true, |
| 883 | 'state' => 'undefined', |
| 884 | 'runtime' => $runtime, |
| 885 | 'defines' => 0, |
| 886 | 'hash' => hash('sha256', $config), |
| 887 | ); |
| 888 | } |
| 889 | if ($count > 1) { |
| 890 | return array( |
| 891 | 'path' => $path, |
| 892 | 'readable' => true, |
| 893 | 'state' => 'duplicate', |
| 894 | 'runtime' => $runtime, |
| 895 | 'defines' => $count, |
| 896 | 'hash' => hash('sha256', $config), |
| 897 | ); |
| 898 | } |
| 899 | |
| 900 | /* |
| 901 | * Hosts and older tutorials write the value several ways — 1, '1', |
| 902 | * TRUE — and all are literals. Only something we cannot evaluate by |
| 903 | * reading it (a variable, a call, a ternary) is dynamic. |
| 904 | */ |
| 905 | $literal = self::literal_value($defines[0]); |
| 906 | if (in_array($literal, array('true', '1'), true)) { |
| 907 | $state = 'true'; |
| 908 | } elseif (in_array($literal, array('false', '0', '', 'null'), true)) { |
| 909 | $state = 'false'; |
| 910 | } else { |
| 911 | $state = 'dynamic'; |
| 912 | } |
| 913 | |
| 914 | return array( |
| 915 | 'path' => $path, |
| 916 | 'readable' => true, |
| 917 | 'state' => $state, |
| 918 | 'runtime' => $runtime, |
| 919 | 'defines' => 1, |
| 920 | 'hash' => hash('sha256', $config), |
| 921 | ); |
| 922 | } |
| 923 | |
| 924 | /** wp-config.php, in the site root or one level up (WP supports both). */ |
| 925 | private static function wp_config_path(): string |
| 926 | { |
| 927 | if (! defined('ABSPATH')) { |
| 928 | return ''; |
| 929 | } |
| 930 | foreach (array(ABSPATH . 'wp-config.php', dirname(ABSPATH) . '/wp-config.php') as $path) { |
| 931 | if (file_exists($path)) { |
| 932 | return $path; |
| 933 | } |
| 934 | } |
| 935 | return ''; |
| 936 | } |
| 937 | |
| 938 | /** @return array<int,array<int,mixed>> */ |
| 939 | private static function wp_cache_defines(string $config): array |
| 940 | { |
| 941 | $tokens = token_get_all($config); |
| 942 | $defines = array(); |
| 943 | $count = count($tokens); |
| 944 | for ($i = 0; $i < $count; $i++) { |
| 945 | $token = $tokens[$i]; |
| 946 | if (! self::is_global_define_call($tokens, $i)) { |
| 947 | continue; |
| 948 | } |
| 949 | $open = self::next_code_index($tokens, $i + 1); |
| 950 | if (null === $open || '(' !== $tokens[$open]) { |
| 951 | continue; |
| 952 | } |
| 953 | $name = self::next_code_index($tokens, $open + 1); |
| 954 | if (null === $name || ! is_array($tokens[$name]) || T_CONSTANT_ENCAPSED_STRING !== $tokens[$name][0] || 'WP_CACHE' !== self::unquote($tokens[$name][1])) { |
| 955 | continue; |
| 956 | } |
| 957 | $comma = self::next_code_index($tokens, $name + 1); |
| 958 | if (null === $comma || ',' !== $tokens[$comma]) { |
| 959 | continue; |
| 960 | } |
| 961 | |
| 962 | $value = array(); |
| 963 | $depth = 1; |
| 964 | for ($k = $comma + 1; $k < $count; $k++) { |
| 965 | $current = $tokens[$k]; |
| 966 | if ('(' === $current) { |
| 967 | $depth++; |
| 968 | } elseif (')' === $current) { |
| 969 | $depth--; |
| 970 | if (0 === $depth) { |
| 971 | $defines[] = $value; |
| 972 | $i = $k; |
| 973 | break; |
| 974 | } |
| 975 | } |
| 976 | $value[] = $current; |
| 977 | } |
| 978 | } |
| 979 | return $defines; |
| 980 | } |
| 981 | |
| 982 | private static function literal_value(array $tokens): string |
| 983 | { |
| 984 | $code = array_values( |
| 985 | array_filter( |
| 986 | $tokens, |
| 987 | static function ($token) { |
| 988 | return ! is_array($token) || ! in_array($token[0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true); |
| 989 | } |
| 990 | ) |
| 991 | ); |
| 992 | if (1 !== count($code)) { |
| 993 | return '__dynamic__'; |
| 994 | } |
| 995 | $token = $code[0]; |
| 996 | if (is_array($token) && T_CONSTANT_ENCAPSED_STRING === $token[0]) { |
| 997 | return strtolower(self::unquote($token[1])); |
| 998 | } |
| 999 | if (is_array($token) && in_array($token[0], array(T_STRING, T_LNUMBER), true)) { |
| 1000 | return strtolower($token[1]); |
| 1001 | } |
| 1002 | return '__dynamic__'; |
| 1003 | } |
| 1004 | |
| 1005 | private static function unquote(string $value): string |
| 1006 | { |
| 1007 | if (strlen($value) < 2) { |
| 1008 | return $value; |
| 1009 | } |
| 1010 | $body = substr($value, 1, -1); |
| 1011 | return "'" === $value[0] ? str_replace(array("\\\\", "\\'"), array("\\", "'"), $body) : stripcslashes($body); |
| 1012 | } |
| 1013 | |
| 1014 | private static function next_code_index(array $tokens, int $start): ?int |
| 1015 | { |
| 1016 | for ($i = $start, $count = count($tokens); $i < $count; $i++) { |
| 1017 | if (! is_array($tokens[$i]) || ! in_array($tokens[$i][0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true)) { |
| 1018 | return $i; |
| 1019 | } |
| 1020 | } |
| 1021 | return null; |
| 1022 | } |
| 1023 | |
| 1024 | /** |
| 1025 | * Is the token at $i a call to the GLOBAL define()? |
| 1026 | * |
| 1027 | * `\define( 'WP_CACHE', true )` is valid and some hardened configs write it. |
| 1028 | * PHP 8 tokenizes it as one T_NAME_FULLY_QUALIFIED; PHP 7 as T_NS_SEPARATOR |
| 1029 | * then T_STRING. A namespaced `Foo\define(...)` (T_NAME_QUALIFIED, or on |
| 1030 | * PHP 7 a T_STRING preceded by `Foo\`) is some other function, as is a |
| 1031 | * method call `$x->define(...)` / `X::define(...)`. |
| 1032 | */ |
| 1033 | private static function is_global_define_call(array $tokens, int $i): bool |
| 1034 | { |
| 1035 | $token = $tokens[$i]; |
| 1036 | if (! is_array($token)) { |
| 1037 | return false; |
| 1038 | } |
| 1039 | if (defined('T_NAME_FULLY_QUALIFIED') && T_NAME_FULLY_QUALIFIED === $token[0]) { |
| 1040 | return 0 === strcasecmp($token[1], '\\define'); |
| 1041 | } |
| 1042 | if (T_STRING !== $token[0] || 0 !== strcasecmp($token[1], 'define')) { |
| 1043 | return false; |
| 1044 | } |
| 1045 | $previous = self::previous_code_index($tokens, $i); |
| 1046 | if (null === $previous || ! is_array($tokens[$previous])) { |
| 1047 | return true; |
| 1048 | } |
| 1049 | if (in_array($tokens[$previous][0], array(T_OBJECT_OPERATOR, T_DOUBLE_COLON), true) || (defined('T_NULLSAFE_OBJECT_OPERATOR') && T_NULLSAFE_OBJECT_OPERATOR === $tokens[$previous][0])) { |
| 1050 | return false; |
| 1051 | } |
| 1052 | if (T_NS_SEPARATOR === $tokens[$previous][0]) { |
| 1053 | $before = self::previous_code_index($tokens, $previous); |
| 1054 | // `Foo\define` on PHP 7 (T_STRING before the separator), or `namespace\define` |
| 1055 | // (T_NAMESPACE before it; PHP 8 gives T_NAME_RELATIVE and never gets here). |
| 1056 | return null === $before || ! is_array($tokens[$before]) || ! in_array($tokens[$before][0], array(T_STRING, T_NAMESPACE), true); |
| 1057 | } |
| 1058 | return true; |
| 1059 | } |
| 1060 | |
| 1061 | /** Index of the nearest code token before $before, skipping whitespace and comments. */ |
| 1062 | private static function previous_code_index(array $tokens, int $before): ?int |
| 1063 | { |
| 1064 | for ($i = $before - 1; $i >= 0; $i--) { |
| 1065 | if (! is_array($tokens[$i]) || ! in_array($tokens[$i][0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true)) { |
| 1066 | return $i; |
| 1067 | } |
| 1068 | } |
| 1069 | return null; |
| 1070 | } |
| 1071 | |
| 1072 | |
| 1073 | /** |
| 1074 | * Read a file for inspection. Null on any failure — callers treat null |
| 1075 | * as "unknown", never as "empty". An empty string would read as "no |
| 1076 | * marker found", which is exactly the wrong conclusion. |
| 1077 | */ |
| 1078 | private static function read(string $path): ?string |
| 1079 | { |
| 1080 | if (! is_readable($path)) { |
| 1081 | return null; |
| 1082 | } |
| 1083 | // 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 read path. |
| 1084 | $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. |
| 1085 | return is_string($contents) ? $contents : null; |
| 1086 | } |
| 1087 | |
| 1088 | private static function verdict(string $state, array $blockers, array $notes, array $report): array |
| 1089 | { |
| 1090 | return array( |
| 1091 | 'state' => $state, |
| 1092 | 'blockers' => array_values($blockers), |
| 1093 | 'notes' => array_values($notes), |
| 1094 | 'revision' => (string) ($report['revision'] ?? ''), |
| 1095 | ); |
| 1096 | } |
| 1097 | } |
| 1098 | } |
| 1099 |