diff --git a/NEWS b/NEWS index 3a5f08491611..1278ee88629a 100644 --- a/NEWS +++ b/NEWS @@ -5,6 +5,8 @@ PHP NEWS - Core: . Fixed bug GH-15375 (Nested "yield from" skips items after a valid() or next() call on the inner generator). (iliaal) + . Implemented GH-10497 (Allowed writing to object properties held in + constants). (Khaled Alam) - PDO_PGSQL: . Fixed several lazy fetch (PDO::ATTR_PREFETCH => 0) defects: an infinite diff --git a/UPGRADING b/UPGRADING index a4cc44f9a004..108bf4d94319 100644 --- a/UPGRADING +++ b/UPGRADING @@ -23,6 +23,16 @@ PHP 8.6 UPGRADE NOTES . ??/empty() on a magic property no longer call __get() when __isset() has materialised the property by writing into the property table. The freshly-written value is returned directly. isset() is unaffected. + . Writing to a property of a constant is no longer rejected at compile time + with "Cannot use temporary expression in write context". Such code now + compiles, and behaves as it does for a plain variable holding the same + value: the write succeeds if the constant holds an object, and otherwise + raises an Error at runtime (unset() on a non-object is a silent no-op). + Code that previously failed to compile therefore now runs up to the point + of the write. Enum cases are class constants, so writing to or unsetting + an enum case property now raises the usual readonly property Error at + runtime; enum case properties remain immutable. + RFC: https://wiki.php.net/rfc/const_object_property_write - Curl: . The callback registered with CURLOPT_READFUNCTION now throws a ValueError @@ -309,6 +319,18 @@ PHP 8.6 UPGRADE NOTES RFC: https://wiki.php.net/rfc/override_constants . Implemented partial function application RFC: https://wiki.php.net/rfc/partial_function_application_v2 + . Object properties may now be written through a constant holding the + object, for both global and class constants: + + const OBJ = new stdClass; + OBJ->prop = 42; + Cls::OBJ->prop = 42; + + This includes compound assignment, increment/decrement, isset()/unset(), + passing by reference, destructuring assignment, by-reference foreach and + nested property chains. The constant binding itself remains immutable, and + dimension writes on constants (e.g. CONST[0] = 1) continue to be rejected. + RFC: https://wiki.php.net/rfc/const_object_property_write - Curl: . curl_getinfo() return array now includes a new size_delivered key, which diff --git a/Zend/Optimizer/zend_optimizer.c b/Zend/Optimizer/zend_optimizer.c index 862236c923b2..d437a3fbe53e 100644 --- a/Zend/Optimizer/zend_optimizer.c +++ b/Zend/Optimizer/zend_optimizer.c @@ -280,6 +280,19 @@ bool zend_optimizer_update_op1_const(zend_op_array *op_array, case ZEND_SEPARATE: case ZEND_SEND_VAR_NO_REF: case ZEND_SEND_VAR_NO_REF_EX: + case ZEND_ASSIGN_OP: + case ZEND_ASSIGN_DIM_OP: + case ZEND_ASSIGN_OBJ: + case ZEND_ASSIGN_OBJ_OP: + case ZEND_ASSIGN_OBJ_REF: + case ZEND_UNSET_OBJ: + case ZEND_FETCH_OBJ_W: + case ZEND_FETCH_OBJ_RW: + case ZEND_FETCH_OBJ_UNSET: + case ZEND_PRE_INC_OBJ: + case ZEND_PRE_DEC_OBJ: + case ZEND_POST_INC_OBJ: + case ZEND_POST_DEC_OBJ: return false; case ZEND_CATCH: REQUIRES_STRING(val); @@ -320,10 +333,6 @@ bool zend_optimizer_update_op1_const(zend_op_array *op_array, } zend_optimizer_add_literal_string(op_array, zend_string_tolower(Z_STR_P(val))); break; - case ZEND_ASSIGN_OP: - case ZEND_ASSIGN_DIM_OP: - case ZEND_ASSIGN_OBJ_OP: - break; case ZEND_ASSIGN_STATIC_PROP_OP: case ZEND_ASSIGN_STATIC_PROP: case ZEND_ASSIGN_STATIC_PROP_REF: diff --git a/Zend/tests/enum/no-unsed-value.phpt b/Zend/tests/enum/no-unsed-value.phpt index a1cbdd43cad4..eb058cb1b26f 100644 --- a/Zend/tests/enum/no-unsed-value.phpt +++ b/Zend/tests/enum/no-unsed-value.phpt @@ -11,4 +11,7 @@ unset(Foo::Bar->value); ?> --EXPECTF-- -Fatal error: Cannot use temporary expression in write context in %s on line %d +Fatal error: Uncaught Error: Cannot unset readonly property Foo::$value in %s:%d +Stack trace: +#0 {main} + thrown in %s on line %d diff --git a/Zend/tests/gh10497.phpt b/Zend/tests/gh10497.phpt new file mode 100644 index 000000000000..bcefffe3a8b9 --- /dev/null +++ b/Zend/tests/gh10497.phpt @@ -0,0 +1,84 @@ +--TEST-- +GH-10497: Allow direct modification of object properties on constants +--FILE-- +prop = 123; +var_dump(OBJ->prop); + +OBJ->foo = 'bar'; +OBJ->baz = 456; +var_dump(OBJ->foo, OBJ->baz); + +OBJ->prop = 'overwritten'; +var_dump(OBJ->prop); + +OBJ->inner = new stdClass; +OBJ->inner->value = 999; +var_dump(OBJ->inner->value); + +OBJ->counter = 0; +OBJ->counter++; +OBJ->counter++; +OBJ->counter--; +var_dump(OBJ->counter); + +OBJ->str = 'hello'; +OBJ->str .= ' world'; +var_dump(OBJ->str); + +OBJ->temp = 'remove me'; +var_dump(isset(OBJ->temp)); +unset(OBJ->temp); +var_dump(isset(OBJ->temp)); + +var_dump(isset(OBJ->foo)); +var_dump(empty(OBJ->foo)); +var_dump(isset(OBJ->nonexistent)); +var_dump(empty(OBJ->nonexistent)); + +function incr(&$v) { $v++; } +OBJ->reftest = 10; +incr(OBJ->reftest); +var_dump(OBJ->reftest); + +OBJ->arr = []; +OBJ->arr[0] = 42; +OBJ->arr[] = 43; +var_dump(OBJ->arr); + +OBJ->coalesce ??= 42; +var_dump(OBJ->coalesce); +OBJ->coalesce ??= 43; +var_dump(OBJ->coalesce); + +const OBJS = [new stdClass]; +OBJS[0]->prop = 42; +var_dump(OBJS[0]->prop); + +?> +--EXPECT-- +int(123) +string(3) "bar" +int(456) +string(11) "overwritten" +int(999) +int(1) +string(11) "hello world" +bool(true) +bool(false) +bool(true) +bool(false) +bool(false) +bool(true) +int(11) +array(2) { + [0]=> + int(42) + [1]=> + int(43) +} +int(42) +int(42) +int(42) diff --git a/Zend/tests/gh10497_class_const.phpt b/Zend/tests/gh10497_class_const.phpt new file mode 100644 index 000000000000..4f2861009de5 --- /dev/null +++ b/Zend/tests/gh10497_class_const.phpt @@ -0,0 +1,76 @@ +--TEST-- +GH-10497: Allow direct modification of object properties on class constants +--FILE-- +prop = 123; +var_dump(C::O->prop); + +// Access via instance (static-property-like syntax on an object). +$c = new C; +$c::O->prop = 'overwritten'; +var_dump(C::O->prop); + +// Compound assignment, increment/decrement and concatenation. +C::O->counter = 0; +C::O->counter++; +C::O->counter++; +C::O->counter--; +var_dump(C::O->counter); + +C::O->str = 'hello'; +C::O->str .= ' world'; +var_dump(C::O->str); + +// Nested property chains. +C::O->inner = new stdClass; +C::O->inner->value = 999; +var_dump(C::O->inner->value); + +// isset()/unset(). +C::O->temp = 'remove me'; +var_dump(isset(C::O->temp)); +unset(C::O->temp); +var_dump(isset(C::O->temp)); + +// Passing by reference. +function incr(&$v) { $v++; } +C::O->reftest = 10; +incr(C::O->reftest); +var_dump(C::O->reftest); + +// self:: from within a method. The same backing object is reachable through +// the class constant and the global constant, so the mutation is visible. +class D { + const O = BACKING; + + public static function set(): void { + self::O->fromSelf = 'yes'; + } +} + +D::set(); +var_dump(D::O->fromSelf); +var_dump(BACKING->fromSelf); + +?> +--EXPECT-- +int(123) +string(11) "overwritten" +int(1) +string(11) "hello world" +int(999) +bool(true) +bool(false) +int(11) +string(3) "yes" +string(3) "yes" diff --git a/Zend/tests/gh10497_func_arg.phpt b/Zend/tests/gh10497_func_arg.phpt new file mode 100644 index 000000000000..047cf4cfe45f --- /dev/null +++ b/Zend/tests/gh10497_func_arg.phpt @@ -0,0 +1,19 @@ +--TEST-- +GH-10497: Passing constant object property by reference via FUNC_ARG +--FILE-- +val = 10; +modify(OBJ->val); +var_dump(OBJ->val); + +function modify(&$v) { + $v = 42; +} + +?> +--EXPECT-- +int(42) diff --git a/Zend/tests/gh10497_guardrails.phpt b/Zend/tests/gh10497_guardrails.phpt new file mode 100644 index 000000000000..a04b563b15e7 --- /dev/null +++ b/Zend/tests/gh10497_guardrails.phpt @@ -0,0 +1,9 @@ +--TEST-- +GH-10497: Guardrail - array dim write on constant still fails +--FILE-- + +--EXPECTF-- +Fatal error: Cannot use temporary expression in write context in %s on line %d diff --git a/Zend/tests/gh10497_guardrails_dim_obj.phpt b/Zend/tests/gh10497_guardrails_dim_obj.phpt new file mode 100644 index 000000000000..c6ff2f63d565 --- /dev/null +++ b/Zend/tests/gh10497_guardrails_dim_obj.phpt @@ -0,0 +1,9 @@ +--TEST-- +GH-10497: Guardrail - dim write on constant object still fails +--FILE-- + +--EXPECTF-- +Fatal error: Cannot use temporary expression in write context in %s on line %d diff --git a/Zend/tests/gh10497_guardrails_rebind.phpt b/Zend/tests/gh10497_guardrails_rebind.phpt new file mode 100644 index 000000000000..aa548b16d7d2 --- /dev/null +++ b/Zend/tests/gh10497_guardrails_rebind.phpt @@ -0,0 +1,9 @@ +--TEST-- +GH-10497: Guardrail - constant rebinding still fails +--FILE-- + +--EXPECTF-- +Parse error: syntax error, unexpected token "=" in %s on line %d diff --git a/Zend/tests/gh10497_non_object.phpt b/Zend/tests/gh10497_non_object.phpt new file mode 100644 index 000000000000..b7c6f9750436 --- /dev/null +++ b/Zend/tests/gh10497_non_object.phpt @@ -0,0 +1,114 @@ +--TEST-- +GH-10497: Writing to a property of a non-object constant reports a runtime error +--FILE-- +prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + NULL->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + PHP_INT_MAX->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +class C { + const INT = 5; + const STR = 'str'; + const ARR = [1, 2]; + + public static function fromSelf(): void { + self::INT->prop = 1; + } +} + +try { + C::INT->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + C::STR->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + C::ARR->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + C::fromSelf(); +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +// Other write contexts: compound assignment, unset() and by-reference arguments. +try { + C::INT->prop++; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + C::INT->prop .= 'x'; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +function byRef(&$v) {} + +try { + byRef(C::INT->prop); +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +// As for plain variables, unsetting a property of a non-object is a silent no-op. +unset(C::INT->prop); +echo "unset() did not error\n"; + +// Read context still substitutes constants at compile time. +var_dump(TRUE, C::INT, C::STR); +var_dump(isset(C::INT->prop)); + +try { + __COMPILER_HALT_OFFSET__->prop = 1; +} catch (Error $e) { + echo $e::class, $e->getMessage(), "\n"; +} + +__halt_compiler(); + +?> +--EXPECT-- +Attempt to assign property "prop" on true +Attempt to assign property "prop" on null +Attempt to assign property "prop" on int +Attempt to assign property "prop" on int +Attempt to assign property "prop" on string +Attempt to assign property "prop" on array +Attempt to assign property "prop" on int +Attempt to increment/decrement property "prop" on int +Attempt to assign property "prop" on int +Attempt to modify property "prop" on int +unset() did not error +bool(true) +int(5) +string(3) "str" +bool(false) +ErrorAttempt to assign property "prop" on int diff --git a/Zend/tests/gh10497_write_targets.phpt b/Zend/tests/gh10497_write_targets.phpt new file mode 100644 index 000000000000..9b544035f644 --- /dev/null +++ b/Zend/tests/gh10497_write_targets.phpt @@ -0,0 +1,97 @@ +--TEST-- +GH-10497: Destructuring and by-reference foreach targeting constant object properties +--FILE-- +arr = [1, 2, 3]; +foreach (BACKING->arr as &$v) { + $v *= 2; +} +unset($v); +var_dump(BACKING->arr); + +C::O->arr = [1, 2, 3]; +foreach (C::O->arr as &$v) { + $v *= 2; +} +unset($v); +var_dump(C::O->arr); + +// Nested chains and a dimension after the property fetch. +BACKING->inner = new stdClass; +BACKING->inner->arr = [1, 2, 3]; +foreach (BACKING->inner->arr as &$v) { + $v *= 2; +} +unset($v); +var_dump(BACKING->inner->arr); + +BACKING->matrix = [[1, 2]]; +foreach (BACKING->matrix[0] as &$v) { + $v *= 2; +} +unset($v); +var_dump(BACKING->matrix[0]); + +// Destructuring assignment. +[BACKING->p, BACKING->q] = [1, 2]; +var_dump(BACKING->p, BACKING->q); + +['k' => BACKING->keyed] = ['k' => 9]; +var_dump(BACKING->keyed); + +BACKING->list = []; +[BACKING->list[0]] = [7]; +var_dump(BACKING->list); + +[C::O->viaClassConst] = ['yes']; +var_dump(BACKING->viaClassConst); + +?> +--EXPECT-- +array(3) { + [0]=> + int(2) + [1]=> + int(4) + [2]=> + int(6) +} +array(3) { + [0]=> + int(2) + [1]=> + int(4) + [2]=> + int(6) +} +array(3) { + [0]=> + int(2) + [1]=> + int(4) + [2]=> + int(6) +} +array(2) { + [0]=> + int(2) + [1]=> + int(4) +} +int(1) +int(2) +int(9) +array(1) { + [0]=> + int(7) +} +string(3) "yes" diff --git a/Zend/tests/gh10497_write_targets_guardrail.phpt b/Zend/tests/gh10497_write_targets_guardrail.phpt new file mode 100644 index 000000000000..2bb4c0718540 --- /dev/null +++ b/Zend/tests/gh10497_write_targets_guardrail.phpt @@ -0,0 +1,9 @@ +--TEST-- +GH-10497: Guardrail - destructuring into a constant that is not reached via a property +--FILE-- + +--EXPECTF-- +Fatal error: Assignments can only happen to writable values in %s on line %d diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 91822e886684..c057d3f3542a 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -2594,6 +2594,20 @@ static void zend_short_circuiting_commit(uint32_t checkpoint, znode *result, con } } +/* Same value as ZEND_SHORT_CIRCUITING_INNER, AST kinds must not clash. */ +#define ZEND_CONST_OBJECT_FETCH 0x8000 + +static void zend_mark_const_object_fetch(zend_ast *ast) +{ + while (ast->kind == ZEND_AST_DIM) { + ast = ast->child[0]; + } + + if (ast->kind == ZEND_AST_CONST || ast->kind == ZEND_AST_CLASS_CONST) { + ast->attr |= ZEND_CONST_OBJECT_FETCH; + } +} + static void zend_emit_jmp_null(znode *obj_node, uint32_t bp_type) { uint32_t jmp_null_opnum = get_next_op_number(); @@ -2794,13 +2808,24 @@ static inline bool zend_is_unticked_stmt(const zend_ast *ast) /* {{{ */ static inline bool zend_can_write_to_variable(const zend_ast *ast) /* {{{ */ { + bool via_prop = false; + while ( ast->kind == ZEND_AST_DIM || ast->kind == ZEND_AST_PROP ) { + via_prop |= ast->kind == ZEND_AST_PROP; ast = ast->child[0]; } + /* Object properties may be written through a constant holding the object, + * as long as the write passes through at least one property fetch. A write + * to the constant itself, or to a dimension of it, still targets a + * temporary and remains illegal. */ + if (via_prop && (ast->kind == ZEND_AST_CONST || ast->kind == ZEND_AST_CLASS_CONST)) { + return true; + } + return zend_is_variable_or_call(ast) && !zend_ast_is_short_circuited(ast); } /* }}} */ @@ -3186,6 +3211,7 @@ static zend_op *zend_delayed_compile_prop(znode *result, zend_ast *ast, uint32_t * check for a nullsafe access. */ } else { zend_short_circuiting_mark_inner(obj_ast); + zend_mark_const_object_fetch(obj_ast); opline = zend_delayed_compile_var(&obj_node, obj_ast, type, false); if (opline && (opline->opcode == ZEND_FETCH_DIM_W || opline->opcode == ZEND_FETCH_DIM_RW @@ -11576,9 +11602,14 @@ static void zend_compile_array(znode *result, zend_ast *ast) /* {{{ */ } /* }}} */ -static void zend_emit_fetch_constant(znode *result, zend_string *resolved_name, bool unqualified_in_namespace) +static void zend_emit_fetch_constant(znode *result, zend_string *resolved_name, bool unqualified_in_namespace, uint8_t type) { - zend_op *opline = zend_emit_op_tmp(result, ZEND_FETCH_CONSTANT, NULL, NULL); + zend_op *opline = zend_emit_op(result, ZEND_FETCH_CONSTANT, NULL, NULL); + if (type == BP_VAR_R || type == BP_VAR_IS) { + opline->result_type = IS_TMP_VAR; + result->op_type = IS_TMP_VAR; + } + opline->op2_type = IS_CONST; if (unqualified_in_namespace) { @@ -11591,7 +11622,7 @@ static void zend_emit_fetch_constant(znode *result, zend_string *resolved_name, opline->extended_value = zend_alloc_cache_slot(); } -static void zend_compile_const(znode *result, const zend_ast *ast) /* {{{ */ +static void zend_compile_const(znode *result, const zend_ast *ast, uint8_t type) /* {{{ */ { zend_ast *name_ast = ast->child[0]; @@ -11624,19 +11655,19 @@ static void zend_compile_const(znode *result, const zend_ast *ast) /* {{{ */ } zend_emit_fetch_constant(result, resolved_name, - !is_fully_qualified && FC(current_namespace)); + !is_fully_qualified && FC(current_namespace), type); } /* }}} */ -static void zend_compile_constant(znode *result, zend_ast *ast) +static void zend_compile_constant(znode *result, zend_ast *ast, uint8_t type) { zend_string *name = zend_ast_get_constant_name(ast); zend_emit_fetch_constant(result, zend_string_copy(name), - (ast->attr & IS_CONSTANT_UNQUALIFIED_IN_NAMESPACE) != 0); + (ast->attr & IS_CONSTANT_UNQUALIFIED_IN_NAMESPACE) != 0, type); } -static void zend_compile_class_const(znode *result, zend_ast *ast) /* {{{ */ +static void zend_compile_class_const(znode *result, zend_ast *ast, uint8_t type) /* {{{ */ { zend_ast *class_ast; zend_ast *const_ast; @@ -11667,7 +11698,11 @@ static void zend_compile_class_const(znode *result, zend_ast *ast) /* {{{ */ zend_compile_expr(&const_node, const_ast); - opline = zend_emit_op_tmp(result, ZEND_FETCH_CLASS_CONSTANT, NULL, &const_node); + opline = zend_emit_op(result, ZEND_FETCH_CLASS_CONSTANT, NULL, &const_node); + if (type == BP_VAR_R || type == BP_VAR_IS) { + opline->result_type = IS_TMP_VAR; + result->op_type = IS_TMP_VAR; + } zend_set_class_name_op1(opline, &class_node); @@ -12517,13 +12552,13 @@ static void zend_compile_expr_inner(znode *result, zend_ast *ast) /* {{{ */ zend_compile_array(result, ast); return; case ZEND_AST_CONST: - zend_compile_const(result, ast); + zend_compile_const(result, ast, BP_VAR_R); return; case ZEND_AST_CONSTANT: - zend_compile_constant(result, ast); + zend_compile_constant(result, ast, BP_VAR_R); return; case ZEND_AST_CLASS_CONST: - zend_compile_class_const(result, ast); + zend_compile_class_const(result, ast, BP_VAR_R); return; case ZEND_AST_CLASS_NAME: zend_compile_class_name(result, ast); @@ -12665,9 +12700,31 @@ static zend_op *zend_delayed_compile_var(znode *result, zend_ast *ast, uint32_t } case ZEND_AST_STATIC_PROP: return zend_compile_static_prop(result, ast, type, by_ref, true); - default: - return zend_compile_var(result, ast, type, false); + case ZEND_AST_CONST: + if (!(ast->attr & ZEND_CONST_OBJECT_FETCH)) { + break; + } + zend_compile_const(result, ast, type); + if (!(type == BP_VAR_R || type == BP_VAR_IS) && result->op_type == IS_CONST) { + znode op1 = *result; + /* Intentionally IS_VAR result. */ + zend_emit_op(result, ZEND_QM_ASSIGN, &op1, NULL); + } + return NULL; + case ZEND_AST_CLASS_CONST: + if (!(ast->attr & ZEND_CONST_OBJECT_FETCH)) { + break; + } + zend_compile_class_const(result, ast, type); + if (!(type == BP_VAR_R || type == BP_VAR_IS) && result->op_type == IS_CONST) { + znode op1 = *result; + /* Intentionally IS_VAR result. */ + zend_emit_op(result, ZEND_QM_ASSIGN, &op1, NULL); + } + return NULL; } + + return zend_compile_var(result, ast, type, false); } /* }}} */