Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.95% covered (danger)
6.95%
26 / 374
9.38% covered (danger)
9.38%
3 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
ObjectCache
6.95% covered (danger)
6.95%
26 / 374
9.38% covered (danger)
9.38%
3 / 32
24005.06
0.00% covered (danger)
0.00%
0 / 1
 is_available
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 is_configured_in_wp_config
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
90
 bust_wp_config_cache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 constants_visible_this_request
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_drop_in_path
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_our_drop_in
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 get_state
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 enable
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 1
552
 disable
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
182
 on_deactivation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_preference_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 maybe_restore_dropin
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 get_dropin_source_url
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
30
 default_local_dropin_path
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 run_connectivity_preflight
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 bootstrap_redis_connection_constants_for_preflight
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
210
 maybe_define_redis_constants_from_environment
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 get_wp_config_transformer_readonly
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 parse_wp_config_scalar
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 parse_wp_config_redis_acl_password
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 map_wp_error_to_enable_result
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 maybe_restore_on_activation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_remove_dropin_if_unavailable
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
18.35
 is_object_cache_dropin_auto_management_disabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 normalize_to_bool
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 remove_our_dropin_file_and_disable_preference
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 delete_our_drop_in_file_if_ours
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 reconcile_non_ours_dropin
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
16.11
 delete_dropin_file
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 flush_object_cache
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 clear_options_object_cache
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 flush_and_clear_on_shutdown
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace NewfoldLabs\WP\Module\Performance\Cache\Types;
4
5use NewfoldLabs\WP\Module\Performance\Helpers\RedisEnv;
6use NewfoldLabs\WP\Module\Performance\Helpers\RedisCredentialsProvisioner;
7
8/**
9 * Object cache (Redis drop-in) management for the performance module.
10 *
11 * Not a page-cache type; does not go in CacheManager::classMap().
12 * Handles detection, enable/disable, and flush of the Redis object-cache drop-in.
13 */
14class ObjectCache {
15
16    /**
17     * URL to download the object-cache drop-in from.
18     *
19     * @var string
20     */
21    const DROPIN_URL = 'https://raw.githubusercontent.com/newfold-labs/wp-drop-in-redis-object-cache/prod/object-cache.php';
22
23    /**
24     * Identifier string in our drop-in file header (Plugin Name). Must match the
25     * drop-in's "Plugin Name:" value so is_our_drop_in() and download validation work.
26     *
27     * @var string
28     */
29    const DROPIN_HEADER_IDENTIFIER = 'Redis Object Cache Drop-In';
30
31    /**
32     * Bytes to read from the drop-in file when checking if it is ours.
33     *
34     * @var int
35     */
36    const HEADER_READ_BYTES = 2048;
37
38    /**
39     * Minimum constants required for a Redis connection (at least one must be defined).
40     *
41     * @var string[]
42     */
43    const REDIS_CONNECTION_CONSTANTS = array(
44        'WP_REDIS_PREFIX',
45        'WP_REDIS_PASSWORD',
46    );
47
48    /**
49     * Option name for persisting user preference (enabled = true, disabled = false).
50     * Used on plugin re-activation to restore the drop-in if it was enabled before deactivation.
51     * When the option is missing (first activation), we enable object cache by default when Redis is available.
52     *
53     * @var string
54     */
55    const OPTION_ENABLED_PREFERENCE = 'newfold_object_cache_enabled_preference';
56
57    /**
58     * When true, disables all automatic object-cache.php deletion/replacement from this module
59     * (plugins_loaded cleanup, reconcile, enable failure cleanup, disable file removal).
60     *
61     * Define in wp-config.php before wp-settings.php loads, for example:
62     * `define( 'NFD_DISABLE_OBJECT_CACHE_AUTO_MANAGEMENT', true );`
63     */
64    const DISABLE_AUTO_MANAGEMENT_CONSTANT = 'NFD_DISABLE_OBJECT_CACHE_AUTO_MANAGEMENT';
65
66    /**
67     * Cached wp-config existence check (per request).
68     *
69     * @var bool|null
70     */
71    private static $wp_config_configured_cache = null;
72
73    /**
74     * Whether the object cache feature is available (Redis connection constant is defined).
75     *
76     * @return bool
77     */
78    public static function is_available() {
79        foreach ( self::REDIS_CONNECTION_CONSTANTS as $constant ) {
80            if ( ! defined( $constant ) ) {
81                return false;
82            }
83        }
84        return true;
85    }
86
87    /**
88     * Whether Redis is configured in wp-config.php (at least one connection constant is defined there).
89     * Used for removal: we remove the drop-in when wp-config no longer has Redis config, even if the
90     * drop-in has already defined a constant (e.g. WP_REDIS_PREFIX from WP_CACHE_KEY_SALT or env).
91     * Uses WP-CLI's wp-config-transformer so commented-out defines are not counted as present.
92     * Result is cached per request to avoid repeated file reads and parsing.
93     *
94     * @return bool
95     */
96    public static function is_configured_in_wp_config() {
97        if ( null !== self::$wp_config_configured_cache ) {
98            return self::$wp_config_configured_cache;
99        }
100        $path = defined( 'WP_CONFIG_FILE' ) ? constant( 'WP_CONFIG_FILE' ) : ( ABSPATH . 'wp-config.php' );
101        if ( ! file_exists( $path ) ) {
102            $path = dirname( ABSPATH ) . '/wp-config.php';
103        }
104        if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
105            self::$wp_config_configured_cache = false;
106            return false;
107        }
108        try {
109            $transformer = new \WPConfigTransformer( $path, true );
110            foreach ( self::REDIS_CONNECTION_CONSTANTS as $constant ) {
111                if ( ! $transformer->exists( 'constant', $constant ) ) {
112                    self::$wp_config_configured_cache = false;
113                    return false;
114                }
115            }
116        } catch ( \Throwable $e ) {
117            self::$wp_config_configured_cache = false;
118            return false;
119        }
120        self::$wp_config_configured_cache = true;
121        return true;
122    }
123
124    /**
125     * Bust the static cache used by is_configured_in_wp_config() within the same request.
126     *
127     * @return void
128     */
129    public static function bust_wp_config_cache() {
130        self::$wp_config_configured_cache = null;
131    }
132
133    /**
134     * Whether required Redis constants are visible to PHP in this request (defined()).
135     *
136     * Note: After hosting writes wp-config mid-request, values may exist in the file before PHP reloads.
137     *
138     * @return bool
139     */
140    public static function constants_visible_this_request() {
141        foreach ( self::REDIS_CONNECTION_CONSTANTS as $constant ) {
142            if ( ! defined( $constant ) ) {
143                return false;
144            }
145        }
146        return true;
147    }
148
149    /**
150     * Path to the object-cache drop-in file.
151     *
152     * @return string
153     */
154    public static function get_drop_in_path() {
155        return WP_CONTENT_DIR . '/object-cache.php';
156    }
157
158    /**
159     * Check if the file at the given path is our drop-in (by header).
160     *
161     * @param string $path Full path to object-cache.php.
162     * @return bool
163     */
164    public static function is_our_drop_in( $path ) {
165        if ( ! is_readable( $path ) ) {
166            return false;
167        }
168        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file header read, not remote.
169        $content = @file_get_contents( $path, false, null, 0, self::HEADER_READ_BYTES );
170        if ( false === $content || '' === $content ) {
171            return false;
172        }
173        return strpos( $content, self::DROPIN_HEADER_IDENTIFIER ) !== false;
174    }
175
176    /**
177     * Get current object cache state for the UI and API.
178     *
179     * `available` controls whether the Object Cache settings block is shown. It is not tied to
180     * wp-config already having Redis constants (provisioning may add them on first enable).
181     * `enabled` is true only when Redis constants are defined in this request and our drop-in is active.
182     *
183     * @return array{available: bool, enabled: bool, overwritten: bool, ours: bool, preflight: array}
184     */
185    public static function get_state() {
186        $constants_ready = self::is_available();
187        $path            = self::get_drop_in_path();
188        $exists          = file_exists( $path );
189        $ours            = $exists && self::is_our_drop_in( $path );
190
191        /**
192         * Whether to show the Object Cache UI (toggle + copy). Defaults to true so users can enable
193         * object cache and trigger credential provisioning when constants are not present yet.
194         *
195         * @param bool $show_ui Default true.
196         */
197        $ui_available = (bool) apply_filters( 'newfold_performance_object_cache_ui_available', true );
198
199        return array(
200            'available'   => $ui_available,
201            'enabled'     => $constants_ready && $ours,
202            'overwritten' => $exists && ! $ours,
203            'ours'        => $ours,
204            'preflight'   => ObjectCachePreflight::snapshot( false ),
205        );
206    }
207
208    /**
209     * Enable object cache by downloading and writing the drop-in.
210     * If our drop-in is already present, we do not download again; we just ensure the preference is set.
211     *
212     * @return array{success: bool, message?: string, code?: string}
213     */
214    public static function enable() {
215        $path   = self::get_drop_in_path();
216        $exists = file_exists( $path );
217        $ours   = $exists && self::is_our_drop_in( $path );
218        if ( $exists && ! $ours ) {
219            return array(
220                'success' => false,
221                'code'    => ObjectCacheErrorCodes::DROPIN_OVERWRITTEN,
222                'message' => __( "Another plugin's object cache is active. Disable it in that plugin first.", 'wp-module-performance' ),
223            );
224        }
225
226        if ( ! extension_loaded( 'redis' ) ) {
227            return array(
228                'success' => false,
229                'code'    => ObjectCacheErrorCodes::PHPREDIS_MISSING,
230                'message' => __( 'Object caching is not supported on this server.', 'wp-module-performance' ),
231            );
232        }
233
234        if ( $ours ) {
235            $ping = self::run_connectivity_preflight();
236            if ( true !== $ping ) {
237                if (
238                    ! self::is_object_cache_dropin_auto_management_disabled()
239                    && is_array( $ping )
240                    && isset( $ping['code'] )
241                    && ObjectCacheErrorCodes::REDIS_UNREACHABLE === $ping['code']
242                ) {
243                    self::remove_our_dropin_file_and_disable_preference();
244                }
245                return $ping;
246            }
247            update_option( self::OPTION_ENABLED_PREFERENCE, true );
248            return array( 'success' => true );
249        }
250
251        if ( ! self::is_configured_in_wp_config() ) {
252            $provision = RedisCredentialsProvisioner::provision_enable_redis_via_hosting_api();
253            if ( is_wp_error( $provision ) ) {
254                return self::map_wp_error_to_enable_result( $provision );
255            }
256
257            self::bust_wp_config_cache();
258
259            if ( ! self::is_configured_in_wp_config() ) {
260                return array(
261                    'success' => false,
262                    'code'    => ObjectCacheErrorCodes::CREDENTIALS_PENDING_RELOAD,
263                    'message' => __( 'Setting up object cache. Please wait a few seconds and try again.', 'wp-module-performance' ),
264                );
265            }
266        }
267
268        $ping = self::run_connectivity_preflight();
269        if ( true !== $ping ) {
270            return $ping;
271        }
272
273        $path          = self::get_drop_in_path();
274        $dropin_source = self::get_dropin_source_url();
275        $response      = wp_remote_get(
276            $dropin_source,
277            array(
278                'timeout'   => 15,
279                'sslverify' => true,
280            )
281        );
282
283        if ( is_wp_error( $response ) ) {
284            return array(
285                'success' => false,
286                'code'    => ObjectCacheErrorCodes::DOWNLOAD_FAILED,
287                'message' => __( 'Could not download object cache files. Please try again later.', 'wp-module-performance' ),
288            );
289        }
290
291        $code = wp_remote_retrieve_response_code( $response );
292        if ( 200 !== $code ) {
293            return array(
294                'success' => false,
295                'code'    => ObjectCacheErrorCodes::DOWNLOAD_FAILED,
296                'message' => __( 'Could not download object cache files. Please try again later.', 'wp-module-performance' ),
297            );
298        }
299
300        $body = wp_remote_retrieve_body( $response );
301        if ( empty( $body ) || strpos( $body, self::DROPIN_HEADER_IDENTIFIER ) === false ) {
302            return array(
303                'success' => false,
304                'code'    => ObjectCacheErrorCodes::INVALID_DROPIN,
305                'message' => __( 'Could not enable object cache right now. Please try again later.', 'wp-module-performance' ),
306            );
307        }
308
309        if ( ! function_exists( 'WP_Filesystem' ) ) {
310            require_once ABSPATH . 'wp-admin/includes/file.php';
311        }
312        WP_Filesystem();
313        global $wp_filesystem;
314        if ( $wp_filesystem && $wp_filesystem->put_contents( $path, $body, FS_CHMOD_FILE ) ) {
315            update_option( self::OPTION_ENABLED_PREFERENCE, true );
316            return array( 'success' => true );
317        }
318
319        // Fallback to file_put_contents if WP_Filesystem failed (e.g. direct method not available).
320        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Intentional fallback after WP_Filesystem.
321        if ( false !== @file_put_contents( $path, $body ) ) {
322            update_option( self::OPTION_ENABLED_PREFERENCE, true );
323            return array( 'success' => true );
324        }
325
326        return array(
327            'success' => false,
328            'code'    => ObjectCacheErrorCodes::WRITE_FAILED,
329            'message' => __( 'Could not save object cache file. Check file permissions or contact support.', 'wp-module-performance' ),
330        );
331    }
332
333    /**
334     * Disable object cache by removing the drop-in (only if it is ours).
335     *
336     * @param bool $clear_preference If true (default), set the enabled preference to false. Pass false when called from deactivation so the last user state is preserved.
337     * @return array{success: bool, message?: string}
338     */
339    public static function disable( $clear_preference = true ) {
340        $path = self::get_drop_in_path();
341        if ( ! file_exists( $path ) ) {
342            if ( $clear_preference ) {
343                update_option( self::OPTION_ENABLED_PREFERENCE, false );
344            }
345            return array( 'success' => true );
346        }
347        if ( ! self::is_our_drop_in( $path ) ) {
348            // Drop-in is not ours; do not delete it. Just record the user's preference so reconcile leaves it alone.
349            if ( $clear_preference ) {
350                update_option( self::OPTION_ENABLED_PREFERENCE, false );
351            }
352            return array( 'success' => true );
353        }
354
355        if ( self::is_object_cache_dropin_auto_management_disabled() ) {
356            if ( $clear_preference ) {
357                update_option( self::OPTION_ENABLED_PREFERENCE, false );
358            }
359            return array( 'success' => true );
360        }
361
362        // Flush Redis and clear options cache while our drop-in is still active, then remove the file.
363        self::flush_object_cache();
364        self::clear_options_object_cache();
365
366        if ( ! function_exists( 'WP_Filesystem' ) ) {
367            require_once ABSPATH . 'wp-admin/includes/file.php';
368        }
369        WP_Filesystem();
370        global $wp_filesystem;
371        if ( $wp_filesystem && $wp_filesystem->delete( $path ) ) {
372            if ( $clear_preference ) {
373                // Store false (do not delete option) so maybe_restore_on_activation knows user turned off.
374                update_option( self::OPTION_ENABLED_PREFERENCE, false );
375            }
376            // Only on this request: flush Redis at shutdown so it stays empty (no repopulation from get_option).
377            add_action( 'shutdown', array( self::class, 'flush_and_clear_on_shutdown' ), PHP_INT_MAX );
378            return array( 'success' => true );
379        }
380
381        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- Intentional fallback after WP_Filesystem.
382        if ( @unlink( $path ) ) {
383            if ( $clear_preference ) {
384                // Store false (do not delete option) so maybe_restore_on_activation knows user turned off.
385                update_option( self::OPTION_ENABLED_PREFERENCE, false );
386            }
387            // Only on this request: flush Redis at shutdown so it stays empty (no repopulation from get_option).
388            add_action( 'shutdown', array( self::class, 'flush_and_clear_on_shutdown' ), PHP_INT_MAX );
389            return array( 'success' => true );
390        }
391
392        return array(
393            'success' => false,
394            'message' => __( 'Could not disable object cache. Check file permissions or contact support.', 'wp-module-performance' ),
395        );
396    }
397
398    /**
399     * Remove our object-cache drop-in on plugin/performance deactivation.
400     * Only deletes the file if it is our drop-in. Does not change the enabled preference,
401     * so whatever state the user had (on or off) is preserved for re-activation.
402     *
403     * @return void
404     */
405    public static function on_deactivation() {
406        self::disable( false );
407    }
408
409    /**
410     * Whether the stored preference means "user wants object cache on".
411     * Only explicit true/1/'1' counts as enabled; null or false means do not restore/replace.
412     *
413     * @return bool
414     */
415    public static function is_preference_enabled() {
416        $preference = get_option( self::OPTION_ENABLED_PREFERENCE, null );
417        return in_array( $preference, array( true, 1, '1' ), true );
418    }
419
420    /**
421     * Restore/replace the drop-in when user preference is "on".
422     * Used on activation and when serving cache settings so the UI state matches the preference.
423     *
424     * Calls enable() unconditionally (after preference + file checks) so missing credentials can be
425     * reprovisioned during activation and a missing drop-in can be restored in the same flow.
426     * When a non-ours drop-in is present, delete it first so enable() can install ours.
427     *
428     * @return void
429     */
430    public static function maybe_restore_dropin() {
431        if ( self::is_object_cache_dropin_auto_management_disabled() ) {
432            return;
433        }
434
435        if ( ! self::is_preference_enabled() ) {
436            return;
437        }
438
439        $path   = self::get_drop_in_path();
440        $exists = file_exists( $path );
441        $ours   = $exists && self::is_our_drop_in( $path );
442        if ( $ours ) {
443            return;
444        }
445
446        if ( $exists && ! self::delete_dropin_file( $path ) ) {
447            return;
448        }
449
450        self::enable();
451    }
452
453    /**
454     * Determine where to download the drop-in from.
455     *
456     * Prefers a local monorepo path when present, otherwise falls back to DROPIN_URL.
457     *
458     * @return string URL or file path accepted by wp_remote_get (file://).
459     */
460    private static function get_dropin_source_url() {
461        $local = apply_filters( 'newfold_performance_object_cache_dropin_local_path', self::default_local_dropin_path() );
462        if ( is_string( $local ) && '' !== $local && file_exists( $local ) && is_readable( $local ) ) {
463            return 'file://' . $local;
464        }
465
466        return (string) apply_filters( 'newfold_performance_object_cache_dropin_url', self::DROPIN_URL );
467    }
468
469    /**
470     * Default path to the drop-in when this repo includes `wp-drop-in-redis-object-cache/`.
471     *
472     * @return string
473     */
474    private static function default_local_dropin_path(): string {
475        $module_root = dirname( __DIR__, 3 );
476        return $module_root . '/../wp-drop-in-redis-object-cache/object-cache.php';
477    }
478
479    /**
480     * Connectivity preflight: ensure wp-config has credentials and Redis responds to PING.
481     *
482     * @return true|array{success:false, code:string, message:string}
483     */
484    private static function run_connectivity_preflight() {
485        if ( ! self::is_configured_in_wp_config() ) {
486            return array(
487                'success' => false,
488                'code'    => ObjectCacheErrorCodes::CREDENTIALS_MISSING,
489                'message' => __( 'Object cache is not configured yet.', 'wp-module-performance' ),
490            );
491        }
492
493        self::bootstrap_redis_connection_constants_for_preflight();
494
495        $ping = PhpRedisPinger::ping();
496        if ( empty( $ping['ok'] ) ) {
497            return array(
498                'success' => false,
499                'code'    => ObjectCacheErrorCodes::REDIS_UNREACHABLE,
500                'message' => __( 'Could not connect to the object cache. Please try again later.', 'wp-module-performance' ),
501            );
502        }
503
504        return true;
505    }
506
507    /**
508     * Define missing WP_REDIS_* connection constants from wp-config so phpredis can connect in the same request.
509     *
510     * @return void
511     */
512    public static function bootstrap_redis_connection_constants_for_preflight() {
513        $settings = array(
514            'scheme',
515            'host',
516            'port',
517            'path',
518            'password',
519            'database',
520            'timeout',
521            'read_timeout',
522            'retry_interval',
523        );
524
525        try {
526            $t = self::get_wp_config_transformer_readonly();
527            if ( $t ) {
528                foreach ( $settings as $setting ) {
529                    $name = sprintf( 'WP_REDIS_%s', strtoupper( $setting ) );
530                    if ( defined( $name ) ) {
531                        continue;
532                    }
533                    if ( ! $t->exists( 'constant', $name ) ) {
534                        continue;
535                    }
536                    $raw = $t->get_value( 'constant', $name );
537                    if ( ! is_string( $raw ) ) {
538                        continue;
539                    }
540                    $value = self::parse_wp_config_scalar( $raw );
541                    if ( null === $value && 'password' === $setting ) {
542                        // Redis 6 ACL passwords may be a two-element array in wp-config (see parse_wp_config_redis_acl_password).
543                        $value = self::parse_wp_config_redis_acl_password( $raw );
544                    }
545                    if ( null === $value ) {
546                        continue;
547                    }
548
549                    if ( 'password' === $setting ) {
550                        if ( is_array( $value ) ) {
551                            if ( 2 !== count( $value ) ) {
552                                continue;
553                            }
554                        } elseif ( '' === (string) $value ) {
555                            continue;
556                        }
557                    }
558
559                    // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Redis drop-in constants.
560                    define( $name, $value );
561                }
562            }
563        } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Best-effort bootstrap.
564            // Continue: password may still be supplied via environment.
565        }
566
567        self::maybe_define_redis_constants_from_environment();
568    }
569
570    /**
571     * Define WP_REDIS_PASSWORD / WP_REDIS_USERNAME from the environment when missing (matches common hosting + drop-in patterns).
572     *
573     * @return void
574     */
575    private static function maybe_define_redis_constants_from_environment(): void {
576        foreach ( array( 'WP_REDIS_USERNAME', 'WP_REDIS_PASSWORD' ) as $name ) {
577            if ( defined( $name ) ) {
578                continue;
579            }
580            $val = RedisEnv::string_value( $name );
581            if ( '' === $val ) {
582                continue;
583            }
584            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Redis drop-in constants.
585            define( $name, $val );
586        }
587    }
588
589    /**
590     * Load a read-only WPConfigTransformer for wp-config.php, or return null if unavailable.
591     *
592     * @return \WPConfigTransformer|null
593     */
594    private static function get_wp_config_transformer_readonly() {
595        $path = defined( 'WP_CONFIG_FILE' ) ? constant( 'WP_CONFIG_FILE' ) : ( ABSPATH . 'wp-config.php' );
596        if ( ! file_exists( $path ) ) {
597            $path = dirname( ABSPATH ) . '/wp-config.php';
598        }
599        if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
600            return null;
601        }
602
603        try {
604            return new \WPConfigTransformer( $path, true );
605        } catch ( \Throwable $e ) {
606            return null;
607        }
608    }
609
610    /**
611     * Parse a scalar constant value from raw wp-config text.
612     *
613     * @param string $raw Raw value from WPConfigTransformer::get_value().
614     * @return mixed|null
615     */
616    private static function parse_wp_config_scalar( $raw ) {
617        $raw = trim( (string) $raw );
618        if ( '' === $raw ) {
619            return null;
620        }
621
622        if ( preg_match( '/^\"(.*)\"$/s', $raw, $m ) ) {
623            return stripcslashes( $m[1] );
624        }
625
626        if ( preg_match( '/^\'(.*)\'$/s', $raw, $m ) ) {
627            return str_replace( array( '\\\\', '\\\'' ), array( '\\', '\'' ), $m[1] );
628        }
629
630        if ( 'true' === strtolower( $raw ) ) {
631            return true;
632        }
633        if ( 'false' === strtolower( $raw ) ) {
634            return false;
635        }
636
637        if ( ctype_digit( $raw ) ) {
638            return (int) $raw;
639        }
640
641        if ( is_numeric( $raw ) ) {
642            return 0 + $raw;
643        }
644
645        return null;
646    }
647
648    /**
649     * Parse Redis 6 ACL-style password from wp-config when the value is not a scalar string.
650     *
651     * Supports short and long PHP array syntax with two string elements (username and password).
652     *
653     * @param string $raw Raw value from WPConfigTransformer::get_value().
654     * @return array{0:string,1:string}|null
655     */
656    private static function parse_wp_config_redis_acl_password( $raw ) {
657        $raw = trim( (string) $raw );
658        if ( '' === $raw ) {
659            return null;
660        }
661
662        // Short array syntax with two quoted elements.
663        if ( '' !== $raw && '[' === $raw[0] ) {
664            if ( preg_match( '/^\[\s*\'([^\']*)\'\s*,\s*\'([^\']*)\'\s*\]$/s', $raw, $m ) ) {
665                return array( $m[1], $m[2] );
666            }
667            if ( preg_match( '/^\[\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"\s*,\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"\s*\]$/s', $raw, $m ) ) {
668                return array( stripcslashes( $m[1] ), stripcslashes( $m[2] ) );
669            }
670        }
671
672        // Long array syntax with two single-quoted elements.
673        if ( preg_match( '/^array\s*\(\s*\'([^\']*)\'\s*,\s*\'([^\']*)\'\s*\)\s*$/is', $raw, $m ) ) {
674            return array( $m[1], $m[2] );
675        }
676        if ( preg_match( '/^array\s*\(\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"\s*,\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"\s*\)\s*$/is', $raw, $m ) ) {
677            return array( stripcslashes( $m[1] ), stripcslashes( $m[2] ) );
678        }
679
680        return null;
681    }
682
683    /**
684     * Map a WP_Error from credential provisioning to the enable() result shape.
685     *
686     * @param \WP_Error $error Error.
687     * @return array{success:false, code:string, message:string}
688     */
689    private static function map_wp_error_to_enable_result( \WP_Error $error ) {
690        $code = $error->get_error_code();
691        $data = $error->get_error_data();
692
693        $message = $error->get_error_message();
694
695        // Hosting UAPI failures: prefer customer_error as stable machine code when present.
696        if ( 'nfd_hosting_uapi_error' === (string) $code && is_array( $data ) && ! empty( $data['customer_error'] ) && is_string( $data['customer_error'] ) ) {
697            $code = (string) $data['customer_error'];
698        }
699
700        $known = array(
701            ObjectCacheErrorCodes::HIIVE_NOT_CONNECTED     => __( 'Object cache cannot be enabled automatically right now. Please contact support.', 'wp-module-performance' ),
702            ObjectCacheErrorCodes::HUAPI_TOKEN_UNAVAILABLE => __( 'Could not enable object cache right now. Please try again later.', 'wp-module-performance' ),
703            ObjectCacheErrorCodes::HAL_SITE_ID_MISSING     => __( 'Could not enable object cache right now. Please try again later.', 'wp-module-performance' ),
704            ObjectCacheErrorCodes::REDIS_UNREACHABLE       => __( 'Could not connect to the object cache. Please try again later.', 'wp-module-performance' ),
705            'nfd_hiive_error'                              => __( 'Could not enable object cache right now. Please try again later.', 'wp-module-performance' ),
706            'nfd_hosting_uapi_error'                       => __( 'Could not enable object cache right now. Please try again later.', 'wp-module-performance' ),
707        );
708
709        if ( isset( $known[ $code ] ) ) {
710            $message = $known[ $code ];
711        }
712
713        return array(
714            'success' => false,
715            'code'    => (string) $code,
716            'message' => $message,
717        );
718    }
719
720    /**
721     * On plugin/performance activation: if Redis is available and the drop-in file is missing, enable it.
722     *
723     * @return void
724     */
725    public static function maybe_restore_on_activation() {
726        self::maybe_restore_dropin();
727    }
728
729    /**
730     * If Redis config is no longer present (e.g. constants commented out in wp-config) but our drop-in
731     * is still in place, remove the drop-in so WordPress does not load a broken object cache.
732     * Uses wp-config content (not defined()) so we still remove when the drop-in has already defined
733     * a constant (e.g. WP_REDIS_PREFIX from WP_CACHE_KEY_SALT or env). Also clears the enabled preference.
734     * Checks for the drop-in file first so we skip wp-config read/parse on most requests (no drop-in).
735     *
736     * When the user has not turned host-managed object cache on (stored preference is not true), does
737     * nothing: the UI can show "off" while the option is still unset, and the Redis Object Cache plugin
738     * uses the same drop-in header fingerprint as ours.
739     *
740     * Opt out entirely with {@see self::DISABLE_AUTO_MANAGEMENT_CONSTANT}.
741     *
742     * @return void
743     */
744    public static function maybe_remove_dropin_if_unavailable() {
745        if ( self::is_object_cache_dropin_auto_management_disabled() ) {
746            return;
747        }
748        if ( ! self::is_preference_enabled() ) {
749            return;
750        }
751
752        $path = self::get_drop_in_path();
753        if ( ! file_exists( $path ) || ! self::is_our_drop_in( $path ) ) {
754            return;
755        }
756        if ( self::is_configured_in_wp_config() ) {
757            return;
758        }
759        self::remove_our_dropin_file_and_disable_preference();
760    }
761
762    /**
763     * Whether automatic drop-in install/remove/reconcile is disabled via wp-config constant.
764     *
765     * @see self::DISABLE_AUTO_MANAGEMENT_CONSTANT
766     *
767     * @return bool
768     */
769    public static function is_object_cache_dropin_auto_management_disabled() {
770        $constant_name = self::DISABLE_AUTO_MANAGEMENT_CONSTANT;
771
772        return defined( $constant_name ) && self::normalize_to_bool( constant( $constant_name ) );
773    }
774
775    /**
776     * Coerce a value to boolean (wp-config defines may use strings).
777     *
778     * @param mixed $value Raw value.
779     * @return bool
780     */
781    private static function normalize_to_bool( $value ) {
782        if ( function_exists( 'wp_validate_boolean' ) ) {
783            return wp_validate_boolean( $value );
784        }
785
786        return (bool) filter_var( $value, FILTER_VALIDATE_BOOLEAN );
787    }
788
789    /**
790     * Delete our object-cache.php and set the enabled preference to false.
791     *
792     * Does not call wp_cache_flush(); used when Redis is unreachable or wp-config no longer has creds
793     * so we avoid invoking a broken object cache backend.
794     *
795     * @return bool True if the file was ours and was removed.
796     */
797    private static function remove_our_dropin_file_and_disable_preference(): bool {
798        if ( self::is_object_cache_dropin_auto_management_disabled() ) {
799            return false;
800        }
801        if ( ! self::delete_our_drop_in_file_if_ours() ) {
802            return false;
803        }
804        update_option( self::OPTION_ENABLED_PREFERENCE, false );
805        return true;
806    }
807
808    /**
809     * Delete our object-cache drop-in when the file exists and matches our header.
810     *
811     * @return bool True if our drop-in existed and was deleted.
812     */
813    private static function delete_our_drop_in_file_if_ours(): bool {
814        $path = self::get_drop_in_path();
815        if ( ! file_exists( $path ) || ! self::is_our_drop_in( $path ) ) {
816            return false;
817        }
818        if ( ! function_exists( 'WP_Filesystem' ) ) {
819            require_once ABSPATH . 'wp-admin/includes/file.php';
820        }
821        WP_Filesystem();
822        global $wp_filesystem;
823        if ( $wp_filesystem && $wp_filesystem->delete( $path ) ) {
824            return true;
825        }
826        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- Intentional fallback after WP_Filesystem.
827        return (bool) @unlink( $path );
828    }
829
830    /**
831     * Sentinel value for "preference not set" (option never written). We never write this to the option.
832     *
833     * @var string
834     */
835    const PREFERENCE_NOT_SET_SENTINEL = 'newfold_object_cache_preference_not_set';
836
837    /**
838     * Reconcile a non-ours object-cache.php with the user's preference.
839     *
840     * Runs when the drop-in exists and is not ours. Replace with our drop-in when preference is
841     * enabled or not set and Redis is available; leave the file alone when preference is disabled
842     * or when Redis is not available (so we never remove a drop-in we can't replace).
843     *
844     * @return void
845     */
846    public static function reconcile_non_ours_dropin() {
847        if ( self::is_object_cache_dropin_auto_management_disabled() ) {
848            return;
849        }
850
851        $path = self::get_drop_in_path();
852        if ( ! file_exists( $path ) || self::is_our_drop_in( $path ) ) {
853            return;
854        }
855
856        // Bypass object cache so we read the real preference (e.g. avoid W3TC/other plugin cache returning stale or empty).
857        if ( function_exists( 'wp_cache_delete' ) ) {
858            wp_cache_delete( 'alloptions', 'options' );
859        }
860        $preference = get_option( self::OPTION_ENABLED_PREFERENCE, self::PREFERENCE_NOT_SET_SENTINEL );
861
862        // Preference not set: option does not exist in DB.
863        if ( self::PREFERENCE_NOT_SET_SENTINEL === $preference ) {
864            if ( self::is_available() ) {
865                self::delete_dropin_file( $path );
866                self::enable();
867            }
868            return;
869        }
870
871        // Preference enabled: option exists and value is on (true, 1, '1').
872        if ( in_array( $preference, array( true, 1, '1' ), true ) ) {
873            if ( self::is_available() ) {
874                self::delete_dropin_file( $path );
875                self::enable();
876            }
877            return;
878        }
879
880        // Preference disabled: option exists and not enabled. Leave the non-ours drop-in alone.
881    }
882
883    /**
884     * Delete the object-cache drop-in file at the given path.
885     *
886     * Uses WP_Filesystem then unlink fallback. Used by reconcile when replacing a non-ours drop-in.
887     *
888     * @param string $path Full path to object-cache.php.
889     * @return bool True if file was deleted or did not exist, false if delete failed.
890     */
891    private static function delete_dropin_file( $path ) {
892        if ( ! file_exists( $path ) ) {
893            return true;
894        }
895        if ( ! function_exists( 'WP_Filesystem' ) ) {
896            require_once ABSPATH . 'wp-admin/includes/file.php';
897        }
898        WP_Filesystem();
899        global $wp_filesystem;
900        if ( $wp_filesystem && $wp_filesystem->delete( $path ) ) {
901            return true;
902        }
903        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- Intentional fallback after WP_Filesystem.
904        return @unlink( $path );
905    }
906
907    /**
908     * Flush the object cache (Redis). Only flushes when our drop-in is active.
909     *
910     * Used as part of "Clear cache" action; no separate endpoint.
911     *
912     * @return void
913     */
914    public static function flush_object_cache() {
915        $state = self::get_state();
916        if ( ! $state['enabled'] ) {
917            return;
918        }
919        if ( function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache() && function_exists( 'wp_cache_flush' ) ) {
920            wp_cache_flush();
921        }
922    }
923
924    /**
925     * Clear options-related keys from the object cache so the next request reads from DB.
926     * Call when turning off object cache (or on deactivation) to avoid stale active_plugins/alloptions.
927     *
928     * @return void
929     */
930    public static function clear_options_object_cache() {
931        if ( ! function_exists( 'wp_cache_delete' ) ) {
932            return;
933        }
934        wp_cache_delete( 'active_plugins', 'options' );
935        wp_cache_delete( 'alloptions', 'options' );
936        if ( function_exists( 'wp_cache_flush_group' ) ) {
937            wp_cache_flush_group( 'options' );
938        }
939        if ( function_exists( 'wp_cache_flush_runtime' ) ) {
940            wp_cache_flush_runtime();
941        }
942    }
943
944    /**
945     * Flush entire object cache and clear options keys. Only ever run via shutdown hook
946     * registered in disable() when we have just removed the drop-in (that one request only).
947     * Not run on normal requests—Redis cache is unaffected until the user turns object cache off.
948     *
949     * @return void
950     */
951    public static function flush_and_clear_on_shutdown() {
952        if ( function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache() && function_exists( 'wp_cache_flush' ) ) {
953            wp_cache_flush();
954        }
955        self::clear_options_object_cache();
956    }
957}