From 17b07297175da750f09ccc7e28401cff618a2b0a Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Sat, 7 Mar 2026 20:07:10 +0100 Subject: [PATCH] Fix early binding blocked by internal interfaces (Stringable, Countable, etc.) When a class implements an internal interface, either implicitly (Stringable via __toString()) or explicitly (Countable, ArrayAccess, IteratorAggregate, etc.), the early binding guard in zend_compile_class_decl() rejects it because ce->num_interfaces is non-zero. This prevents hoisting, causing "Class not found" fatal errors on forward references like: class B extends A {} class A { public function __toString(): string { return ''; } } Since certain internal interfaces are registered during engine startup and are always available, they should not prevent early binding. An allowlist of known-safe core interfaces is used rather than allowing all internal interfaces, because some have interface_gets_implemented callbacks that can trigger fatal errors or user-observable side effects at compile time (e.g. DateTimeInterface, Throwable, Serializable). Changes: - Add zend_can_early_bind_interfaces() using an allowlist of known-safe internal interfaces (Stringable, Countable, ArrayAccess, Iterator, IteratorAggregate, Traversable). Interfaces without a callback are always safe; those with callbacks are only allowed if explicitly listed. - Add zend_early_bind_resolve_internal_interfaces() to resolve interface names via zend_do_implement_interfaces() during early binding, with correct handling of overlapping interface hierarchies. - Extend zend_try_early_bind() to resolve a class's own interfaces when early binding with a parent, and build the traits_and_interfaces array for opcache's inheritance cache. Also support parent_ce=NULL for no-parent classes during opcache's delayed early binding. - Update opcache's zend_accel_do_delayed_early_binding() to also bind no-parent classes with internal interfaces (empty lc_parent_name). - Handle polyfill patterns in zend_bind_class_in_slot(): when a non-toplevel runtime ZEND_DECLARE_CLASS collides with a toplevel class that was early-bound at compile time, replace the early-bound entry instead of erroring. This supports patterns like: if (PHP_VERSION_ID >= 80000) { class Foo extends \Bar {} // non-toplevel, executes return; } class Foo { ... } // toplevel, early-bound Detected via ZEND_ACC_TOP_LEVEL: old class has it, new class doesn't. Closes GH-7873 Closes GH-8323 Closes GH-19729 --- .../early_binding_internal_interfaces.phpt | 112 +++++++++++++++ Zend/zend_compile.c | 129 +++++++++++++++--- Zend/zend_inheritance.c | 105 +++++++++++++- Zend/zend_inheritance.h | 1 + ext/opcache/zend_accelerator_util_funcs.c | 17 ++- 5 files changed, 334 insertions(+), 30 deletions(-) create mode 100644 Zend/tests/early_binding_internal_interfaces.phpt diff --git a/Zend/tests/early_binding_internal_interfaces.phpt b/Zend/tests/early_binding_internal_interfaces.phpt new file mode 100644 index 0000000000000..e0c7738061282 --- /dev/null +++ b/Zend/tests/early_binding_internal_interfaces.phpt @@ -0,0 +1,112 @@ +--TEST-- +Early binding should not be prevented by internal interfaces +--FILE-- + +--EXPECT-- +Test 1 (implicit Stringable): A1 +bool(true) +Test 2 (explicit Stringable): A2 +bool(true) +Test 3 (Countable): 42 +bool(true) +Test 4 (ArrayAccess): val +bool(true) +Test 5 (IteratorAggregate): 1 2 +bool(true) +Test 6 (Stringable+Countable): A6 count=6 +bool(true) +Test 7 (child __toString, abstract parent Stringable): B7 +bool(true) +Test 8 (both have __toString): B8 +Test 9 (casting): A9_value +bool(true) diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 23db72bb4fda1..0bdd51fab49c0 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -1335,8 +1335,34 @@ ZEND_API zend_class_entry *zend_bind_class_in_slot( if (UNEXPECTED(!success)) { zend_class_entry *old_class = zend_hash_find_ptr(EG(class_table), Z_STR_P(lcname)); ZEND_ASSERT(old_class); - zend_class_redeclaration_error(E_COMPILE_ERROR, old_class); - return NULL; + if ((old_class->ce_flags & (ZEND_ACC_LINKED | ZEND_ACC_TOP_LEVEL)) + == (ZEND_ACC_LINKED | ZEND_ACC_TOP_LEVEL) + && !(ce->ce_flags & ZEND_ACC_TOP_LEVEL) + && old_class->type == ZEND_USER_CLASS + && !is_preloaded) { + /* A non-toplevel runtime declaration is colliding with a + * toplevel class that was early-bound at compile time. This + * is the polyfill pattern: + * + * if (PHP_VERSION_ID >= 80000) { + * class Foo extends \Bar {} // non-toplevel, runs + * return; + * } + * class Foo { ... } // toplevel, early-bound + * + * The toplevel fallback was hoisted at compile time, but the + * conditional branch is the one actually executing. Remove + * the early-bound entry and let the runtime declaration + * take its place. */ + zend_hash_del(EG(class_table), Z_STR_P(lcname)); + if (EXPECTED(zend_hash_set_bucket_key(EG(class_table), (Bucket*) class_table_slot, Z_STR_P(lcname)) != NULL)) { + success = true; + } + } + if (UNEXPECTED(!success)) { + zend_class_redeclaration_error(E_COMPILE_ERROR, old_class); + return NULL; + } } if (ce->ce_flags & ZEND_ACC_LINKED) { @@ -5140,7 +5166,7 @@ static zend_result zend_compile_func_array_map(znode *result, zend_ast_list *arg * breaking for the generated call. */ if (callback->kind == ZEND_AST_CALL - && callback->child[0]->kind == ZEND_AST_ZVAL + && callback->child[0]->kind == ZEND_AST_ZVAL && Z_TYPE_P(zend_ast_get_zval(callback->child[0])) == IS_STRING && zend_string_equals_literal_ci(zend_ast_get_str(callback->child[0]), "assert")) { return FAILURE; @@ -9527,6 +9553,50 @@ static void zend_compile_enum_backing_type(zend_class_entry *ce, zend_ast *enum_ zend_type_release(type, 0); } +/* Check if all unresolved interfaces on a class entry are internal (built-in) + * interfaces that can be safely resolved during early binding. + * + * We use an allowlist of known-safe core interfaces rather than allowing all + * internal interfaces, because some internal interfaces have + * interface_gets_implemented callbacks that can trigger fatal errors or + * user-observable side effects at compile time: + * - Serializable: calls zend_error(E_DEPRECATED), triggering the user error + * handler which may not be set up during compilation. + * - DateTimeInterface: calls zend_error_noreturn(E_ERROR) for user classes + * that don't extend DateTime/DateTimeImmutable. + * - Throwable: calls zend_error_noreturn(E_ERROR) for user classes that + * don't extend Exception/Error. + * + * The allowed interfaces are registered during engine startup and are always + * available. Their callbacks either don't exist (Stringable, Countable) or + * only perform safe struct initialization (ArrayAccess, Iterator, + * IteratorAggregate, Traversable). + * + * Returns true if there are no interfaces, or all interfaces are in the + * known-safe allowlist. */ +static bool zend_can_early_bind_interfaces(const zend_class_entry *ce) { + for (uint32_t i = 0; i < ce->num_interfaces; i++) { + zend_class_entry *iface = zend_lookup_class_ex( + ce->interface_names[i].name, ce->interface_names[i].lc_name, + ZEND_FETCH_CLASS_NO_AUTOLOAD); + if (!iface + || iface->type != ZEND_INTERNAL_CLASS + || !(iface->ce_flags & ZEND_ACC_INTERFACE)) { + return false; + } + /* Only allow interfaces whose callbacks are known to be safe during + * early binding. Interfaces without a callback are always safe. */ + if (iface->interface_gets_implemented != NULL + && iface != zend_ce_arrayaccess + && iface != zend_ce_aggregate + && iface != zend_ce_iterator + && iface != zend_ce_traversable) { + return false; + } + } + return true; +} + static void zend_compile_class_decl(znode *result, const zend_ast *ast, bool toplevel) /* {{{ */ { const zend_ast_decl *decl = (const zend_ast_decl *) ast; @@ -9648,11 +9718,22 @@ static void zend_compile_class_decl(znode *result, const zend_ast *ast, bool top ce->ce_flags |= ZEND_ACC_TOP_LEVEL; } - /* We currently don't early-bind classes that implement interfaces or use traits */ - if (!ce->num_interfaces && !ce->num_traits && !ce->num_hooked_prop_variance_checks + /* We currently don't early-bind classes that use traits, enums, or that + * implement non-internal interfaces. We allow early binding when all + * interfaces are internal engine interfaces (e.g. Stringable, Countable, + * Iterator), since these are registered during engine startup and always + * available. + * + * Enums are excluded because zend_enum_register_funcs() adds arena-allocated + * internal methods (cases/from/tryFrom) that interact poorly with opcache's + * inheritance cache, matching the is_cacheable=false guard in + * zend_do_link_class(). Enums can't be extended, so forward references + * aren't an issue, they're linked at runtime via ZEND_DECLARE_CLASS. */ + if (!(ce->ce_flags & ZEND_ACC_ENUM) + && !ce->num_traits && !ce->num_hooked_prop_variance_checks + && zend_can_early_bind_interfaces(ce) #ifdef ZEND_OPCACHE_SHM_REATTACHMENT - /* See zend_link_hooked_object_iter(). */ - && !ce->num_hooked_props + && !ce->num_hooked_props /* See zend_link_hooked_object_iter(). */ #endif && !(CG(compiler_options) & ZEND_COMPILE_WITHOUT_EXECUTION)) { if (toplevel) { @@ -9667,22 +9748,31 @@ static void zend_compile_class_decl(znode *result, const zend_ast *ast, bool top return; } } - } else if (EXPECTED(zend_hash_add_ptr(CG(class_table), lcname, ce) != NULL)) { - zend_string_release(lcname); - zend_build_properties_info_table(ce); - zend_inheritance_check_override(ce); - ce->ce_flags |= ZEND_ACC_LINKED; - zend_observer_class_linked_notify(ce, lcname); - return; } else { - goto link_unbound; + if (EXPECTED(zend_hash_add_ptr(CG(class_table), lcname, ce) != NULL)) { + zend_string_release(lcname); + zend_build_properties_info_table(ce); + ce->ce_flags |= ZEND_ACC_LINKED; + if (ce->num_interfaces) { + zend_early_bind_resolve_internal_interfaces(ce); + } + zend_inheritance_check_override(ce); + zend_observer_class_linked_notify(ce, lcname); + return; + } + /* If zend_hash_add_ptr failed, the class name already exists + * in the class table (e.g. polyfill pattern with two conditional + * declarations of the same class). Fall through to emit a + * runtime ZEND_DECLARE_CLASS opcode instead. */ } } else if (!extends_ast) { -link_unbound: /* Link unbound simple class */ zend_build_properties_info_table(ce); - zend_inheritance_check_override(ce); ce->ce_flags |= ZEND_ACC_LINKED; + if (ce->num_interfaces) { + zend_early_bind_resolve_internal_interfaces(ce); + } + zend_inheritance_check_override(ce); } } @@ -9727,8 +9817,9 @@ static void zend_compile_class_decl(znode *result, const zend_ast *ast, bool top opline->opcode = ZEND_DECLARE_CLASS; if (toplevel && (CG(compiler_options) & ZEND_COMPILE_DELAYED_BINDING) - /* We currently don't early-bind classes that implement interfaces or use traits */ - && !ce->num_interfaces && !ce->num_traits && !ce->num_hooked_prop_variance_checks + /* We currently don't early-bind classes that use traits, enums, or have non-internal interfaces */ + && !(ce->ce_flags & ZEND_ACC_ENUM) + && zend_can_early_bind_interfaces(ce) && !ce->num_traits && !ce->num_hooked_prop_variance_checks ) { if (!extends_ast) { /* Use empty string for classes without parents to avoid new handler, and special diff --git a/Zend/zend_inheritance.c b/Zend/zend_inheritance.c index 557a99bc871d9..a09059eb12747 100644 --- a/Zend/zend_inheritance.c +++ b/Zend/zend_inheritance.c @@ -3886,11 +3886,44 @@ static zend_always_inline bool register_early_bound_ce(zval *delayed_early_bindi return false; } +/* Resolve internal interface names to class entry pointers during early binding + * for classes without a parent. Uses zend_do_implement_interfaces() for correct + * deduplication of overlapping interface hierarchies (e.g. a class implementing + * both RecursiveIterator and Iterator). + * + * This must only be called when zend_can_early_bind_interfaces() in + * zend_compile.c returned true, guaranteeing all interfaces are internal. + * The class must already have ZEND_ACC_LINKED set. */ +void zend_early_bind_resolve_internal_interfaces(zend_class_entry *ce) +{ + ZEND_ASSERT(ce->ce_flags & ZEND_ACC_LINKED); + ZEND_ASSERT(!(ce->ce_flags & ZEND_ACC_RESOLVED_INTERFACES)); + ZEND_ASSERT(!ce->parent); + + uint32_t num_interfaces = ce->num_interfaces; + zend_class_entry **interfaces = emalloc( + sizeof(zend_class_entry *) * num_interfaces); + + for (uint32_t i = 0; i < num_interfaces; i++) { + zend_class_entry *iface = zend_lookup_class_ex( + ce->interface_names[i].name, ce->interface_names[i].lc_name, + ZEND_FETCH_CLASS_NO_AUTOLOAD); + ZEND_ASSERT(iface && iface->type == ZEND_INTERNAL_CLASS + && (iface->ce_flags & ZEND_ACC_INTERFACE)); + interfaces[i] = iface; + } + + /* zend_do_implement_interfaces() frees interface_names, sets + * ce->interfaces, ce->num_interfaces, and ZEND_ACC_RESOLVED_INTERFACES. */ + zend_do_implement_interfaces(ce, interfaces); +} + ZEND_API zend_class_entry *zend_try_early_bind(zend_class_entry *ce, zend_class_entry *parent_ce, zend_string *lcname, zval *delayed_early_binding) /* {{{ */ { inheritance_status status; zend_class_entry *proto = NULL; zend_class_entry *orig_linking_class; + zend_class_entry **traits_and_interfaces = NULL; if (ce->ce_flags & ZEND_ACC_LINKED) { ZEND_ASSERT(ce->parent == NULL); @@ -3901,12 +3934,33 @@ ZEND_API zend_class_entry *zend_try_early_bind(zend_class_entry *ce, zend_class_ return ce; } + ALLOCA_FLAG(use_heap); uint32_t is_cacheable = ce->ce_flags & ZEND_ACC_IMMUTABLE; - UPDATE_IS_CACHEABLE(parent_ce); + if (parent_ce) { + UPDATE_IS_CACHEABLE(parent_ce); + } if (is_cacheable) { if (zend_inheritance_cache_get && zend_inheritance_cache_add) { - zend_class_entry *ret = zend_inheritance_cache_get(ce, parent_ce, NULL); + /* Build traits_and_interfaces array for the inheritance cache. + * In the early-bind path num_traits is always 0, so only + * interfaces need to be resolved. */ + if (ce->num_interfaces) { + traits_and_interfaces = do_alloca( + sizeof(zend_class_entry *) * ce->num_interfaces, use_heap); + for (uint32_t i = 0; i < ce->num_interfaces; i++) { + zend_class_entry *iface = zend_lookup_class_ex( + ce->interface_names[i].name, + ce->interface_names[i].lc_name, + ZEND_FETCH_CLASS_NO_AUTOLOAD); + ZEND_ASSERT(iface && (iface->ce_flags & ZEND_ACC_INTERFACE)); + traits_and_interfaces[i] = iface; + } + } + zend_class_entry *ret = zend_inheritance_cache_get(ce, parent_ce, traits_and_interfaces); if (ret) { + if (traits_and_interfaces) { + free_alloca(traits_and_interfaces, use_heap); + } if (UNEXPECTED(!register_early_bound_ce(delayed_early_binding, lcname, ret))) { return NULL; } @@ -3921,7 +3975,14 @@ ZEND_API zend_class_entry *zend_try_early_bind(zend_class_entry *ce, zend_class_ orig_linking_class = CG(current_linking_class); CG(current_linking_class) = NULL; - status = zend_can_early_bind(ce, parent_ce); + if (parent_ce) { + status = zend_can_early_bind(ce, parent_ce); + } else { + /* No parent nothing to check compatibility against. + * This path is used for no-parent classes with internal + * interfaces (e.g. Stringable) during delayed early binding. */ + status = INHERITANCE_SUCCESS; + } CG(current_linking_class) = orig_linking_class; if (EXPECTED(status != INHERITANCE_UNRESOLVED)) { if (ce->ce_flags & ZEND_ACC_IMMUTABLE) { @@ -3953,8 +4014,33 @@ ZEND_API zend_class_entry *zend_try_early_bind(zend_class_entry *ce, zend_class_ zend_link_hooked_object_iter(ce); #endif - zend_do_inheritance_ex(ce, parent_ce, status == INHERITANCE_SUCCESS); - if (parent_ce && parent_ce->num_interfaces) { + if (parent_ce) { + zend_do_inheritance_ex(ce, parent_ce, status == INHERITANCE_SUCCESS); + } + if (ce->num_interfaces) { + /* Class has its own unresolved interfaces (e.g. implicitly added + * Stringable from __toString()). Resolve them and combine with + * parent interfaces, similar to zend_do_link_class(). */ + uint32_t num_parent_interfaces = parent_ce ? parent_ce->num_interfaces : 0; + uint32_t num_own_interfaces = ce->num_interfaces; + zend_class_entry **interfaces = emalloc( + sizeof(zend_class_entry *) * (num_own_interfaces + num_parent_interfaces)); + + if (num_parent_interfaces) { + memcpy(interfaces, parent_ce->interfaces, + sizeof(zend_class_entry *) * num_parent_interfaces); + } + + for (uint32_t i = 0; i < num_own_interfaces; i++) { + zend_class_entry *iface = zend_lookup_class_ex( + ce->interface_names[i].name, ce->interface_names[i].lc_name, + ZEND_FETCH_CLASS_NO_AUTOLOAD); + ZEND_ASSERT(iface && (iface->ce_flags & ZEND_ACC_INTERFACE)); + interfaces[num_parent_interfaces + i] = iface; + } + + zend_do_implement_interfaces(ce, interfaces); + } else if (parent_ce && parent_ce->num_interfaces) { zend_do_inherit_interfaces(ce, parent_ce); } zend_build_properties_info_table(ce); @@ -3979,7 +4065,7 @@ ZEND_API zend_class_entry *zend_try_early_bind(zend_class_entry *ce, zend_class_ zend_class_entry *new_ce; ce->inheritance_cache = NULL; - new_ce = zend_inheritance_cache_add(ce, proto, parent_ce, NULL, ht); + new_ce = zend_inheritance_cache_add(ce, proto, parent_ce, traits_and_interfaces, ht); if (new_ce) { zval *zv = zend_hash_find_known_hash(CG(class_table), lcname); ce = new_ce; @@ -3996,6 +4082,10 @@ ZEND_API zend_class_entry *zend_try_early_bind(zend_class_entry *ce, zend_class_ zend_free_recorded_errors(); } + if (traits_and_interfaces) { + free_alloca(traits_and_interfaces, use_heap); + } + if (ZSTR_HAS_CE_CACHE(ce->name)) { ZSTR_SET_CE_CACHE(ce->name, ce); } @@ -4003,6 +4093,9 @@ ZEND_API zend_class_entry *zend_try_early_bind(zend_class_entry *ce, zend_class_ return ce; } + if (traits_and_interfaces) { + free_alloca(traits_and_interfaces, use_heap); + } return NULL; } /* }}} */ diff --git a/Zend/zend_inheritance.h b/Zend/zend_inheritance.h index 7171a9385f3ba..af86a0be6f621 100644 --- a/Zend/zend_inheritance.h +++ b/Zend/zend_inheritance.h @@ -37,6 +37,7 @@ ZEND_API zend_class_entry *zend_do_link_class(zend_class_entry *ce, zend_string void zend_verify_abstract_class(zend_class_entry *ce); void zend_build_properties_info_table(zend_class_entry *ce); ZEND_API zend_class_entry *zend_try_early_bind(zend_class_entry *ce, zend_class_entry *parent_ce, zend_string *lcname, zval *delayed_early_binding); +void zend_early_bind_resolve_internal_interfaces(zend_class_entry *ce); void zend_inheritance_check_override(const zend_class_entry *ce); diff --git a/ext/opcache/zend_accelerator_util_funcs.c b/ext/opcache/zend_accelerator_util_funcs.c index 2de7854fb14c5..cc6f303e5d6c7 100644 --- a/ext/opcache/zend_accelerator_util_funcs.c +++ b/ext/opcache/zend_accelerator_util_funcs.c @@ -342,11 +342,18 @@ static void zend_accel_do_delayed_early_binding( zval *zv = zend_hash_find_known_hash(EG(class_table), early_binding->rtd_key); if (zv) { zend_class_entry *orig_ce = Z_CE_P(zv); - zend_class_entry *parent_ce = !(orig_ce->ce_flags & ZEND_ACC_LINKED) - ? zend_hash_find_ex_ptr(EG(class_table), early_binding->lc_parent_name, 1) - : NULL; - if (parent_ce || (orig_ce->ce_flags & ZEND_ACC_LINKED)) { - ce = zend_try_early_bind(orig_ce, parent_ce, early_binding->lcname, zv); + if (orig_ce->ce_flags & ZEND_ACC_LINKED) { + ce = zend_try_early_bind(orig_ce, NULL, early_binding->lcname, zv); + } else if (ZSTR_LEN(early_binding->lc_parent_name) == 0) { + /* No-parent class with internal interfaces (e.g. Stringable). + * Bind zend_try_early_bind supports parent_ce=NULL directly. */ + ce = zend_try_early_bind(orig_ce, NULL, early_binding->lcname, zv); + } else { + zend_class_entry *parent_ce = zend_hash_find_ex_ptr( + EG(class_table), early_binding->lc_parent_name, 1); + if (parent_ce) { + ce = zend_try_early_bind(orig_ce, parent_ce, early_binding->lcname, zv); + } } } if (ce && early_binding->cache_slot != (uint32_t) -1) {