From 47ea5d5502d3516a646f9c5ecd664244f8632007 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 two pieces of internal state that array_merge() is expected to reset. The next free index: 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 internal pointer: array_merge() returns an array whose pointer is on the first element. An argument whose pointer had been moved made current($result) return a wrong element. Both were observable and made array_merge([], $a) behave differently from array_merge($a, []). array_merge_recursive() shares the same code path and had the same two bugs. 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 element count (or unset) and whose internal pointer is on the first element. 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. --- ext/standard/array.c | 19 +++++++-- .../array/array_merge_empty_array_result.phpt | 42 +++++++++++++++++++ 2 files changed, 57 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..b253088387ed 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -4275,15 +4275,26 @@ 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); + /* An array that never had an integer key keeps the ZEND_LONG_MIN + * sentinel; appending to it uses index 0. */ + zend_long next_free = ht->nNextFreeElement == ZEND_LONG_MIN ? 0 : ht->nNextFreeElement; + + /* array_merge() builds a new array: integer keys are renumbered from + * zero, the next free index is reset and the internal pointer is + * rewound to the first element. The argument may be returned as is + * only when it already behaves like the result of that code path. */ + if (HT_IS_PACKED(ht)) { + if (HT_IS_WITHOUT_HOLES(ht) && + ht->nInternalPointer == 0 && + next_free == (zend_long) ht->nNumOfElements) { RETURN_COPY(ret); } - } else { + } else if (next_free == 0 && ht->nInternalPointer == 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..769fa357f88a --- /dev/null +++ b/ext/standard/tests/array/array_merge_empty_array_result.phpt @@ -0,0 +1,42 @@ +--TEST-- +array_merge() with an empty argument returns an array with a fresh next free index and internal pointer +--FILE-- + 1]; +$b[] = 2; +unset($b[0]); +$r = array_merge([], $b); +$r[] = 3; +var_dump($r); + +$c = [10, 20, 30]; +next($c); +next($c); +var_dump(current(array_merge([], $c))); +?> +--EXPECT-- +array(3) { + [0]=> + int(1) + [1]=> + int(2) + [2]=> + int(4) +} +array(2) { + ["a"]=> + int(1) + [0]=> + int(3) +} +int(10)