diff --git a/package.xml b/package.xml
index dc6498e..9555682 100644
--- a/package.xml
+++ b/package.xml
@@ -16,11 +16,11 @@
nicolas@brousse.info
yes
- 2026-08-22
+ 2026-08-23
- 2.7.0
- 2.7.0
+ 2.7.1
+ 2.7.1
stable
@@ -28,18 +28,10 @@
PHP
-- FEATURE: Native cache and TTL storage engine -- Judy::STRING_TO_ENTRY (type 11).
- Stores values with TTL timestamps (uint32) and 16-bit metadata flags (uint16_t)
- directly packed into native C struct entries (judy_cache_entry_t) in the
- JudySL radix trie without PHP array wrappers or secondary lookup indexes (#188).
-- FEATURE: Native cache entry methods -- set(), get(), pruneExpired(), getEntry(),
- getExpiry(), and getFlags(). pruneExpired(?int $now = null) performs a single-pass
- trie sweep directly in C with 0 PHP heap allocations (12.9 ms per 100k items).
-- FEATURE: Full bulk and iteration support on STRING_TO_ENTRY -- ArrayAccess,
- Iterator, keys(), values(), toArray(), slice(), getAll(), and serialization.
-- BUILD: Pure-Rust Expanse backend integration support (--with-expanse) for
- modern 64-bit microarchitectures, zero-leak memory management, and cross-platform
- MSVC Windows support without source patching.
+- SECURITY FIX: Guard against destructor re-entrancy and heap Use-After-Free (UAF)
+ in Judy::STRING_TO_ENTRY during ArrayAccess overwrites, Judy::set(), and
+ Judy::pruneExpired() sweeps (#198). Commits new slot payload prior to invoking
+ zval_ptr_dtor(), and unlinks keys from the JudySL trie before freeing entry structs.
@@ -319,6 +311,7 @@
+
@@ -445,6 +438,31 @@
+
+ 2026-08-22
+
+ 2.7.0
+ 2.7.0
+
+
+ stable
+ stable
+
+
+- FEATURE: Native cache and TTL storage engine -- Judy::STRING_TO_ENTRY (type 11).
+ Stores values with TTL timestamps (uint32) and 16-bit metadata flags (uint16_t)
+ directly packed into native C struct entries (judy_cache_entry_t) in the
+ JudySL radix trie without PHP array wrappers or secondary lookup indexes (#188).
+- FEATURE: Native cache entry methods -- set(), get(), pruneExpired(), getEntry(),
+ getExpiry(), and getFlags(). pruneExpired(?int $now = null) performs a single-pass
+ trie sweep directly in C with 0 PHP heap allocations (12.9 ms per 100k items).
+- FEATURE: Full bulk and iteration support on STRING_TO_ENTRY -- ArrayAccess,
+ Iterator, keys(), values(), toArray(), slice(), getAll(), and serialization.
+- BUILD: Pure-Rust Expanse backend integration support (--with-expanse) for
+ modern 64-bit microarchitectures, zero-leak memory management, and cross-platform
+ MSVC Windows support without source patching.
+
+
2026-08-19
diff --git a/php_judy.c b/php_judy.c
index d33e45d..d5714ff 100644
--- a/php_judy.c
+++ b/php_judy.c
@@ -1153,10 +1153,12 @@ int judy_object_write_dimension_helper(zval *object, zval *offset, zval *value)
}
judy_cache_entry_t *entry = (judy_cache_entry_t *)(uintptr_t)(*slot);
if (entry != NULL) {
- zval_ptr_dtor(&entry->value);
+ zval old_val;
+ ZVAL_COPY_VALUE(&old_val, &entry->value);
entry->expires_at = 0;
entry->flags = 0;
ZVAL_COPY(&entry->value, value);
+ zval_ptr_dtor(&old_val);
} else {
entry = (judy_cache_entry_t *)emalloc(sizeof(judy_cache_entry_t));
entry->expires_at = 0;
@@ -5984,11 +5986,13 @@ PHP_METHOD(Judy, set)
entry = (judy_cache_entry_t *)(uintptr_t)(*slot);
if (entry != NULL) {
- /* Overwrite existing entry */
- zval_ptr_dtor(&entry->value);
+ /* Overwrite existing entry: update new state before destroying old value */
+ zval old_val;
+ ZVAL_COPY_VALUE(&old_val, &entry->value);
entry->expires_at = expires_at;
entry->flags = (uint16_t)flags;
ZVAL_COPY(&entry->value, value);
+ zval_ptr_dtor(&old_val);
} else {
/* Allocate new entry */
entry = (judy_cache_entry_t *)emalloc(sizeof(judy_cache_entry_t));
@@ -6107,15 +6111,15 @@ PHP_METHOD(Judy, pruneExpired)
memcpy(key_to_del, kindex, klen);
key_to_del[klen] = '\0';
- zval_ptr_dtor(&entry->value);
- efree(entry);
-
- /* Delete the key from JudySL */
+ /* Delete the key from JudySL before running destructor */
JSLD(Rc_int, intern->array, key_to_del);
intern->counter--;
judy_string_bytes_sub(intern, (Word_t)klen);
pruned_count++;
+ zval_ptr_dtor(&entry->value);
+ efree(entry);
+
/* Find the next key strictly greater than key_to_del */
JSLN(PValue, intern->array, key_to_del);
if (PValue != NULL && PValue != PJERR) {
diff --git a/php_judy.h b/php_judy.h
index e6f731b..412fb6f 100644
--- a/php_judy.h
+++ b/php_judy.h
@@ -19,7 +19,7 @@
#ifndef PHP_JUDY_H
#define PHP_JUDY_H
-#define PHP_JUDY_VERSION "2.7.0"
+#define PHP_JUDY_VERSION "2.7.1"
#define PHP_JUDY_EXTNAME "judy"
/* Windows x64 (LLP64): Force 64-bit Word_t to match libjudy ABI.
diff --git a/tests/regression_string_to_entry_reentrancy_001.phpt b/tests/regression_string_to_entry_reentrancy_001.phpt
new file mode 100644
index 0000000..f1a5886
--- /dev/null
+++ b/tests/regression_string_to_entry_reentrancy_001.phpt
@@ -0,0 +1,50 @@
+--TEST--
+Regression: Judy STRING_TO_ENTRY destructor reentrancy and UAF safety
+--SKIPIF--
+
+--FILE--
+judy[$this->key]);
+ }
+}
+
+class PruneEvilObject {
+ public function __construct(public Judy $judy) {}
+ public function __destruct() {
+ // Reentrantly access/prune the cache
+ $this->judy->pruneExpired();
+ }
+}
+
+// 1. ArrayAccess overwrite reentrancy
+$j = new Judy(Judy::STRING_TO_ENTRY);
+$j["k"] = new EvilObject($j, "k");
+$j["k"] = 42;
+var_dump(isset($j["k"]));
+
+// 2. Judy::set() overwrite reentrancy
+$j2 = new Judy(Judy::STRING_TO_ENTRY);
+$j2->set("token", new EvilObject($j2, "token"), 300);
+$j2->set("token", "new_token_payload", 300);
+var_dump(isset($j2["token"]));
+
+// 3. Judy::pruneExpired() reentrancy
+$j3 = new Judy(Judy::STRING_TO_ENTRY);
+$j3->set("expired_item", new PruneEvilObject($j3), 1);
+sleep(2);
+$evicted = $j3->pruneExpired();
+var_dump($evicted >= 1);
+var_dump(count($j3));
+
+echo "OK\n";
+?>
+--EXPECT--
+bool(false)
+bool(false)
+bool(true)
+int(0)
+OK