Commit 4561e92fa1e for php.net

commit 4561e92fa1e7b5515aad9d2f127e52d56e159928
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Sat Mar 14 20:00:04 2026 -0400

    Fix ReflectionMethod::invoke() for first class callables (#21389)

    * Fix ReflectionMethod::invoke() crash with internal closures

    The closure identity check added in GH-21366 accessed op_array.opcodes
    unconditionally, but internal closures (e.g. var_dump(...)) use
    internal_function, not op_array. This caused undefined behavior when
    comparing closures created via first-class callable syntax on internal
    functions.

    Check the function type first: compare op_array.opcodes for user
    closures, compare the function pointer directly for internal closures.

    * Fix internal closure comparison and expand test coverage

    The previous comparison (orig_func == given_func) could never match for
    internal closures since zend_get_closure_method_def() returns a pointer
    to each closure's embedded copy. Compare function_name and scope instead.

    Also handle the mixed user/internal type case explicitly.

    Add tests for: userland first-class callables, cloned internal closures,
    and cross-type (user vs internal) closure rejection.

    * php_reflection: Simplify the Closure::__invoke() check

    ---------

    Co-authored-by: Tim Düsterhus <tim@bastelstu.be>

diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c
index 40a759043f0..ee49d2d90f5 100644
--- a/ext/reflection/php_reflection.c
+++ b/ext/reflection/php_reflection.c
@@ -3441,14 +3441,27 @@ static void reflection_method_invoke(INTERNAL_FUNCTION_PARAMETERS, int variadic)
 		}

 		/* For Closure::__invoke(), closures from different source locations have
-		 * different signatures, so we must reject those. However, closures created
-		 * from the same source (e.g. in a loop) share the same op_array and should
-		 * be allowed. Compare the underlying function pointer via op_array. */
+		 * different signatures, so we must reject those. */
 		if (obj_ce == zend_ce_closure && !Z_ISUNDEF(intern->obj)
 				&& Z_OBJ_P(object) != Z_OBJ(intern->obj)) {
 			const zend_function *orig_func = zend_get_closure_method_def(Z_OBJ(intern->obj));
 			const zend_function *given_func = zend_get_closure_method_def(Z_OBJ_P(object));
-			if (orig_func->op_array.opcodes != given_func->op_array.opcodes) {
+
+			bool same_closure = false;
+			/* Check if they are either both fake closures or they both are not. */
+			if ((orig_func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE) == (given_func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) {
+				if (orig_func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE) {
+					/* For fake closures, scope and name must match. */
+					same_closure = orig_func->common.scope == given_func->common.scope
+						&& orig_func->common.function_name == given_func->common.function_name;
+				} else {
+					/* Otherwise the opcode structure must be identical. */
+					ZEND_ASSERT(orig_func->type == ZEND_USER_FUNCTION);
+					same_closure = orig_func->op_array.opcodes == given_func->op_array.opcodes;
+				}
+			}
+
+			if (!same_closure) {
 				if (!variadic) {
 					efree(params);
 				}
diff --git a/ext/reflection/tests/gh21362.phpt b/ext/reflection/tests/gh21362.phpt
index ce7840b494e..1f5619ce779 100644
--- a/ext/reflection/tests/gh21362.phpt
+++ b/ext/reflection/tests/gh21362.phpt
@@ -43,6 +43,60 @@
 foreach ($closures as $closure) {
     var_dump($m2->invoke($closure));
 }
+
+// First-class callable of a userland function
+function my_func($x) { return "my_func: $x"; }
+function other_func($x) { return "other_func: $x"; }
+
+$mf = my_func(...);
+$mf2 = my_func(...);
+$of = other_func(...);
+
+$m3 = new ReflectionMethod($mf, '__invoke');
+var_dump($m3->invoke($mf, 'test'));
+var_dump($m3->invoke($mf2, 'test'));
+
+try {
+    $m3->invoke($of, 'test');
+    echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+    echo $e->getMessage() . "\n";
+}
+
+// Internal closures (first-class callable syntax) should also be validated
+$vd = var_dump(...);
+$pr = print_r(...);
+
+$m4 = new ReflectionMethod($vd, '__invoke');
+$m4->invoke($vd, 'internal closure OK');
+
+// Cloned internal closure is a different object but same function - should work
+$vd2 = clone $vd;
+$m4->invoke($vd2, 'cloned internal closure OK');
+
+// Different internal closure should throw
+try {
+    $m4->invoke($pr, 'should not print');
+    echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+    echo $e->getMessage() . "\n";
+}
+
+// Cross-type: userland Closure to internal Closure's invoke should throw
+try {
+    $m4->invoke($c1, 'should not print');
+    echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+    echo $e->getMessage() . "\n";
+}
+
+// Cross-type: internal Closure to userland Closure's invoke should throw
+try {
+    $m->invoke($vd, 'should not print');
+    echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+    echo $e->getMessage() . "\n";
+}
 ?>
 --EXPECT--
 c1: foo=FOO, bar=BAR
@@ -51,3 +105,11 @@
 int(0)
 int(1)
 int(2)
+string(13) "my_func: test"
+string(13) "my_func: test"
+Given Closure is not the same as the reflected Closure
+string(19) "internal closure OK"
+string(26) "cloned internal closure OK"
+Given Closure is not the same as the reflected Closure
+Given Closure is not the same as the reflected Closure
+Given Closure is not the same as the reflected Closure