Skip to content

Commit 9c034f3

Browse files
committed
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
1 parent 58acc67 commit 9c034f3

File tree

5 files changed

+334
-29
lines changed

5 files changed

+334
-29
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
--TEST--
2+
Early binding should not be prevented by internal interfaces
3+
--FILE--
4+
<?php
5+
6+
// Test 1: Implicit Stringable from __toString() should not block hoisting
7+
class B1 extends A1 {}
8+
class A1 {
9+
public function __toString(): string { return 'A1'; }
10+
}
11+
$b1 = new B1();
12+
echo "Test 1 (implicit Stringable): " . $b1 . "\n";
13+
var_dump($b1 instanceof Stringable);
14+
15+
// Test 2: Explicit implements Stringable should not block hoisting
16+
class B2 extends A2 {}
17+
class A2 implements Stringable {
18+
public function __toString(): string { return 'A2'; }
19+
}
20+
$b2 = new B2();
21+
echo "Test 2 (explicit Stringable): " . $b2 . "\n";
22+
var_dump($b2 instanceof Stringable);
23+
24+
// Test 3: Countable should not block hoisting
25+
class B3 extends A3 {}
26+
class A3 implements Countable {
27+
public function count(): int { return 42; }
28+
}
29+
$b3 = new B3();
30+
echo "Test 3 (Countable): " . count($b3) . "\n";
31+
var_dump($b3 instanceof Countable);
32+
33+
// Test 4: ArrayAccess should not block hoisting
34+
class B4 extends A4 {}
35+
class A4 implements ArrayAccess {
36+
public function offsetExists(mixed $offset): bool { return $offset === 'x'; }
37+
public function offsetGet(mixed $offset): mixed { return 'val'; }
38+
public function offsetSet(mixed $offset, mixed $value): void {}
39+
public function offsetUnset(mixed $offset): void {}
40+
}
41+
$b4 = new B4();
42+
echo "Test 4 (ArrayAccess): " . $b4['x'] . "\n";
43+
var_dump($b4 instanceof ArrayAccess);
44+
45+
// Test 5: IteratorAggregate should not block hoisting
46+
class B5 extends A5 {}
47+
class A5 implements IteratorAggregate {
48+
public function getIterator(): Traversable { return new ArrayIterator([1, 2]); }
49+
}
50+
$b5 = new B5();
51+
echo "Test 5 (IteratorAggregate):";
52+
foreach ($b5 as $v) echo " $v";
53+
echo "\n";
54+
var_dump($b5 instanceof IteratorAggregate);
55+
56+
// Test 6: Multiple internal interfaces combined
57+
class B6 extends A6 {}
58+
class A6 implements Stringable, Countable {
59+
public function __toString(): string { return 'A6'; }
60+
public function count(): int { return 6; }
61+
}
62+
$b6 = new B6();
63+
echo "Test 6 (Stringable+Countable): " . $b6 . " count=" . count($b6) . "\n";
64+
var_dump($b6 instanceof Stringable && $b6 instanceof Countable);
65+
66+
// Test 7: Child with __toString() extending abstract parent with explicit Stringable
67+
class B7 extends A7 {
68+
public function __toString(): string { return 'B7'; }
69+
}
70+
abstract class A7 implements Stringable {}
71+
$b7 = new B7();
72+
echo "Test 7 (child __toString, abstract parent Stringable): " . $b7 . "\n";
73+
var_dump($b7 instanceof Stringable);
74+
75+
// Test 8: Both parent and child have __toString()
76+
class B8 extends A8 {
77+
public function __toString(): string { return 'B8'; }
78+
}
79+
class A8 {
80+
public function __toString(): string { return 'A8'; }
81+
}
82+
$b8 = new B8();
83+
echo "Test 8 (both have __toString): " . $b8 . "\n";
84+
85+
// Test 9: String casting works correctly through inheritance
86+
class B9 extends A9 {}
87+
class A9 {
88+
public function __toString(): string { return 'A9_value'; }
89+
}
90+
$cast = (string) new B9();
91+
echo "Test 9 (casting): $cast\n";
92+
var_dump($cast === 'A9_value');
93+
94+
?>
95+
--EXPECT--
96+
Test 1 (implicit Stringable): A1
97+
bool(true)
98+
Test 2 (explicit Stringable): A2
99+
bool(true)
100+
Test 3 (Countable): 42
101+
bool(true)
102+
Test 4 (ArrayAccess): val
103+
bool(true)
104+
Test 5 (IteratorAggregate): 1 2
105+
bool(true)
106+
Test 6 (Stringable+Countable): A6 count=6
107+
bool(true)
108+
Test 7 (child __toString, abstract parent Stringable): B7
109+
bool(true)
110+
Test 8 (both have __toString): B8
111+
Test 9 (casting): A9_value
112+
bool(true)

