From 5b197316497b9eb350770a84fad045185f3e015e Mon Sep 17 00:00:00 2001 From: michael-grunder Date: Tue, 7 Apr 2026 12:51:37 -0700 Subject: [PATCH 01/31] Update `GCRA` optional argument from `NUM_REQUESTS` to `TOKENS`. See: https://github.com/redis/redis/pull/14950 --- redis.stub.php | 4 ++-- redis_arginfo.h | 4 ++-- redis_cluster.stub.php | 2 +- redis_cluster_arginfo.h | 4 ++-- redis_cluster_legacy_arginfo.h | 4 ++-- redis_commands.c | 12 ++++++------ redis_legacy_arginfo.h | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/redis.stub.php b/redis.stub.php index 20fa031f3e..6d30144064 100644 --- a/redis.stub.php +++ b/redis.stub.php @@ -5276,7 +5276,7 @@ public function vlinks(string $key, mixed $member, bool $withscores = false): Re * @param int $maxBurst * @param int $requestsPerPeriod * @param int $period - * @param int $numRequests = 0 + * @param int $tokens = 0 * @return Redis|array|false * * @see https://redis.io/docs/latest/commands/gcra/ @@ -5285,7 +5285,7 @@ public function vlinks(string $key, mixed $member, bool $withscores = false): Re * $redis->gcra('user:123', 10, 100, 3600); */ public function gcra(string $key, int $maxBurst, int $requestsPerPeriod, - int $period, int $numRequests = 0): Redis|array|false; + int $period, int $tokens = 0): Redis|array|false; /** diff --git a/redis_arginfo.h b/redis_arginfo.h index 68dc200424..73acd29d8f 100644 --- a/redis_arginfo.h +++ b/redis_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 190c8ed87be2064ed4cb1c7305576576af10afc6 */ + * Stub hash: 3ee3118802fef67d6bd7176f2a72f0568d962c8b */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Redis___construct, 0, 0, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, options, IS_ARRAY, 1, "null") @@ -1153,7 +1153,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Redis_gcra, 0, 4, Redi ZEND_ARG_TYPE_INFO(0, maxBurst, IS_LONG, 0) ZEND_ARG_TYPE_INFO(0, requestsPerPeriod, IS_LONG, 0) ZEND_ARG_TYPE_INFO(0, period, IS_LONG, 0) - ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, numRequests, IS_LONG, 0, "0") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, tokens, IS_LONG, 0, "0") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Redis_xtrim, 0, 2, Redis, MAY_BE_LONG|MAY_BE_FALSE) diff --git a/redis_cluster.stub.php b/redis_cluster.stub.php index 513fc4f6e5..7f7d93fbb0 100644 --- a/redis_cluster.stub.php +++ b/redis_cluster.stub.php @@ -1185,7 +1185,7 @@ public function vsetattr(string $key, mixed $member, array|string $attributes): * @see \Redis::gcra() */ public function gcra(string $key, int $maxBurst, int $requestsPerPeriod, - int $period, int $numRequests = 0): RedisCluster|array|false; + int $period, int $tokens = 0): RedisCluster|array|false; /** diff --git a/redis_cluster_arginfo.h b/redis_cluster_arginfo.h index 2f39d86213..6d255712c8 100644 --- a/redis_cluster_arginfo.h +++ b/redis_cluster_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: d0720357eca4c563bbd5211bbfbfc6f5a00c4b72 */ + * Stub hash: 6c7a87611b3bc9039650a3cf2e3c4d4f916611b0 */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_RedisCluster___construct, 0, 0, 1) ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 1) @@ -969,7 +969,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_RedisCluster_gcra, 0, ZEND_ARG_TYPE_INFO(0, maxBurst, IS_LONG, 0) ZEND_ARG_TYPE_INFO(0, requestsPerPeriod, IS_LONG, 0) ZEND_ARG_TYPE_INFO(0, period, IS_LONG, 0) - ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, numRequests, IS_LONG, 0, "0") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, tokens, IS_LONG, 0, "0") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_RedisCluster_xack, 0, 3, RedisCluster, MAY_BE_LONG|MAY_BE_FALSE) diff --git a/redis_cluster_legacy_arginfo.h b/redis_cluster_legacy_arginfo.h index 588ce4c651..3fd44e313b 100644 --- a/redis_cluster_legacy_arginfo.h +++ b/redis_cluster_legacy_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: d0720357eca4c563bbd5211bbfbfc6f5a00c4b72 */ + * Stub hash: 6c7a87611b3bc9039650a3cf2e3c4d4f916611b0 */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_RedisCluster___construct, 0, 0, 1) ZEND_ARG_INFO(0, name) @@ -820,7 +820,7 @@ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_RedisCluster_gcra, 0, 0, 4) ZEND_ARG_INFO(0, maxBurst) ZEND_ARG_INFO(0, requestsPerPeriod) ZEND_ARG_INFO(0, period) - ZEND_ARG_INFO(0, numRequests) + ZEND_ARG_INFO(0, tokens) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_INFO_EX(arginfo_class_RedisCluster_xack, 0, 0, 3) diff --git a/redis_commands.c b/redis_commands.c index 02ceb8c5fb..a044b61123 100644 --- a/redis_commands.c +++ b/redis_commands.c @@ -7500,7 +7500,7 @@ redis_vsetattr_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock, int redis_gcra_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock, char **cmd, int *cmd_len, short *slot, void **ctx) { - zend_long max_burst, req_per_period, period, count = 0; + zend_long max_burst, req_per_period, period, tokens = 0; smart_string cmdstr = {0}; zend_string *key; @@ -7510,18 +7510,18 @@ int redis_gcra_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock, Z_PARAM_LONG(req_per_period) Z_PARAM_LONG(period) Z_PARAM_OPTIONAL - Z_PARAM_LONG(count) + Z_PARAM_LONG(tokens) ZEND_PARSE_PARAMETERS_END_EX(return FAILURE); - REDIS_CMD_INIT_SSTR_STATIC(&cmdstr, 4 + (count > 0 ? 2 : 0), "GCRA"); + REDIS_CMD_INIT_SSTR_STATIC(&cmdstr, 4 + (tokens > 0 ? 2 : 0), "GCRA"); redis_cmd_append_sstr_key_zstr(&cmdstr, key, redis_sock, slot); redis_cmd_append_sstr_long(&cmdstr, max_burst); redis_cmd_append_sstr_long(&cmdstr, req_per_period); redis_cmd_append_sstr_long(&cmdstr, period); - if (count > 0) { - REDIS_CMD_APPEND_SSTR_STATIC(&cmdstr, "NUM_REQUESTS"); - redis_cmd_append_sstr_long(&cmdstr, count); + if (tokens > 0) { + REDIS_CMD_APPEND_SSTR_STATIC(&cmdstr, "TOKENS"); + redis_cmd_append_sstr_long(&cmdstr, tokens); } *cmd = cmdstr.c; diff --git a/redis_legacy_arginfo.h b/redis_legacy_arginfo.h index 844b5494ea..e74e2d6c4a 100644 --- a/redis_legacy_arginfo.h +++ b/redis_legacy_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 190c8ed87be2064ed4cb1c7305576576af10afc6 */ + * Stub hash: 3ee3118802fef67d6bd7176f2a72f0568d962c8b */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Redis___construct, 0, 0, 0) ZEND_ARG_INFO(0, options) @@ -1021,7 +1021,7 @@ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Redis_gcra, 0, 0, 4) ZEND_ARG_INFO(0, maxBurst) ZEND_ARG_INFO(0, requestsPerPeriod) ZEND_ARG_INFO(0, period) - ZEND_ARG_INFO(0, numRequests) + ZEND_ARG_INFO(0, tokens) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Redis_xtrim, 0, 0, 2) From 82bf96c3c059161e0a91e61dc120b76430149769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Wed, 8 Apr 2026 22:56:58 +0900 Subject: [PATCH 02/31] Fix flaky testExists by using deterministic key setup The test used rand() to decide which keys to create, making it possible for $mkeys to be empty. This caused EXISTS to receive an empty array, resulting in a sporadic assertion failure: (false) !== 0 Observed in #2825 CI and also in an unrelated branch: - https://github.com/phpredis/phpredis/actions/runs/24132080914 - https://github.com/phpredis/phpredis/actions/runs/23617186872 --- tests/RedisTest.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/RedisTest.php b/tests/RedisTest.php index c5e239a537..ee4f05494c 100644 --- a/tests/RedisTest.php +++ b/tests/RedisTest.php @@ -1015,17 +1015,14 @@ public function testExists() { /* Add multiple keys */ $mkeys = []; for ($i = 0; $i < 10; $i++) { - if (rand(1, 2) == 1) { - $mkey = "{exists}key:$i"; - $this->redis->set($mkey, $i); - $mkeys[] = $mkey; - } + $mkey = "{exists}key:$i"; + $this->redis->set($mkey, $i); + $mkeys[] = $mkey; } /* Test passing an array as well as the keys variadic */ $this->assertEquals(count($mkeys), $this->redis->exists($mkeys)); - if (count($mkeys)) - $this->assertEquals(count($mkeys), $this->redis->exists(...$mkeys)); + $this->assertEquals(count($mkeys), $this->redis->exists(...$mkeys)); } public function testTouch() { From f5ed17048bde7c9e8ada04005b0773bbd99e5abf Mon Sep 17 00:00:00 2001 From: michael-grunder Date: Wed, 8 Apr 2026 18:42:21 -0700 Subject: [PATCH 03/31] Update doctum docs --- docs/Redis.html | 6 +++--- docs/RedisCluster.html | 6 +++--- docs/renderer.index | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/Redis.html b/docs/Redis.html index cf6efe5095..009c813d0a 100644 --- a/docs/Redis.html +++ b/docs/Redis.html @@ -3652,7 +3652,7 @@

