Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 175
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
PhpRedisPinger
0.00% covered (danger)
0.00%
0 / 175
0.00% covered (danger)
0.00%
0 / 9
4970
0.00% covered (danger)
0.00%
0 / 1
 ping
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
90
 ping_shards
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 ping_cluster
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
110
 ping_single
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
182
 build_cluster_seeds
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 normalize_ping_result
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
72
 build_parameters_from_constants
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
90
 phpredis_auth
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 redis_auth_secret_string
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3namespace NewfoldLabs\WP\Module\Performance\Cache\Types;
4
5use NewfoldLabs\WP\Module\Performance\Helpers\RedisEnv;
6
7/**
8 * Performs a lightweight Redis connectivity check using phpredis.
9 *
10 * Mirrors the common connection modes from the Newfold Redis drop-in, but intentionally stays
11 * defensive: if we cannot confidently connect, we return failure with a message.
12 */
13final class PhpRedisPinger {
14
15    /**
16     * Run Redis PING using the active connection mode (single, cluster, or shards).
17     *
18     * @return array{ok:bool, message?:string}
19     */
20    public static function ping(): array {
21        if ( ! extension_loaded( 'redis' ) || ! class_exists( 'Redis', false ) ) {
22            return array(
23                'ok'      => false,
24                'message' => __( 'phpredis is not available.', 'wp-module-performance' ),
25            );
26        }
27
28        try {
29            if ( defined( 'WP_REDIS_SHARDS' ) && is_array( WP_REDIS_SHARDS ) ) {
30                if ( ! class_exists( 'RedisArray', false ) ) {
31                    return array(
32                        'ok'      => false,
33                        'message' => __( 'RedisArray is not available in this PHP Redis build.', 'wp-module-performance' ),
34                    );
35                }
36                return self::ping_shards();
37            }
38
39            if ( defined( 'WP_REDIS_CLUSTER' ) ) {
40                if ( ! class_exists( 'RedisCluster', false ) ) {
41                    return array(
42                        'ok'      => false,
43                        'message' => __( 'RedisCluster is not available in this PHP Redis build.', 'wp-module-performance' ),
44                    );
45                }
46                return self::ping_cluster();
47            }
48
49            return self::ping_single();
50        } catch ( \Throwable $e ) {
51            return array(
52                'ok'      => false,
53                'message' => $e->getMessage(),
54            );
55        }
56    }
57
58    /**
59     * Ping Redis in RedisArray (sharded) mode.
60     *
61     * @return array{ok:bool, message?:string}
62     */
63    private static function ping_shards(): array {
64        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Drop-in constant.
65        $redis = new \RedisArray( array_values( WP_REDIS_SHARDS ) );
66        $pong  = $redis->ping();
67
68        return self::normalize_ping_result( $pong );
69    }
70
71    /**
72     * Ping Redis in cluster mode.
73     *
74     * @return array{ok:bool, message?:string}
75     */
76    private static function ping_cluster(): array {
77        $version = phpversion( 'redis' );
78        $version = is_string( $version ) ? $version : '0.0.0';
79
80        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Drop-in constant.
81        $cluster = WP_REDIS_CLUSTER;
82
83        if ( is_string( $cluster ) ) {
84            $redis = new \RedisCluster( $cluster );
85            $pong  = $redis->ping();
86            return self::normalize_ping_result( $pong );
87        }
88
89        if ( ! is_array( $cluster ) ) {
90            return array(
91                'ok'      => false,
92                'message' => __( 'Unsupported WP_REDIS_CLUSTER configuration.', 'wp-module-performance' ),
93            );
94        }
95
96        $parameters = self::build_parameters_from_constants();
97
98        $args = array(
99            'cluster'      => self::build_cluster_seeds( $cluster ),
100            'timeout'      => $parameters['timeout'],
101            'read_timeout' => $parameters['read_timeout'],
102            'persistent'   => (bool) $parameters['persistent'],
103        );
104
105        if ( isset( $parameters['password'] ) && version_compare( $version, '4.3.0', '>=' ) ) {
106            $args['password'] = $parameters['password'];
107        }
108
109        if ( version_compare( $version, '5.3.0', '>=' ) && defined( 'WP_REDIS_SSL_CONTEXT' ) && ! empty( WP_REDIS_SSL_CONTEXT ) ) {
110            if ( ! array_key_exists( 'password', $args ) ) {
111                $args['password'] = null;
112            }
113            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Drop-in constant.
114            $args['ssl'] = WP_REDIS_SSL_CONTEXT;
115        }
116
117        $redis = new \RedisCluster( null, ...array_values( $args ) );
118        $pong  = $redis->ping();
119
120        return self::normalize_ping_result( $pong );
121    }
122
123    /**
124     * Ping a single Redis instance.
125     *
126     * @return array{ok:bool, message?:string}
127     */
128    private static function ping_single(): array {
129        $parameters = self::build_parameters_from_constants();
130        $version    = phpversion( 'redis' );
131        $version    = is_string( $version ) ? $version : '0.0.0';
132
133        $redis = new \Redis();
134
135        $retry_interval = $parameters['retry_interval'];
136        $args           = array(
137            'host'           => $parameters['host'],
138            'port'           => (int) $parameters['port'],
139            'timeout'        => $parameters['timeout'],
140            'reserved'       => '',
141            'retry_interval' => null === $retry_interval ? 0 : (int) $retry_interval,
142        );
143
144        if ( version_compare( $version, '3.1.3', '>=' ) ) {
145            $args['read_timeout'] = $parameters['read_timeout'];
146        }
147
148        if ( strcasecmp( 'tls', (string) $parameters['scheme'] ) === 0 ) {
149            $args['host'] = sprintf(
150                '%s://%s',
151                $parameters['scheme'],
152                str_replace( 'tls://', '', (string) $parameters['host'] )
153            );
154
155            if ( version_compare( $version, '5.3.0', '>=' ) && defined( 'WP_REDIS_SSL_CONTEXT' ) && ! empty( WP_REDIS_SSL_CONTEXT ) ) {
156                $args['others'] = array(
157                    'stream' => WP_REDIS_SSL_CONTEXT,
158                );
159            }
160        }
161
162        if ( strcasecmp( 'unix', (string) $parameters['scheme'] ) === 0 ) {
163            $args['host'] = (string) $parameters['path'];
164            $args['port'] = -1;
165        }
166
167        call_user_func_array( array( $redis, 'connect' ), array_values( $args ) );
168
169        self::phpredis_auth( $redis, $parameters, $version );
170
171        if ( isset( $parameters['database'] ) && $parameters['database'] ) {
172            $db = $parameters['database'];
173            if ( ctype_digit( (string) $db ) ) {
174                $db = (int) $db;
175            }
176            if ( $db ) {
177                $redis->select( $db );
178            }
179        }
180
181        $pong = $redis->ping();
182        return self::normalize_ping_result( $pong );
183    }
184
185    /**
186     * Build host:port seed strings from a WP_REDIS_CLUSTER array-style definition.
187     *
188     * @param mixed $cluster_def Cluster definition from WP_REDIS_CLUSTER.
189     * @return array<int, string>
190     */
191    private static function build_cluster_seeds( $cluster_def ): array {
192        $seeds = array();
193        foreach ( (array) $cluster_def as $key => $define ) {
194            if ( is_array( $define ) ) {
195                $seeds[] = implode( ':', array_map( 'strval', $define ) );
196                continue;
197            }
198
199            $host    = is_int( $key ) ? strval( $define ) : strval( $key );
200            $port    = strval( $define );
201            $seeds[] = "{$host}:{$port}";
202        }
203
204        return $seeds;
205    }
206
207    /**
208     * Interpret a PING response from phpredis as success or failure.
209     *
210     * @param mixed $pong Return value from Redis::ping().
211     * @return array{ok:bool, message?:string}
212     */
213    private static function normalize_ping_result( $pong ): array {
214        if ( true === $pong ) {
215            return array( 'ok' => true );
216        }
217
218        if ( is_string( $pong ) && stripos( $pong, 'PONG' ) !== false ) {
219            return array( 'ok' => true );
220        }
221
222        if ( is_array( $pong ) ) {
223            foreach ( $pong as $v ) {
224                if ( is_string( $v ) && stripos( $v, 'PONG' ) !== false ) {
225                    return array( 'ok' => true );
226                }
227            }
228        }
229
230        return array(
231            'ok'      => false,
232            'message' => __( 'Redis did not respond to PING as expected.', 'wp-module-performance' ),
233        );
234    }
235
236    /**
237     * Build connection parameters from WP_REDIS_* constants plus optional environment fallbacks.
238     *
239     * @return array{scheme:string,host:string,port:int,path:string,password?:string,database:int|float|string,timeout:float|int,read_timeout:float|int,retry_interval:?int,persistent:bool}
240     */
241    private static function build_parameters_from_constants(): array {
242        $parameters = array(
243            'scheme'         => 'tcp',
244            'host'           => '127.0.0.1',
245            'port'           => 6379,
246            'path'           => '',
247            'database'       => 0,
248            'timeout'        => 1,
249            'read_timeout'   => 1,
250            'retry_interval' => null,
251            'persistent'     => false,
252        );
253
254        $settings = array(
255            'scheme',
256            'host',
257            'port',
258            'path',
259            'password',
260            'username',
261            'database',
262            'timeout',
263            'read_timeout',
264            'retry_interval',
265        );
266
267        foreach ( $settings as $setting ) {
268            $constant = sprintf( 'WP_REDIS_%s', strtoupper( $setting ) );
269            if ( defined( $constant ) ) {
270                $parameters[ $setting ] = constant( $constant );
271            }
272        }
273
274        if ( isset( $parameters['password'] ) && '' === $parameters['password'] ) {
275            unset( $parameters['password'] );
276        }
277
278        if ( ! isset( $parameters['password'] ) ) {
279            $from_env = RedisEnv::string_value( 'WP_REDIS_PASSWORD' );
280            if ( '' !== $from_env ) {
281                $parameters['password'] = $from_env;
282            }
283        }
284
285        if ( ! isset( $parameters['username'] ) ) {
286            $user_env = RedisEnv::string_value( 'WP_REDIS_USERNAME' );
287            if ( '' !== $user_env ) {
288                $parameters['username'] = $user_env;
289            }
290        }
291
292        return $parameters;
293    }
294
295    /**
296     * Authenticate using the PHP Redis extension: legacy password, ACL array (Redis 6+), or username and password.
297     *
298     * @param \Redis               $redis      Connected client.
299     * @param array<string, mixed> $parameters From build_parameters_from_constants().
300     * @param string               $phpredis_ver Extension version string.
301     */
302    private static function phpredis_auth( \Redis $redis, array $parameters, $phpredis_ver ): void {
303        if ( ! isset( $parameters['password'] ) ) {
304            return;
305        }
306
307        $pw = $parameters['password'];
308
309        if ( is_array( $pw ) ) {
310            $redis->auth( $pw );
311            return;
312        }
313
314        $pw_string = self::redis_auth_secret_string( $pw );
315        if ( null === $pw_string ) {
316            return;
317        }
318
319        if ( isset( $parameters['username'] ) && is_string( $parameters['username'] ) && '' !== $parameters['username'] ) {
320            if ( version_compare( (string) $phpredis_ver, '5.3.0', '>=' ) ) {
321                $redis->auth( $parameters['username'], $pw_string );
322                return;
323            }
324        }
325
326        $redis->auth( $pw_string );
327    }
328
329    /**
330     * Coerce password to string for phpredis without triggering PHP 8+ conversion fatals.
331     *
332     * @param mixed $pw Password value from constants or env.
333     * @return string|null String to pass to Redis::auth(), or null if the value cannot be used safely.
334     */
335    private static function redis_auth_secret_string( $pw ): ?string {
336        if ( is_string( $pw ) ) {
337            return $pw;
338        }
339        if ( is_int( $pw ) || is_float( $pw ) ) {
340            return (string) $pw;
341        }
342        if ( is_bool( $pw ) ) {
343            return $pw ? '1' : '';
344        }
345        if ( is_object( $pw ) && method_exists( $pw, '__toString' ) ) {
346            return (string) $pw;
347        }
348
349        return null;
350    }
351}