Zend/zend_compile.c

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,8 +1335,34 @@ ZEND_API zend_class_entry *zend_bind_class_in_slot(
13351335
if (UNEXPECTED(!success)) {
13361336
zend_class_entry *old_class = zend_hash_find_ptr(EG(class_table), Z_STR_P(lcname));
13371337
ZEND_ASSERT(old_class);
1338-
zend_class_redeclaration_error(E_COMPILE_ERROR, old_class);
1339-
return NULL;
1338+
if ((old_class->ce_flags & (ZEND_ACC_LINKED | ZEND_ACC_TOP_LEVEL))
1339+
== (ZEND_ACC_LINKED | ZEND_ACC_TOP_LEVEL)
1340+
&& !(ce->ce_flags & ZEND_ACC_TOP_LEVEL)
1341+
&& old_class->type == ZEND_USER_CLASS
1342+
&& !is_preloaded) {
1343+
/* A non-toplevel runtime declaration is colliding with a
1344+
* toplevel class that was early-bound at compile time. This
1345+
* is the polyfill pattern:
1346+
*
1347+
* if (PHP_VERSION_ID >= 80000) {
1348+
* class Foo extends \Bar {} // non-toplevel, runs
1349+
* return;
1350+
* }
1351+
* class Foo { ... } // toplevel, early-bound
1352+
*
1353+
* The toplevel fallback was hoisted at compile time, but the
1354+
* conditional branch is the one actually executing. Remove
1355+
* the early-bound entry and let the runtime declaration
1356+
* take its place. */
1357+
zend_hash_del(EG(class_table), Z_STR_P(lcname));
1358+
if (EXPECTED(zend_hash_set_bucket_key(EG(class_table), (Bucket*) class_table_slot, Z_STR_P(lcname)) != NULL)) {
1359+
success = true;
1360+
}
1361+
}
1362+
if (UNEXPECTED(!success)) {
1363+
zend_class_redeclaration_error(E_COMPILE_ERROR, old_class);
1364+
return NULL;
1365+
}
13401366
}
13411367

13421368
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
51405166
* breaking for the generated call.
51415167
*/
51425168
if (callback->kind == ZEND_AST_CALL
5143-
&& callback->child[0]->kind == ZEND_AST_ZVAL
5169+
&& callback->child[0]->kind == ZEND_AST_ZVAL
51445170
&& Z_TYPE_P(zend_ast_get_zval(callback->child[0])) == IS_STRING
51455171
&& zend_string_equals_literal_ci(zend_ast_get_str(callback->child[0]), "assert")) {
51465172
return FAILURE;
@@ -9527,6 +9553,50 @@ static void zend_compile_enum_backing_type(zend_class_entry *ce, zend_ast *enum_
95279553
zend_type_release(type, 0);
95289554
}
95299555

9556+
/* Check if all unresolved interfaces on a class entry are internal (built-in)
9557+
* interfaces that can be safely resolved during early binding.
9558+
*
9559+
* We use an allowlist of known-safe core interfaces rather than allowing all
9560+
* internal interfaces, because some internal interfaces have
9561+
* interface_gets_implemented callbacks that can trigger fatal errors or
9562+
* user-observable side effects at compile time:
9563+
* - Serializable: calls zend_error(E_DEPRECATED), triggering the user error
9564+
* handler which may not be set up during compilation.
9565+
* - DateTimeInterface: calls zend_error_noreturn(E_ERROR) for user classes
9566+
* that don't extend DateTime/DateTimeImmutable.
9567+
* - Throwable: calls zend_error_noreturn(E_ERROR) for user classes that
9568+
* don't extend Exception/Error.
9569+
*
9570+
* The allowed interfaces are registered during engine startup and are always
9571+
* available. Their callbacks either don't exist (Stringable, Countable) or
9572+
* only perform safe struct initialization (ArrayAccess, Iterator,
9573+
* IteratorAggregate, Traversable).
9574+
*
9575+
* Returns true if there are no interfaces, or all interfaces are in the
9576+
* known-safe allowlist. */
9577+
static bool zend_can_early_bind_interfaces(const zend_class_entry *ce) {
9578+
for (uint32_t i = 0; i < ce->num_interfaces; i++) {
9579+
zend_class_entry *iface = zend_lookup_class_ex(
9580+
ce->interface_names[i].name, ce->interface_names[i].lc_name,
9581+
ZEND_FETCH_CLASS_NO_AUTOLOAD);
9582+
if (!iface
9583+
|| iface->type != ZEND_INTERNAL_CLASS
9584+
|| !(iface->ce_flags & ZEND_ACC_INTERFACE)) {
9585+
return false;
9586+
}
9587+
/* Only allow interfaces whose callbacks are known to be safe during
9588+
* early binding. Interfaces without a callback are always safe. */
9589+
if (iface->interface_gets_implemented != NULL
9590+
&& iface != zend_ce_arrayaccess
9591+
&& iface != zend_ce_aggregate
9592+
&& iface != zend_ce_iterator
9593+
&& iface != zend_ce_traversable) {
9594+
return false;
9595+
}
9596+
}
9597+
return true;
9598+
}
9599+
95309600
static void zend_compile_class_decl(znode *result, const zend_ast *ast, bool toplevel) /* {{{ */
95319601
{
95329602
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
96489718
ce->ce_flags |= ZEND_ACC_TOP_LEVEL;
96499719
}
96509720

9651-
/* We currently don't early-bind classes that implement interfaces or use traits */
9652-
if (!ce->num_interfaces && !ce->num_traits && !ce->num_hooked_prop_variance_checks
9721+
/* We currently don't early-bind classes that use traits, enums, or that
9722+
* implement non-internal interfaces. We allow early binding when all
9723+
* interfaces are internal engine interfaces (e.g. Stringable, Countable,
9724+
* Iterator), since these are registered during engine startup and always
9725+
* available.
9726+
*
9727+
* Enums are excluded because zend_enum_register_funcs() adds arena-allocated
9728+
* internal methods (cases/from/tryFrom) that interact poorly with opcache's
9729+
* inheritance cache, matching the is_cacheable=false guard in
9730+
* zend_do_link_class(). Enums can't be extended, so forward references
9731+
* aren't an issue — they're linked at runtime via ZEND_DECLARE_CLASS. */
9732+
if (!(ce->ce_flags & ZEND_ACC_ENUM)
9733+
&& !ce->num_traits && !ce->num_hooked_prop_variance_checks
9734+
&& zend_can_early_bind_interfaces(ce)
96539735
#ifdef ZEND_OPCACHE_SHM_REATTACHMENT
9654-
/* See zend_link_hooked_object_iter(). */
9655-
&& !ce->num_hooked_props
9736+
&& !ce->num_hooked_props /* See zend_link_hooked_object_iter(). */
96569737
#endif
96579738
&& !(CG(compiler_options) & ZEND_COMPILE_WITHOUT_EXECUTION)) {
96589739
if (toplevel) {
@@ -9667,22 +9748,32 @@ static void zend_compile_class_decl(znode *result, const zend_ast *ast, bool top
96679748
return;
96689749
}
96699750
}
9670-
} else if (EXPECTED(zend_hash_add_ptr(CG(class_table), lcname, ce) != NULL)) {
9671-
zend_string_release(lcname);
9672-
zend_build_properties_info_table(ce);
9673-
zend_inheritance_check_override(ce);
9674-
ce->ce_flags |= ZEND_ACC_LINKED;
9675-
zend_observer_class_linked_notify(ce, lcname);
9676-
return;
96779751
} else {
9678-
goto link_unbound;
9752+
if (EXPECTED(zend_hash_add_ptr(CG(class_table), lcname, ce) != NULL)) {
9753+
zend_string_release(lcname);
9754+
zend_build_properties_info_table(ce);
9755+
ce->ce_flags |= ZEND_ACC_LINKED;
9756+
if (ce->num_interfaces) {
9757+
zend_early_bind_resolve_internal_interfaces(ce);
9758+
}
9759+
zend_inheritance_check_override(ce);
9760+
zend_observer_class_linked_notify(ce, lcname);
9761+
return;
9762+
}
9763+
/* If zend_hash_add_ptr failed, the class name already exists
9764+
* in the class table (e.g. polyfill pattern with two conditional
9765+
* declarations of the same class). Fall through to emit a
9766+
* runtime ZEND_DECLARE_CLASS opcode instead. */
96799767
}
96809768
} else if (!extends_ast) {
96819769
link_unbound:
96829770
/* Link unbound simple class */
96839771
zend_build_properties_info_table(ce);
9684-
zend_inheritance_check_override(ce);
96859772
ce->ce_flags |= ZEND_ACC_LINKED;
9773+
if (ce->num_interfaces) {
9774+
zend_early_bind_resolve_internal_interfaces(ce);
9775+
}
9776+
zend_inheritance_check_override(ce);
96869777
}
96879778
}
96889779

@@ -9727,8 +9818,9 @@ static void zend_compile_class_decl(znode *result, const zend_ast *ast, bool top
97279818
opline->opcode = ZEND_DECLARE_CLASS;
97289819
if (toplevel
97299820
&& (CG(compiler_options) & ZEND_COMPILE_DELAYED_BINDING)
9730-
/* We currently don't early-bind classes that implement interfaces or use traits */
9731-
&& !ce->num_interfaces && !ce->num_traits && !ce->num_hooked_prop_variance_checks
9821+
/* We currently don't early-bind classes that use traits, enums, or have non-internal interfaces */
9822+
&& !(ce->ce_flags & ZEND_ACC_ENUM)
9823+
&& zend_can_early_bind_interfaces(ce) && !ce->num_traits && !ce->num_hooked_prop_variance_checks
97329824
) {
97339825
if (!extends_ast) {
97349826
/* Use empty string for classes without parents to avoid new handler, and special

0 commit comments

Comments
 (0)