Methods

Redis|array|false
- gcra(string $key, int $maxBurst, int $requestsPerPeriod, int $period, int $numRequests = 0) + gcra(string $key, int $maxBurst, int $requestsPerPeriod, int $period, int $tokens = 0)

Get rate limiting information

@@ -22668,7 +22668,7 @@

Examples

- Redis|array|false gcra(string $key, int $maxBurst, int $requestsPerPeriod, int $period, int $numRequests = 0) + Redis|array|false gcra(string $key, int $maxBurst, int $requestsPerPeriod, int $period, int $tokens = 0)

@@ -22703,7 +22703,7 @@

Parameters

int - $numRequests + $tokens

= 0

diff --git a/docs/RedisCluster.html b/docs/RedisCluster.html index d73b0e6449..3187136751 100644 --- a/docs/RedisCluster.html +++ b/docs/RedisCluster.html @@ -2889,7 +2889,7 @@

Methods

RedisCluster|array|false
- gcra(string $key, int $maxBurst, int $requestsPerPeriod, int $period, int $numRequests = 0) + gcra(string $key, int $maxBurst, int $requestsPerPeriod, int $period, int $tokens = 0)

No description

@@ -16595,7 +16595,7 @@

See also

- RedisCluster|array|false gcra(string $key, int $maxBurst, int $requestsPerPeriod, int $period, int $numRequests = 0) + RedisCluster|array|false gcra(string $key, int $maxBurst, int $requestsPerPeriod, int $period, int $tokens = 0)

@@ -16631,7 +16631,7 @@

Parameters

