From f7b26a5221e3aa97d7da4ded9eb63eeeab2ba917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sun, 18 Oct 2026 00:08:26 +0200 Subject: [PATCH] ext/standard: Fix array_merge() with an empty array argument When one of the two arguments of array_merge() is empty, the other one is returned as is instead of being copied. The returned array is the argument itself, shared through copy-on-write, so it also kept the next free index of the argument. array_merge() renumbers integer keys from zero, so the next append to the result must use the element count. An argument whose next free index was stale, for example after unset() of its last element, made $result[] = $value use a wrong key. The shortcut now applies only when the argument already behaves like the result of the general code path: a packed array without holes, or an array with string keys only, whose next free index is the one the general path would produce. Everything else goes through the general path, which builds a fresh array. Clean arrays are still returned as is, so the copy is still avoided. array_merge_recursive() shares the same code path and had the same bug. --- ext/standard/array.c | 13 ++++--- .../array/array_merge_empty_array_result.phpt | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 ext/standard/tests/array/array_merge_empty_array_result.phpt diff --git a/ext/standard/array.c b/ext/standard/array.c index 0b184efc3998..504609f4c216 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -4275,15 +4275,20 @@ static zend_always_inline void php_array_merge_wrapper(INTERNAL_FUNCTION_PARAMET ret = &args[0]; } if (ret) { - if (HT_IS_PACKED(Z_ARRVAL_P(ret))) { - if (HT_IS_WITHOUT_HOLES(Z_ARRVAL_P(ret))) { + HashTable *ht = Z_ARRVAL_P(ret); + /* ZEND_LONG_MIN means no integer key yet, the next append uses index 0. */ + zend_long next_free = ht->nNextFreeElement == ZEND_LONG_MIN ? 0 : ht->nNextFreeElement; + + /* array_merge() renumbers integer keys from 0, so the next free index must match. */ + if (HT_IS_PACKED(ht)) { + if (HT_IS_WITHOUT_HOLES(ht) && next_free == (zend_long) ht->nNumOfElements) { RETURN_COPY(ret); } - } else { + } else if (next_free == 0) { bool copy = true; zend_string *string_key; - ZEND_HASH_MAP_FOREACH_STR_KEY(Z_ARRVAL_P(ret), string_key) { + ZEND_HASH_MAP_FOREACH_STR_KEY(ht, string_key) { if (!string_key) { copy = false; break; diff --git a/ext/standard/tests/array/array_merge_empty_array_result.phpt b/ext/standard/tests/array/array_merge_empty_array_result.phpt new file mode 100644 index 000000000000..bc480c884b7b --- /dev/null +++ b/ext/standard/tests/array/array_merge_empty_array_result.phpt @@ -0,0 +1,34 @@ +--TEST-- +array_merge() with an empty argument returns an array with a fresh next free index +--FILE-- + 1]; +$b[] = 2; +unset($b[0]); +$r = array_merge([], $b); +$r[] = 3; +var_dump($r); +?> +--EXPECT-- +array(3) { + [0]=> + int(1) + [1]=> + int(2) + [2]=> + int(4) +} +array(2) { + ["a"]=> + int(1) + [0]=> + int(3) +}