int - $numRequests + $tokens diff --git a/docs/renderer.index b/docs/renderer.index index a2a450d606..addbc8c22a 100644 --- a/docs/renderer.index +++ b/docs/renderer.index @@ -1 +1 @@ -O:21:"Doctum\Renderer\Index":3:{i:0;a:6:{s:5:"Redis";s:40:"190c8ed87be2064ed4cb1c7305576576af10afc6";s:10:"RedisArray";s:40:"1293a0ccef1e700da87d9ace76c3a05d91c0ffc7";s:12:"RedisCluster";s:40:"d0720357eca4c563bbd5211bbfbfc6f5a00c4b72";s:21:"RedisClusterException";s:40:"d0720357eca4c563bbd5211bbfbfc6f5a00c4b72";s:14:"RedisException";s:40:"190c8ed87be2064ed4cb1c7305576576af10afc6";s:13:"RedisSentinel";s:40:"ca40579af888c5bb0661cd0201d840297474479a";}i:1;a:1:{i:0;s:7:"develop";}i:2;a:1:{i:0;s:0:"";}} \ No newline at end of file +O:21:"Doctum\Renderer\Index":3:{i:0;a:6:{s:5:"Redis";s:40:"3ee3118802fef67d6bd7176f2a72f0568d962c8b";s:10:"RedisArray";s:40:"1293a0ccef1e700da87d9ace76c3a05d91c0ffc7";s:12:"RedisCluster";s:40:"6c7a87611b3bc9039650a3cf2e3c4d4f916611b0";s:21:"RedisClusterException";s:40:"6c7a87611b3bc9039650a3cf2e3c4d4f916611b0";s:14:"RedisException";s:40:"3ee3118802fef67d6bd7176f2a72f0568d962c8b";s:13:"RedisSentinel";s:40:"ca40579af888c5bb0661cd0201d840297474479a";}i:1;a:1:{i:0;s:7:"develop";}i:2;a:1:{i:0;s:0:"";}} \ No newline at end of file From fe67b8cb3310a83d98040c0971d52c0d1bd24f53 Mon Sep 17 00:00:00 2001 From: arshidkv12 Date: Fri, 1 May 2026 14:43:19 +0530 Subject: [PATCH 04/31] fix: fix: correct snprintf format specifier for long value --- redis_commands.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis_commands.c b/redis_commands.c index a044b61123..b579bbe0ad 100644 --- a/redis_commands.c +++ b/redis_commands.c @@ -2921,7 +2921,7 @@ static inline zval *coerce_hash_field(zval *zv, zval *aux) { is_numeric_string(Z_STRVAL_P(zv), Z_STRLEN_P(zv), &lv, NULL, 0) == IS_LONG)) { - len = snprintf(buf, sizeof(buf), "%ld", lv); + len = snprintf(buf, sizeof(buf), "%lld", lv); if (len == Z_STRLEN_P(zv) && redis_strncmp(Z_STRVAL_P(zv), buf, len) == 0) { ZVAL_LONG(aux, lv); return aux; From b8b29687c18bb9249a73136f6df46f03895d602e Mon Sep 17 00:00:00 2001 From: Arshid Date: Fri, 1 May 2026 20:13:05 +0530 Subject: [PATCH 05/31] Update redis_commands.c Co-authored-by: Michael Grunder --- redis_commands.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis_commands.c b/redis_commands.c index b579bbe0ad..8c8518649a 100644 --- a/redis_commands.c +++ b/redis_commands.c @@ -2921,7 +2921,7 @@ static inline zval *coerce_hash_field(zval *zv, zval *aux) { is_numeric_string(Z_STRVAL_P(zv), Z_STRLEN_P(zv), &lv, NULL, 0) == IS_LONG)) { - len = snprintf(buf, sizeof(buf), "%lld", lv); + len = snprintf(buf, sizeof(buf), ZEND_LONG_FMT, lv); if (len == Z_STRLEN_P(zv) && redis_strncmp(Z_STRVAL_P(zv), buf, len) == 0) { ZVAL_LONG(aux, lv); return aux; From b83af6417b4cb174014d466eb8f14fce707a912a Mon Sep 17 00:00:00 2001 From: Arshid Date: Tue, 12 May 2026 08:24:02 +0530 Subject: [PATCH 06/31] Fix serialization failure handling for anonymous classes (#2838) * Fix serialization failure handling for anonymous classes --- library.c | 5 ++++- tests/RedisTest.php | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/library.c b/library.c index facd3f840f..f00c287d61 100644 --- a/library.c +++ b/library.c @@ -4328,12 +4328,15 @@ redis_serialize(RedisSock *redis_sock, zval *z, char **val, size_t *val_len) case REDIS_SERIALIZER_PHP: PHP_VAR_SERIALIZE_INIT(ht); php_var_serialize(&sstr, z, &ht); + PHP_VAR_SERIALIZE_DESTROY(ht); + if (!sstr.s) { + break; + } *val = estrndup(ZSTR_VAL(sstr.s), ZSTR_LEN(sstr.s)); *val_len = ZSTR_LEN(sstr.s); smart_str_free(&sstr); - PHP_VAR_SERIALIZE_DESTROY(ht); return 1; diff --git a/tests/RedisTest.php b/tests/RedisTest.php index ee4f05494c..e682e23f22 100644 --- a/tests/RedisTest.php +++ b/tests/RedisTest.php @@ -8911,5 +8911,19 @@ public function testDelEx() { $this->assertEquals(1, $this->redis->delex('captain', $arg)); } } + + public function testAnonymousClassSerializationFailure() { + $this->redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); + + $obj = new class() {}; + + $this->assertThrowsMatch(null, function () use ($obj) { + $this->redis->set('payload', $obj); + }, "/Serialization of 'class@anonymous' is not allowed/"); + + /* Ensure extension remains stable after failure */ + $this->assertTrue($this->redis->set('after_failure', 'ok')); + $this->assertEquals('ok', $this->redis->get('after_failure')); + } } ?> From b112875b70545df684931e71a7d3b4f6901ea62a Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 19 May 2026 15:36:57 -0400 Subject: [PATCH 07/31] session: derive lock secret from php_random_bytes_silent generate_lock_secret derived the secret from hostname plus pid, roughly 22 bits of guessable entropy and also readable from Redis under _LOCK. Replace with 16 bytes from php_random_bytes_silent, hex-encoded. Defense in depth: an attacker with write access to the Redis instance can already bypass the lock by DELing the key, so this is not a primary defense; worth fixing for the case where only the lock key itself is exposed. The hostname|pid path stays as a fallback when php_random_bytes_silent fails, so the caller always gets a non-NULL secret. --- redis_session.c | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/redis_session.c b/redis_session.c index e9cbc7f202..5e32348f76 100644 --- a/redis_session.c +++ b/redis_session.c @@ -20,6 +20,13 @@ #include "common.h" +#if PHP_VERSION_ID < 80400 +#include +#else +#include +#endif +#include + #ifdef HAVE_CONFIG_H #include "config.h" #endif @@ -331,12 +338,21 @@ static void generate_lock_key(redis_session_lock_status *status) { } static void generate_lock_secret(redis_session_lock_status *status) { + unsigned char buf[16]; char hostname[HOST_NAME_MAX] = {0}; - gethostname(hostname, HOST_NAME_MAX); if (status->lock_secret) zend_string_release(status->lock_secret); + if (php_random_bytes_silent(buf, sizeof(buf)) == SUCCESS) { + zend_string *s = zend_string_alloc(sizeof(buf) * 2, 0); + php_hash_bin2hex(ZSTR_VAL(s), buf, sizeof(buf)); + ZSTR_VAL(s)[sizeof(buf) * 2] = '\0'; + status->lock_secret = s; + return; + } + + gethostname(hostname, HOST_NAME_MAX); status->lock_secret = strpprintf(0, "%s|%ld", hostname, (long)getpid()); } From 5c6e2d2b3cd16ad29effb7036a9984339fc5bd0c Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 19 May 2026 15:34:14 -0400 Subject: [PATCH 08/31] library: replace atol with bounded strtoll for RESP length parsing atol returns undefined behavior on overflow per C11 7.22.1.4. glibc saturates to LONG_MAX, but musl, BSD libc, and Windows libc differ. Replace atol / atoi at the three RESP length parse sites in library.c with strtoll plus ERANGE rejection. The wire input is server- controlled; an out-of-range value should drop the reply rather than land an implementation-defined value in downstream length arithmetic. --- library.c | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/library.c b/library.c index f00c287d61..5ff7db3f1c 100644 --- a/library.c +++ b/library.c @@ -7,6 +7,8 @@ #include "common.h" #include "php_network.h" #include +#include +#include #ifdef HAVE_REDIS_IGBINARY #include "igbinary/igbinary.h" @@ -364,7 +366,17 @@ read_mbulk_header(RedisSock *redis_sock, int *nelem) return FAILURE; } - *nelem = atoi(line + 1); + { + char *endptr; + long long n; + + errno = 0; + n = strtoll(line + 1, &endptr, 10); + if (endptr == line + 1 || errno == ERANGE || n < -1 || n > INT_MAX) { + return FAILURE; + } + *nelem = (int)n; + } return SUCCESS; } @@ -822,7 +834,17 @@ redis_sock_read(RedisSock *redis_sock, int *buf_len) return NULL; case '$': - *buf_len = atoi(inbuf + 1); + { + char *endptr; + long long n; + + errno = 0; + n = strtoll(inbuf + 1, &endptr, 10); + if (endptr == inbuf + 1 || errno == ERANGE || n < -1 || n > INT_MAX) { + return NULL; + } + *buf_len = (int)n; + } return redis_sock_read_bulk_reply(redis_sock, *buf_len); case '*': @@ -4567,7 +4589,15 @@ redis_read_reply_type(RedisSock *redis_sock, REDIS_REPLY_TYPE *reply_type, } /* Set our size response */ - *reply_info = atol(inbuf); + { + char *endptr; + + errno = 0; + *reply_info = strtol(inbuf, &endptr, 10); + if (endptr == inbuf || errno == ERANGE) { + return -1; + } + } } else { /* Always initialize to prevent UB */ *reply_info = 0; From c67d3e493fad38e2e5751382764071636e393371 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 19 May 2026 15:34:55 -0400 Subject: [PATCH 09/31] library: redis_sock_getc returns int not char to detect EOF php_stream_getc returns int; storing into a char truncates the EOF sentinel. On unsigned-char platforms (ARM Linux, AIX, PPC) EOF == -1 becomes 0xFF after promotion and res != EOF is always true; on signed-char platforms a server byte of 0xFF is indistinguishable from real EOF. Subscribe loops can busy-loop or stall. Return int, matching the standard getc-style convention. --- library.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library.h b/library.h index e1e2cf2788..d5c30c8834 100644 --- a/library.h +++ b/library.h @@ -312,8 +312,8 @@ static inline char *redis_sock_get_line(RedisSock *redis_sock, char *buf, size_t return res; } -static inline char redis_sock_getc(RedisSock *redis_sock) { - char res; +static inline int redis_sock_getc(RedisSock *redis_sock) { + int res; res = php_stream_getc(redis_sock->stream); if (res != EOF) From fbf5affa14a7193fe6d6519339285361b2647274 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 19 May 2026 15:32:09 -0400 Subject: [PATCH 10/31] cluster: harden CLUSTER SLOTS / MOVED parsing against hostile replies Three independent hardening fixes against malicious cluster replies, shipped together because they share the same threat model and live within a few lines of each other in cluster_library.c. Reject nil-bulk hosts in CLUSTER SLOTS rows. VALIDATE_SLOTS_INNER checked type == TYPE_BULK but not str != NULL || len > 0, so a hostile seed could drive redis_sock_create(NULL, (size_t)-1) into zend_string_init's memmove from NULL. Apply the same str/len gate to the slaves loop, which previously skipped only on len == 0. Store ASK-redirect nodes in c->nodes. cluster_get_asking_node built a fresh redisClusterNode on every ASK to an unknown host but never inserted it, leaking the node plus its RedisSock, AUTH zend_strings, slaves HashTable, and persistent connection for the cluster object's lifetime. Bound the redirect port and slot. atoi-into-(unsigned short) let a hostile MOVED / ASK target reach port mod 65536; replace with strtol plus explicit range checks and reject out-of-range values. --- cluster_library.c | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/cluster_library.c b/cluster_library.c index b342bfe3e8..11829524c4 100644 --- a/cluster_library.c +++ b/cluster_library.c @@ -5,6 +5,7 @@ #include "cluster_library.h" #include "crc16.h" #include +#include extern zend_class_entry *redis_cluster_exception_ce; int le_cluster_slot_cache; @@ -687,7 +688,8 @@ cluster_node_add_slave(redisClusterNode *master, redisClusterNode *slave) r->element[1]->type == TYPE_INT) #define VALIDATE_SLOTS_INNER(r) \ (r->type == TYPE_MULTIBULK && r->elements >= 2 && \ - r->element[0]->type == TYPE_BULK && r->element[1]->type == TYPE_INT) + r->element[0]->type == TYPE_BULK && r->element[0]->str != NULL && \ + r->element[0]->len > 0 && r->element[1]->type == TYPE_INT) /* Use the output of CLUSTER SLOTS to map our nodes */ static int cluster_map_slots(redisCluster *c, clusterReply *r) { @@ -727,13 +729,12 @@ static int cluster_map_slots(redisCluster *c, clusterReply *r) { // Attach slaves first time we encounter a given master in order to avoid registering the slaves multiple times for (j = 3; j< r2->elements; j++) { r3 = r2->element[j]; + + // Skip slaves whose host bulk is missing or empty if (!VALIDATE_SLOTS_INNER(r3)) { - return -1; + continue; } - // Skip slaves where the host is "" - if (r3->element[0]->len == 0) continue; - // Attach this node to our slave slave = cluster_node_create(c, r3->element[0]->str, (int)r3->element[0]->len, @@ -788,9 +789,10 @@ static redisClusterNode *cluster_get_asking_node(redisCluster *c) { /* This host:port is unknown to us, so add it */ pNode = cluster_node_create(c, c->redir_host, c->redir_host_len, c->redir_port, c->redir_slot, 0); + zend_hash_str_update_ptr(c->nodes, key, key_len, pNode); /* Return the node */ - return pNode; + return pNode; } /* Get or create a node at the host:port we were asked to check, and return the @@ -1150,12 +1152,27 @@ static int cluster_set_redirection(redisCluster* c, char *msg, int moved) if ((port = strrchr(host, ':')) == NULL) return -1; *port++ = '\0'; + char *endptr; + long slot_val, port_val; + + errno = 0; + slot_val = strtol(msg, &endptr, 10); + if (endptr == msg || errno == ERANGE || slot_val < 0 || slot_val >= REDIS_CLUSTER_SLOTS) { + return -1; + } + + errno = 0; + port_val = strtol(port, &endptr, 10); + if (endptr == port || errno == ERANGE || port_val <= 0 || port_val > 65535) { + return -1; + } + // Success, apply it c->redir_type = moved ? REDIR_MOVED : REDIR_ASK; strncpy(c->redir_host, host, sizeof(c->redir_host) - 1); c->redir_host_len = port - host - 1; - c->redir_slot = (unsigned short)atoi(msg); - c->redir_port = (unsigned short)atoi(port); + c->redir_slot = (unsigned short)slot_val; + c->redir_port = (unsigned short)port_val; return 0; } From 2bf673c64f66de514b733259ff952ddf653199d2 Mon Sep 17 00:00:00 2001 From: michael-grunder Date: Thu, 21 May 2026 08:17:46 -0700 Subject: [PATCH 11/31] fix: Don't blindly return LZ4 header length strings Previously we were only checking if `LZ4_decompress_safe` was returning > 0 but then blindly returning to the user whatever length the header specified. This fix does two things: * Short circuits on negative length headers * Fails the decompression if the decompressed length does not match. --- library.c | 10 +++++++--- tests/RedisTest.php | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/library.c b/library.c index 5ff7db3f1c..92e6efc10f 100644 --- a/library.c +++ b/library.c @@ -4179,7 +4179,7 @@ redis_uncompress(RedisSock *redis_sock, char **dst, size_t *dstlen, const char * #ifdef HAVE_REDIS_LZ4 { char *data; - int datalen; + int datalen, res; uint8_t lz4crc; /* We must have at least enough bytes for our header, and can't have more than @@ -4197,15 +4197,19 @@ redis_uncompress(RedisSock *redis_sock, char **dst, size_t *dstlen, const char * memcpy(&datalen, copy, sizeof(int)); copy += sizeof(int); copylen -= sizeof(int); + if (datalen <= 0) + break; + /* Make sure our CRC matches (TODO: Maybe issue a docref error?) */ if (crc8((unsigned char*)&datalen, sizeof(datalen)) != lz4crc) break; /* Finally attempt decompression */ data = emalloc(datalen); - if (LZ4_decompress_safe(copy, data, copylen, datalen) > 0) { + res = LZ4_decompress_safe(copy, data, copylen, datalen); + if (res == datalen) { *dst = data; - *dstlen = datalen; + *dstlen = res; return 1; } diff --git a/tests/RedisTest.php b/tests/RedisTest.php index e682e23f22..4d673d4742 100644 --- a/tests/RedisTest.php +++ b/tests/RedisTest.php @@ -5576,15 +5576,51 @@ public function testCompressionZSTD() { $this->checkCompression(Redis::COMPRESSION_ZSTD, 9); } - public function testCompressionLZ4() { if ( ! defined('Redis::COMPRESSION_LZ4')) $this->markTestSkipped(); + $this->redis->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_LZ4); + $payload = $this->lz4PayloadWithDeclaredLength('LEAK_ME!', 4096); + $this->assertThrowsMatch($payload, function ($payload) { + $this->redis->_uncompress($payload); + }, '/Invalid compressed data or uncompression error/'); + $this->checkCompression(Redis::COMPRESSION_LZ4, 0); $this->checkCompression(Redis::COMPRESSION_LZ4, 9); } + private function lz4PayloadWithDeclaredLength($value, $declared_len) { + $len = strlen($value); + $lz4 = chr(min($len, 15) << 4); + + if ($len >= 15) { + $extra = $len - 15; + while ($extra >= 255) { + $lz4 .= "\xff"; + $extra -= 255; + } + $lz4 .= chr($extra); + } + + $declared = pack('V', $declared_len); + + return chr($this->crc8($declared)) . $declared . $lz4 . $value; + } + + private function crc8($value) { + $crc = 0xff; + + for ($i = 0; $i < strlen($value); $i++) { + $crc ^= ord($value[$i]); + for ($j = 0; $j < 8; $j++) { + $crc = $crc & 0x80 ? (($crc << 1) ^ 0x31) & 0xff : ($crc << 1) & 0xff; + } + } + + return $crc; + } + private function checkCompression($mode, $level) { $set_cmp = $this->redis->setOption(Redis::OPT_COMPRESSION, $mode); $this->assertTrue($set_cmp); From 8b1280f3cd440dc741482c355d35eace4787de49 Mon Sep 17 00:00:00 2001 From: michael-grunder Date: Thu, 21 May 2026 09:40:18 -0700 Subject: [PATCH 12/31] fix: Reject redirection hosts that cannot fit in our buffer Previously a corrupted or malicious `MOVED` response could embed a host name that was larger than the `c->redir_host` buffer which could leave it non null-terminated. Worse, `c->redir_host_len` was calculated from the too-large input which could cause subsequent use to memcpy past the end of our buffer. This fix simply hard rejects any host that we can't store in `c->redir_host` while including a null terminator. In addition we swich from a statically sized buffer in `RedisCluster::_redir` to using `zend_smart_str` --- cluster_library.c | 8 +++++++- redis_cluster.c | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/cluster_library.c b/cluster_library.c index 11829524c4..ea087a936b 100644 --- a/cluster_library.c +++ b/cluster_library.c @@ -1152,6 +1152,9 @@ static int cluster_set_redirection(redisCluster* c, char *msg, int moved) if ((port = strrchr(host, ':')) == NULL) return -1; *port++ = '\0'; + /* If the host len does not fit in our buffer we must fail */ + if (port - host - 1 >= sizeof(c->redir_host)) return -1; + char *endptr; long slot_val, port_val; @@ -1169,8 +1172,11 @@ static int cluster_set_redirection(redisCluster* c, char *msg, int moved) // Success, apply it c->redir_type = moved ? REDIR_MOVED : REDIR_ASK; - strncpy(c->redir_host, host, sizeof(c->redir_host) - 1); + c->redir_host_len = port - host - 1; + memcpy(c->redir_host, host, c->redir_host_len); + c->redir_host[c->redir_host_len] = '\0'; + c->redir_slot = (unsigned short)slot_val; c->redir_port = (unsigned short)port_val; diff --git a/redis_cluster.c b/redis_cluster.c index 71a5003e7b..e3ebf00dd5 100644 --- a/redis_cluster.c +++ b/redis_cluster.c @@ -2065,12 +2065,12 @@ PHP_METHOD(RedisCluster, _masters) { PHP_METHOD(RedisCluster, _redir) { redisCluster *c = GET_CONTEXT(); - char buf[255]; - size_t len; + smart_str s = {0}; - len = snprintf(buf, sizeof(buf), "%s:%d", c->redir_host, c->redir_port); if (*c->redir_host && c->redir_host_len) { - RETURN_STRINGL(buf, len); + smart_str_append_printf(&s, "%s:%d", c->redir_host, c->redir_port); + smart_str_0(&s); + RETURN_STR(s.s); } else { RETURN_NULL(); } From bb9e87695b9535d92a3fada820f166f46e7752a6 Mon Sep 17 00:00:00 2001 From: michael-grunder Date: Thu, 21 May 2026 13:12:56 -0700 Subject: [PATCH 13/31] fix: Harden `CLUSTER SLOTS` response parsinig 1. Make sure slot ranges are in bounds and that `high >= low` 2. Make sure any returned host lens are not >= `sizeof(c->redir_host)` so they can be stored and null terminated. 3. Make sure all returned ports are sane (0-65535). --- cluster_library.c | 69 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/cluster_library.c b/cluster_library.c index ea087a936b..2f99f30469 100644 --- a/cluster_library.c +++ b/cluster_library.c @@ -684,28 +684,45 @@ cluster_node_add_slave(redisClusterNode *master, redisClusterNode *slave) /* Sanity check/validation for CLUSTER SLOTS command */ #define VALIDATE_SLOTS_OUTER(r) \ - (r->elements >= 3 && r2->element[0]->type == TYPE_INT && \ - r->element[1]->type == TYPE_INT) + ((r)->type == TYPE_MULTIBULK && (r)->elements >= 3 && \ + (r)->element[0]->type == TYPE_INT && (r)->element[1]->type == TYPE_INT) #define VALIDATE_SLOTS_INNER(r) \ - (r->type == TYPE_MULTIBULK && r->elements >= 2 && \ - r->element[0]->type == TYPE_BULK && r->element[0]->str != NULL && \ - r->element[0]->len > 0 && r->element[1]->type == TYPE_INT) + ((r)->type == TYPE_MULTIBULK && (r)->elements >= 2 && \ + (r)->element[0]->type == TYPE_BULK && (r)->element[0]->str != NULL && \ + (r)->element[0]->len > 0 && (r)->element[1]->type == TYPE_INT) + +static zend_always_inline int +cluster_validate_slot_range(size_t low, size_t high) { + return low <= high && low < REDIS_CLUSTER_SLOTS && high < REDIS_CLUSTER_SLOTS; +} + +static zend_always_inline int +cluster_validate_port(size_t port) { + return port <= 65535; +} + +static zend_always_inline int +cluster_validate_host(char *host, size_t len) { + return len > 0 && len < sizeof(((redisCluster *)0)->redir_host) && + memchr(host, '\0', len) == NULL; +} /* Use the output of CLUSTER SLOTS to map our nodes */ static int cluster_map_slots(redisCluster *c, clusterReply *r) { redisClusterNode *pnode, *master, *slave; redisSlotRange range; - int i,j, hlen, klen; - short low, high; + int i,j, klen; + size_t low, high, hlen, port; clusterReply *r2, *r3; - unsigned short port; char *host, key[1024]; + zend_hash_clean(c->nodes); + for (i = 0; i < r->elements; i++) { // Inner response r2 = r->element[i]; - // Validate outer and master slot + // Validate outer and master slot structure if (!VALIDATE_SLOTS_OUTER(r2) || !VALIDATE_SLOTS_INNER(r2->element[2])) { return -1; } @@ -714,19 +731,32 @@ static int cluster_map_slots(redisCluster *c, clusterReply *r) { r3 = r2->element[2]; // Grab our slot range, as well as master host/port - low = (unsigned short)r2->element[0]->integer; - high = (unsigned short)r2->element[1]->integer; + low = r2->element[0]->integer; + high = r2->element[1]->integer; host = r3->element[0]->str; hlen = r3->element[0]->len; - port = (unsigned short)r3->element[1]->integer; + port = r3->element[1]->integer; + + /* Ensure slot range and port are sane and within bounds */ + if (!cluster_validate_slot_range(low, high) || + !cluster_validate_port(port) || + !cluster_validate_host(host, hlen) + ) { + return -1; + } // If the node is new, create and add to nodes. Otherwise use it. - klen = snprintf(key, sizeof(key), "%s:%d", host, port); + klen = snprintf(key, sizeof(key), "%s:%zu", host, port); + if (klen < 0 || klen >= sizeof(key)) { + return -1; + } + if ((pnode = zend_hash_str_find_ptr(c->nodes, key, klen)) == NULL) { master = cluster_node_create(c, host, hlen, port, low, 0); zend_hash_str_update_ptr(c->nodes, key, klen, master); - // Attach slaves first time we encounter a given master in order to avoid registering the slaves multiple times + // Attach slaves first time we encounter a given master in order to + // avoid registering the slaves multiple times for (j = 3; j< r2->elements; j++) { r3 = r2->element[j]; @@ -735,10 +765,15 @@ static int cluster_map_slots(redisCluster *c, clusterReply *r) { continue; } + if (!cluster_validate_port(r3->element[1]->integer) || + !cluster_validate_host(r3->element[0]->str, r3->element[0]->len) + ) { + continue; + } + // Attach this node to our slave slave = cluster_node_create(c, r3->element[0]->str, - (int)r3->element[0]->len, - (unsigned short)r3->element[1]->integer, low, 1); + r3->element[0]->len, r3->element[1]->integer, low, 1); cluster_node_add_slave(master, slave); } } else { @@ -751,7 +786,7 @@ static int cluster_map_slots(redisCluster *c, clusterReply *r) { } /* Append to our list of slot ranges */ - range.low = low; range.high = high; + range = (redisSlotRange){.low = low, .high = high}; zend_llist_add_element(&master->slots, &range); } From 806b7b3f79ace4159c69c748a85144dbb8848997 Mon Sep 17 00:00:00 2001 From: michael-grunder Date: Mon, 1 Jun 2026 10:52:38 -0700 Subject: [PATCH 14/31] Fix: typo in stub --- redis.stub.php | 2 +- redis_arginfo.h | 2 +- redis_legacy_arginfo.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/redis.stub.php b/redis.stub.php index 6d30144064..cfa6325a01 100644 --- a/redis.stub.php +++ b/redis.stub.php @@ -881,7 +881,7 @@ public function bitpos(string $key, bool $bit, int $start = 0, int $end = -1, bo * * @example * $redis->blPop('list1', 'list2', 'list3', 1.5); - * $relay->blPop(['list1', 'list2', 'list3'], 1.5); + * $redis->blPop(['list1', 'list2', 'list3'], 1.5); */ public function blPop(string|array $key_or_keys, string|float|int $timeout_or_key, mixed ...$extra_args): Redis|array|null|false; diff --git a/redis_arginfo.h b/redis_arginfo.h index 73acd29d8f..c6b7fe748b 100644 --- a/redis_arginfo.h +++ b/redis_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 3ee3118802fef67d6bd7176f2a72f0568d962c8b */ + * Stub hash: b3acb420671015ae297df9912dd2b3bc1678315e */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Redis___construct, 0, 0, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, options, IS_ARRAY, 1, "null") diff --git a/redis_legacy_arginfo.h b/redis_legacy_arginfo.h index e74e2d6c4a..ce1b5963ad 100644 --- a/redis_legacy_arginfo.h +++ b/redis_legacy_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 3ee3118802fef67d6bd7176f2a72f0568d962c8b */ + * Stub hash: b3acb420671015ae297df9912dd2b3bc1678315e */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Redis___construct, 0, 0, 0) ZEND_ARG_INFO(0, options) From 7b2fdc6de13df0007c441c5d26e44567adc0f810 Mon Sep 17 00:00:00 2001 From: michael-grunder Date: Mon, 1 Jun 2026 10:02:54 -0700 Subject: [PATCH 15/31] fix: Guard against bulk length overflow Clamp range in a couple library functions to values that can fit into an iint, since we narrow it later. A more comprehensive change that widens all of these values to 64 bits will come in a future commit. --- library.c | 23 ++++++++++++++++++++--- library.h | 2 +- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/library.c b/library.c index 92e6efc10f..1bb6fa63e6 100644 --- a/library.c +++ b/library.c @@ -778,6 +778,12 @@ redis_sock_read_bulk_reply(RedisSock *redis_sock, int bytes) char *reply; ssize_t got; + if (bytes < -1 || bytes > INT_MAX - 2) { + zend_throw_exception_ex(redis_exception_ce, 0, + "protocol error, invalid bulk length: %d", bytes); + return NULL; + } + if (-1 == bytes || -1 == redis_check_eof(redis_sock, 1, 0)) { return NULL; } @@ -4642,18 +4648,29 @@ redis_read_variant_line(RedisSock *redis_sock, REDIS_REPLY_TYPE reply_type, } PHP_REDIS_API int -redis_read_variant_bulk(RedisSock *redis_sock, int size, zval *z_ret +redis_read_variant_bulk(RedisSock *redis_sock, long size, zval *z_ret ) { + int bytes; + + if (size < -1 || size > INT_MAX - 2) { + zend_throw_exception_ex(redis_exception_ce, 0, + "protocol error, invalid bulk length: %ld", size); + ZVAL_FALSE(z_ret); + return -1; + } + + bytes = size; + // Attempt to read the bulk reply - char *bulk_resp = redis_sock_read_bulk_reply(redis_sock, size); + char *bulk_resp = redis_sock_read_bulk_reply(redis_sock, bytes); /* Set our reply to FALSE on failure, and the string on success */ if(bulk_resp == NULL) { ZVAL_FALSE(z_ret); return -1; } - ZVAL_STRINGL(z_ret, bulk_resp, size); + ZVAL_STRINGL(z_ret, bulk_resp, bytes); efree(bulk_resp); return 0; } diff --git a/library.h b/library.h index d5c30c8834..39227c6247 100644 --- a/library.h +++ b/library.h @@ -238,7 +238,7 @@ PHP_REDIS_API int redis_acl_log_reply(INTERNAL_FUNCTION_PARAMETERS, RedisSock *r */ PHP_REDIS_API int redis_read_reply_type(RedisSock *redis_sock, REDIS_REPLY_TYPE *reply_type, long *reply_info); -PHP_REDIS_API int redis_read_variant_bulk(RedisSock *redis_sock, int size, zval *z_ret); +PHP_REDIS_API int redis_read_variant_bulk(RedisSock *redis_sock, long size, zval *z_ret); PHP_REDIS_API int redis_read_multibulk_recursive(RedisSock *redis_sock, long long elements, int status_strings, zval *z_ret); PHP_REDIS_API int redis_read_variant_reply(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock, zval *z_tab, void *ctx); PHP_REDIS_API int redis_read_raw_variant_reply(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock, zval *z_tab, void *ctx); From ea8a86727e3ff1b1b660b06f0162a8cd1bb974ba Mon Sep 17 00:00:00 2001 From: michael-grunder Date: Mon, 1 Jun 2026 10:26:22 -0700 Subject: [PATCH 16/31] Internal: Add an initial `AGENTS.md` Mostly tells the llms how to build the extension, etc. --- .gitignore | 1 + AGENTS.md | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index fa1d633688..077c14b810 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ compile_commands.json doctum.phar run-tests.php vendor/ +.agents.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..87ec6e11e8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,146 @@ +## Overview + +This project is PhpRedis, a PHP extension that exposes Redis, RedisCluster, +RedisArray, and RedisSentinel APIs. + +### Project goals and constraints + +- PhpRedis is a widely used C extension loaded into production PHP processes. + Correctness and clarity are of utmost importance. The extension cannot crash + in production because it could take down an entire PHP-FPM pool and cause + customer downtime. +- After correctness, performance matters. Hot paths such as command packing, + reply parsing, serializer/compressor handling, persistent connection reuse, + and cluster routing should avoid unnecessary allocations and repeated work. +- Preserve the public PHP API and wire-protocol behavior unless the change is + intentionally compatibility-breaking. Many users rely on subtle historical + behavior. +- Prefer modular and composable code over duplicating logic. Given that this + project is in C, that can typically be done without any performance cost. +- Be careful with ownership, refcounts, persistent allocations, and error paths. + Most bugs in extensions become leaks, use-after-free defects, or process + crashes. +- The PHP API surface can be found in the stubs under `*.stub.php`; generated + arginfo headers should remain consistent with those stubs. +- This project supports optional serializers and compression backends. Changes + should account for the combinations enabled by `config.m4`, especially JSON, + igbinary, msgpack, lzf, lz4, zstd, and session support. +- For performance-critical code, it is often worth looking at codegen and + trying more than one formulation. This can be done with `objdump -d` or by + using specific compiler flags, such as `-S`. + +### C style guide + +- Do not add casts unless they are required for correctness, ABI compatibility, + aliasing, intentional narrowing, or to change arithmetic semantics (e.g. + forcing floating-point arithmetic). Avoid documentation/performative casts + that merely restate the compiler's implicit conversions. +- Keep error handling explicit and local. If a function returns ownership or + borrows memory, make that obvious from the surrounding code and naming. +- Prefer existing helpers in `library.c`, `redis_commands.c`, cluster helpers, + and session code over adding a second ad hoc implementation of the same + parsing, packing, or connection behavior. + +### Building and running the modified project + +- Before choosing a build, run, or test command, check whether `.agents.md` + exists in the repository root. If it exists, read it and follow any + user-specific local instructions in addition to this file. +- `.agents.md` is for local developer notes such as custom build scripts, + local PHP source tree layouts, debugger helpers, or machine-specific + configuration. It is intentionally ignored by git and must not be committed. +- Before building, agents should assume they need to run `phpize` and then + `./configure` from the repository root. Do not assume a checked-in + `Makefile` is current. +- Agents can assume the default `phpize`, `php-config`, and PHP development + headers are already available. +- A broad configure flow for agents is: + +```bash +phpize +./configure \ + --enable-redis \ + --enable-redis-igbinary \ + --enable-redis-msgpack \ + --enable-redis-lzf \ + --enable-redis-lz4 \ + --enable-redis-zstd \ + --with-liblz4=/usr \ + --with-libzstd=/usr +make +``` + +- Session support and JSON serializer support are enabled by default. Use + `--disable-redis-session` or `--disable-redis-json` only when a task + specifically needs those configurations. +- After `configure` completes, build with `make`. + +### Running the extension + +- For agent runs, prefer `php -n` so the process starts without any ambient + `ini` state, then load the required extensions explicitly. +- Load optional serializer extensions before `redis.so` when the build enables + them. +- A typical invocation is: + +```bash +php -n \ + -dextension=json.so \ + -dextension=igbinary.so \ + -dextension=msgpack.so \ + -dextension=.libs/redis.so \ +