diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index 80941bdb..4388a966 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -48,6 +48,7 @@ - 禁止子类用同名 `private` 属性隐藏父类私有属性;`public` / `protected` 同名声明视为同一个继承 property slot,仍须满足类型、可见性和 `readonly` 兼容性要求。 - 为避免 typed property 写入路径引入额外动态检查,native typed property 在右值类型不确定或与属性类型不一致时会退化为 `setProperty()`;部分标量赋值可能遵循 Zend 弱类型转换,而不是 AOT 默认 strict 语义。 - constructor property promotion 的运行时属性可用,但 `ReflectionProperty::isPromoted()` 目前不返回标准 PHP 结果。 +- `readonly` 使用“初始化窗口”语义,而不是 PHP 的“一次赋值”语义:属性只能在声明类自己的 `__construct()` 或 `__clone()` 中通过直接 `$this` 写入,且窗口内允许重复修改;其他方法、子类初始化方法、嵌套闭包、其他对象、引用和 `unset()` 均不可写。`__construct()` / `__clone()` 不能作为普通方法调用,合法的 `parent::__construct()` / `parent::__clone()` 初始化链除外。 ## 表达式与控制流 diff --git a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md index 0eb378bb..f5e888c5 100644 --- a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md +++ b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md @@ -76,6 +76,7 @@ These items should be documented with the exact boundary. | Reserved keyword methods such as `toArray()` | Intentional Rule | Conversion keywords are resolved before ordinary object methods to keep conversion lowering static and predictable. | | Zero-initialized fixed typed property slots | Intentional Rule / Partial | Native fixed-layout slots use their type's zero value instead of preserving every Zend uninitialized-property transition. | | Structural mutation of `std` containers during `foreach` | Intentional Rule | Native C++ iterators may be invalidated by append, insertion, erase or whole-container replacement. TypePHP rejects these operations inside the active loop while allowing non-structural element updates. | +| `readonly` initialization window | Intentional Rule | A readonly property is writable only through direct `$this` access in its declaring class' `__construct()` or `__clone()`. Repeated initialization writes are allowed there; after either initialization path it is frozen. Ordinary calls to these lifecycle methods are rejected, while lexical parent initialization chains remain valid. | ## Implementable but Currently Unsupported diff --git a/phpunit/code/clone-direct-method-call.php b/phpunit/code/clone-direct-method-call.php new file mode 100644 index 00000000..98e85b49 --- /dev/null +++ b/phpunit/code/clone-direct-method-call.php @@ -0,0 +1,13 @@ +__clone(); + } +} diff --git a/phpunit/code/clone-direct-static-call.php b/phpunit/code/clone-direct-static-call.php new file mode 100644 index 00000000..7ca1be23 --- /dev/null +++ b/phpunit/code/clone-direct-static-call.php @@ -0,0 +1,13 @@ +__construct(); +} diff --git a/phpunit/code/constructor-direct-static-call.php b/phpunit/code/constructor-direct-static-call.php new file mode 100644 index 00000000..5344feb5 --- /dev/null +++ b/phpunit/code/constructor-direct-static-call.php @@ -0,0 +1,10 @@ +integer = $integer; + $this->floating = $floating; + $this->integer += 1; + $this->floating += 1.5; + } +} diff --git a/phpunit/code/readonly-reference-assignment.php b/phpunit/code/readonly-reference-assignment.php new file mode 100644 index 00000000..ccaeb962 --- /dev/null +++ b/phpunit/code/readonly-reference-assignment.php @@ -0,0 +1,10 @@ +value =& $source; + } +} diff --git a/phpunit/code/readonly-reference-call-argument.php b/phpunit/code/readonly-reference-call-argument.php new file mode 100644 index 00000000..457c9783 --- /dev/null +++ b/phpunit/code/readonly-reference-call-argument.php @@ -0,0 +1,16 @@ +value = 1; + mutate_readonly_argument($this->value); + } +} diff --git a/phpunit/code/readonly-reference-fetch.php b/phpunit/code/readonly-reference-fetch.php new file mode 100644 index 00000000..2b2bc7e7 --- /dev/null +++ b/phpunit/code/readonly-reference-fetch.php @@ -0,0 +1,11 @@ +value = 1; + $reference =& $this->value; + } +} diff --git a/phpunit/code/readonly-write-array-dim.php b/phpunit/code/readonly-write-array-dim.php new file mode 100644 index 00000000..8c64750e --- /dev/null +++ b/phpunit/code/readonly-write-array-dim.php @@ -0,0 +1,7 @@ +value = []; } + public function change(): void { $this->value[] = 1; } +} diff --git a/phpunit/code/readonly-write-child-clone.php b/phpunit/code/readonly-write-child-clone.php new file mode 100644 index 00000000..fdfa0b27 --- /dev/null +++ b/phpunit/code/readonly-write-child-clone.php @@ -0,0 +1,19 @@ +value = 1; + } +} + +class ReadonlyCloneChild extends ReadonlyCloneParent +{ + public function __clone(): void + { + $this->value = 2; + } +} diff --git a/phpunit/code/readonly-write-child-constructor.php b/phpunit/code/readonly-write-child-constructor.php new file mode 100644 index 00000000..80711c3a --- /dev/null +++ b/phpunit/code/readonly-write-child-constructor.php @@ -0,0 +1,13 @@ +value = 1; + } +} diff --git a/phpunit/code/readonly-write-clone-closure.php b/phpunit/code/readonly-write-clone-closure.php new file mode 100644 index 00000000..b54583fc --- /dev/null +++ b/phpunit/code/readonly-write-clone-closure.php @@ -0,0 +1,19 @@ +value = 1; + } + + public function __clone(): void + { + $write = function (): void { + $this->value = 2; + }; + $write(); + } +} diff --git a/phpunit/code/readonly-write-coalesce.php b/phpunit/code/readonly-write-coalesce.php new file mode 100644 index 00000000..9f785115 --- /dev/null +++ b/phpunit/code/readonly-write-coalesce.php @@ -0,0 +1,7 @@ +value = null; } + public function change(): void { $this->value ??= 1; } +} diff --git a/phpunit/code/readonly-write-compound.php b/phpunit/code/readonly-write-compound.php new file mode 100644 index 00000000..e01ca545 --- /dev/null +++ b/phpunit/code/readonly-write-compound.php @@ -0,0 +1,7 @@ +value = 1; } + public function change(): void { $this->value += 1; } +} diff --git a/phpunit/code/readonly-write-constructor-closure.php b/phpunit/code/readonly-write-constructor-closure.php new file mode 100644 index 00000000..697b8c39 --- /dev/null +++ b/phpunit/code/readonly-write-constructor-closure.php @@ -0,0 +1,13 @@ +value = 1; + }; + $writer(); + } +} diff --git a/phpunit/code/readonly-write-foreach.php b/phpunit/code/readonly-write-foreach.php new file mode 100644 index 00000000..28260962 --- /dev/null +++ b/phpunit/code/readonly-write-foreach.php @@ -0,0 +1,10 @@ +value = 1; } + public function change(): void + { + foreach ([2] as $this->value) {} + } +} diff --git a/phpunit/code/readonly-write-increment.php b/phpunit/code/readonly-write-increment.php new file mode 100644 index 00000000..11d5469d --- /dev/null +++ b/phpunit/code/readonly-write-increment.php @@ -0,0 +1,7 @@ +value = 1; } + public function change(): void { ++$this->value; } +} diff --git a/phpunit/code/readonly-write-list.php b/phpunit/code/readonly-write-list.php new file mode 100644 index 00000000..6a574822 --- /dev/null +++ b/phpunit/code/readonly-write-list.php @@ -0,0 +1,7 @@ +value = 1; } + public function change(): void { [$this->value] = [2]; } +} diff --git a/phpunit/code/readonly-write-other-instance-clone.php b/phpunit/code/readonly-write-other-instance-clone.php new file mode 100644 index 00000000..ecd79b5f --- /dev/null +++ b/phpunit/code/readonly-write-other-instance-clone.php @@ -0,0 +1,17 @@ +value = 1; + } + + public function __clone(): void + { + $other = new self(); + $other->value = 2; + } +} diff --git a/phpunit/code/readonly-write-other-instance-constructor.php b/phpunit/code/readonly-write-other-instance-constructor.php new file mode 100644 index 00000000..c22906ae --- /dev/null +++ b/phpunit/code/readonly-write-other-instance-constructor.php @@ -0,0 +1,10 @@ +value = 1; + } +} diff --git a/phpunit/code/readonly-write-outside-constructor.php b/phpunit/code/readonly-write-outside-constructor.php new file mode 100644 index 00000000..bcf1a9ba --- /dev/null +++ b/phpunit/code/readonly-write-outside-constructor.php @@ -0,0 +1,15 @@ +value = 1; + } + + public function change(): void + { + $this->value = 2; + } +} diff --git a/phpunit/src/NativePropertyTest.php b/phpunit/src/NativePropertyTest.php index 49b0b842..c730abee 100644 --- a/phpunit/src/NativePropertyTest.php +++ b/phpunit/src/NativePropertyTest.php @@ -60,6 +60,21 @@ class NativePropertyTest extends \BaseTest $this->assertStringNotContainsString('box.attr(php_get_prop(0, _literal_strings[0], 0, _literal_strings[1]), true) +=', $code); } + public function testReadonlyPropertiesDoNotUseNativeScalarReferences(): void + { + try { + $outputFile = $this->compileNativeProperty('readonly-property-no-native-ref.php'); + } catch (TestError $e) { + $this->fail($e->getMessage()); + } + + $code = file_get_contents($outputFile); + $this->assertStringNotContainsString('typephp_static_int_ref(', $code); + $this->assertStringNotContainsString('typephp_static_float_ref(', $code); + $this->assertStringNotContainsString('_object_prop_', $code); + $this->assertStringContainsString('.attr(', $code); + } + public function testNativeIntPropertyAssignOpConvertsBitwiseNotClassConst(): void { try { diff --git a/phpunit/src/ReadonlyPropertyTest.php b/phpunit/src/ReadonlyPropertyTest.php new file mode 100644 index 00000000..30721c7f --- /dev/null +++ b/phpunit/src/ReadonlyPropertyTest.php @@ -0,0 +1,77 @@ +exec('Constructor __construct() can only be invoked by new', 'constructor-direct-method-call.php'); + $this->exec('Constructor __construct() can only be invoked by new', 'constructor-direct-static-call.php'); + } + + public function testCloneCannotBeCalledAsOrdinaryMethod(): void + { + $this->exec('Clone method __clone() can only be invoked by clone', 'clone-direct-method-call.php'); + $this->exec('Clone method __clone() can only be invoked by clone', 'clone-direct-static-call.php'); + } + + public function testWriteOutsideConstructorIsRejected(): void + { + $this->exec('Readonly property `ReadonlyWriteOutsideConstructor::$value` can only be modified in its declaring `__construct` or `__clone` method', 'readonly-write-outside-constructor.php'); + } + + public function testChildConstructorCannotWriteParentReadonlyProperty(): void + { + $this->exec('Readonly property `ReadonlyParent::$value` can only be modified in its declaring `__construct` or `__clone` method', 'readonly-write-child-constructor.php'); + } + + public function testConstructorCannotWriteReadonlyPropertyOnAnotherObject(): void + { + $this->exec('Readonly property `ReadonlyOtherInstance::$value` can only be modified on `$this`', 'readonly-write-other-instance-constructor.php'); + } + + public function testClosureInsideConstructorCannotWriteReadonlyProperty(): void + { + $this->exec('Readonly property `ReadonlyConstructorClosure::$value` can only be modified directly in `__construct` or `__clone`', 'readonly-write-constructor-closure.php'); + } + + public function testReadonlyCloneWriteRetainsLexicalRestrictions(): void + { + $this->exec( + 'Readonly property `ReadonlyCloneParent::$value` can only be modified in its declaring `__construct` or `__clone` method', + 'readonly-write-child-clone.php' + ); + $this->exec( + 'Readonly property `ReadonlyCloneClosure::$value` can only be modified directly in `__construct` or `__clone`', + 'readonly-write-clone-closure.php' + ); + $this->exec( + 'Readonly property `ReadonlyCloneOtherInstance::$value` can only be modified on `$this`', + 'readonly-write-other-instance-clone.php' + ); + } + + public function testReadonlyPropertyCannotBeAssignedByReference(): void + { + $this->exec('Cannot assign readonly property `ReadonlyReferenceAssignment::$value` by reference', 'readonly-reference-assignment.php'); + } + + public function testReadonlyPropertyCannotBeTakenByReference(): void + { + $this->exec('Cannot take reference to readonly property `ReadonlyReferenceFetch::$value`', 'readonly-reference-fetch.php'); + $this->exec('Cannot take reference to readonly property `ReadonlyReferenceCallArgument::$value`', 'readonly-reference-call-argument.php'); + } + + public function testAllReadonlyWriteFormsOutsideConstructorAreRejected(): void + { + foreach ([ + 'readonly-write-compound.php', + 'readonly-write-increment.php', + 'readonly-write-array-dim.php', + 'readonly-write-coalesce.php', + 'readonly-write-list.php', + 'readonly-write-foreach.php', + ] as $file) { + $this->exec('can only be modified in its declaring `__construct` or `__clone` method', $file); + } + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 8b148eee..def1e9ea 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1095,6 +1095,11 @@ class CompilerBase implements PropertyAccessContext return $this->method === '__construct'; } + protected function isCurrentCloneMethod(): bool + { + return $this->method === '__clone'; + } + protected function getCurrentMethodDisplayName(): string { return $this->getFullClassName() . '::' . $this->method; diff --git a/src/Entity/MethodDef.php b/src/Entity/MethodDef.php index 93eca879..cbf52344 100644 --- a/src/Entity/MethodDef.php +++ b/src/Entity/MethodDef.php @@ -25,6 +25,9 @@ class MethodDef /** Source trait, retained only for diagnostics and the __TRAIT__ constant. */ public string $traitOrigin = ''; + /** Original trait method name before a use-site alias is applied. */ + public string $traitMethod = ''; + public function __construct(int $flags, string $name) { $this->flags = $flags; diff --git a/src/Generator/CallArgumentGenerator.php b/src/Generator/CallArgumentGenerator.php index 5a36c7ec..0bb15b29 100644 --- a/src/Generator/CallArgumentGenerator.php +++ b/src/Generator/CallArgumentGenerator.php @@ -418,6 +418,9 @@ trait CallArgumentGenerator $namedArgs[$arg->name->name] = true; $byRef = ($funcName && $this->isReferenceNamedArgument($funcName, $className, $arg->name->name)) || ($preserveExistingReferences && $this->isExistingReferenceCallArg($arg)); + if ($byRef) { + $this->assertReadonlyPropertyReferenceForbidden($arg->value, $arg, false); + } $value = ($byRef || $this->isRefvalCall($arg->value) || $this->isToRefCall($arg->value)) ? $this->parseReferenceCallArgValue($arg) : $this->parseCallArgValue($arg); @@ -438,6 +441,9 @@ trait CallArgumentGenerator } $byRef = ($funcName && $this->isReferenceArgument($funcName, $className, $i)) || ($preserveExistingReferences && $this->isExistingReferenceCallArg($arg)); + if ($byRef) { + $this->assertReadonlyPropertyReferenceForbidden($arg->value, $arg, false); + } if (($funcName === 'call_user_func' || $funcName === 'call_user_func_array') && $i === 0) { $callback = $this->parseScopedCallbackArg($arg); if ($callback !== null) { diff --git a/src/Parser/ArrayExpressionTrait.php b/src/Parser/ArrayExpressionTrait.php index 819cf8cf..1a1186c4 100644 --- a/src/Parser/ArrayExpressionTrait.php +++ b/src/Parser/ArrayExpressionTrait.php @@ -106,6 +106,11 @@ trait ArrayExpressionTrait } if ($expr instanceof Expr\PropertyFetch) { + // Keep the write-policy check at the common writable-expression + // boundary as well as at assignment lowering sites. This covers + // destructuring, foreach targets, and future write forms that use + // parseWritableIdentifier() directly. + $this->preparePropertyWriteTarget($expr); return $this->parsePropertyFetchUpdate($expr); } diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 593477ba..1fe2da86 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -624,6 +624,12 @@ trait AssignOpTrait if ($def === null) { return null; } + if ($def->isReadonly()) { + // A native scalar reference mutates the property zval directly and + // bypasses Zend's readonly checks. Keep readonly properties on the + // normal attr() path so no raw scalar reference escapes the wrapper. + return null; + } $rightType = $this->detectTypeOfExpr($node->expr); if ($this->isFixedObjectProp($def) && $rightType !== Type::VAR && !$this->canAssignStaticTypeToObjectProperty($def, $rightType)) { @@ -782,6 +788,12 @@ trait AssignOpTrait $this->fatalError($expr->expr, 'Cannot take reference of a nullsafe chain'); } + // A reference would outlive the constructor-only write window and + // make later mutations invisible to the compiler. It is therefore + // forbidden on either side even inside the declaring constructor. + $this->assertReadonlyPropertyReferenceForbidden($expr->var, $expr, true); + $this->assertReadonlyPropertyReferenceForbidden($expr->expr, $expr, false); + $left = $this->parseWritableIdentifier($expr->var); // Keep this write-context form for every RHS kind. Re-parsing it as a // read later breaks append and missing-key targets such as diff --git a/src/Parser/ForeachTrait.php b/src/Parser/ForeachTrait.php index 1e0ce341..9b1db1d5 100644 --- a/src/Parser/ForeachTrait.php +++ b/src/Parser/ForeachTrait.php @@ -91,7 +91,9 @@ trait ForeachTrait return $this->getIndent() . "{$array}.offsetSet({$dim}, {$valueExpr});"; } - $valueVar = $this->parseIdentifier($node->valueVar); + $valueVar = $this->isPropertyFetch($node->valueVar) + ? $this->parseWritableIdentifier($node->valueVar) + : $this->parseIdentifier($node->valueVar); if ($node->byRef) { if (!$this->hasVar($valueVar)) { $this->addLocalVar($valueVar, Type::REF); diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 3bc1d4c3..b767d437 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -276,6 +276,25 @@ trait MethodCallTrait } return $this->genRuntimeFunctionCall($callable, $expr->args, $method, $parentClass); } + if ($method === '__construct') { + if (!$this->isConstructorImplementationContext() || $this->context->inClosure) { + $this->fatalError($expr, 'Constructor __construct() can only be invoked by new'); + } + if (empty($expr->args)) { + return 'typephp_call_parent_constructor(this_, ' . $methodPtr . ')'; + } + return 'typephp_call_parent_constructor(this_, ' . $methodPtr . ', ' + . $this->parseCallArgs($expr->args, $method, $parentClass) . ')'; + } + if ($method === '__clone') { + if (!$this->isCurrentCloneMethod() || $this->context->inClosure) { + $this->fatalError($expr, 'Clone method __clone() can only be invoked by clone'); + } + if (!empty($expr->args)) { + $this->fatalError($expr, 'Clone method __clone() does not accept arguments'); + } + return 'typephp_call_parent_clone(this_, ' . $methodPtr . ')'; + } if (empty($expr->args)) { return 'this_.call(' . $methodPtr . ')'; } @@ -303,6 +322,15 @@ trait MethodCallTrait protected function parseMethodCall(Expr\MethodCall $expr): string { + if ($this->isNamedMethod($expr->name)) { + $methodName = strtolower($expr->name->toString()); + if ($methodName === '__construct') { + $this->fatalError($expr, 'Constructor __construct() can only be invoked by new'); + } + if ($methodName === '__clone') { + $this->fatalError($expr, 'Clone method __clone() can only be invoked by clone'); + } + } if ($this->containsNullsafeChain($expr->var)) { return $this->parseNullsafeExpr($expr); } @@ -510,6 +538,12 @@ trait MethodCallTrait } } + private function isConstructorImplementationContext(): bool + { + return $this->isCurrentConstructor() + || strtolower($this->methodDef?->traitMethod ?? '') === '__construct'; + } + private function isDefinitelyObjectReceiver( Expr $receiver, string $object, @@ -573,6 +607,25 @@ trait MethodCallTrait $rtClass = ''; $class = $this->parseIdentifier($expr->class); + if ($this->isIdExpr($expr->name) && strtolower($expr->name->toString()) === '__construct') { + $isParentConstructor = $this->isNameExpr($expr->class) + && $class === 'parent' + && $this->isConstructorImplementationContext() + && !$this->context->inClosure; + if (!$isParentConstructor) { + $this->fatalError($expr, 'Constructor __construct() can only be invoked by new'); + } + } + if ($this->isIdExpr($expr->name) && strtolower($expr->name->toString()) === '__clone') { + $isParentClone = $this->isNameExpr($expr->class) + && $class === 'parent' + && $this->isCurrentCloneMethod() + && !$this->context->inClosure; + if (!$isParentClone) { + $this->fatalError($expr, 'Clone method __clone() can only be invoked by clone'); + } + } + // parent::$method() still has a lexical parent class even when the // method name itself is dynamic. Handle it before the generic dynamic // static-call branch below. diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index 68a0f025..54d2b3b8 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -147,6 +147,9 @@ trait PropertyAccessTrait protected function emitDynamicPropertyFetchAppendArray(Expr\PropertyFetch $expr, string $value, ?PropertyWriteTarget $target = null): string { + if ($this->isNativePropertyAccess($expr)) { + return $this->parseWritableIdentifier($expr) . ".newItem() = {$value}"; + } if ($this->canEmitDynamicPropertyTarget($target)) { return $this->emitDynamicPropertyTargetAppendArray($target, $value); } @@ -160,6 +163,9 @@ trait PropertyAccessTrait protected function emitDynamicPropertyFetchUpdateArray(Expr\PropertyFetch $expr, string $dim, string $value, ?PropertyWriteTarget $target = null): string { + if ($this->isNativePropertyAccess($expr)) { + return $this->parseWritableIdentifier($expr) . ".item({$dim}, true) = {$value}"; + } if ($this->canEmitDynamicPropertyTarget($target)) { return $this->emitDynamicPropertyTargetUpdateArray($target, $dim, $value); } @@ -437,7 +443,7 @@ trait PropertyAccessTrait $this->assertCanAssignObjectProperty($left, $right, 'static property'); } - protected function preparePropertyWriteTarget(NodeAbstract $left): ?PropertyWriteTarget + protected function preparePropertyWriteTarget(NodeAbstract $left, bool $checkReadonlyWrite = true): ?PropertyWriteTarget { if ($left instanceof Expr\PropertyFetch) { $objectExpr = null; @@ -449,6 +455,9 @@ trait PropertyAccessTrait if ($this->isIdExpr($left->name)) { $this->getPropertyIdentifier($left, $left->var, $left->name); $this->assertPropertySetVisibility($left); + if ($checkReadonlyWrite) { + $this->assertReadonlyPropertyWriteContext($left); + } } return new PropertyWriteTarget($left, 'object property', $objectExpr, $propertyExpr); } @@ -464,6 +473,71 @@ trait PropertyAccessTrait return null; } + /** + * TypePHP intentionally gives readonly an initialization-phase meaning: + * only the declaring class' __construct or __clone body may write its own + * property. This check is lexical; a nested closure is a different + * function and must not inherit the write privilege. + */ + private function assertReadonlyPropertyWriteContext(Expr\PropertyFetch $property): void + { + $access = $this->getNativePropertyAccess($property); + if ($access === null || !$access->getPropertyDef()->isReadonly()) { + return; + } + + $declaringClass = $access->resolution->declaringClass; + $propertyName = $this->parseIdentifier($property->name); + $display = $declaringClass . '::$' . $propertyName; + + if ($this->context->inClosure) { + $this->fatalError( + $property, + "Readonly property `{$display}` can only be modified directly in `__construct` or `__clone`" + ); + } + + if ((!$this->isCurrentConstructor() && !$this->isCurrentCloneMethod()) + || !$this->isSameClassName($this->getFullClassName(), $declaringClass)) { + $this->fatalError( + $property, + "Readonly property `{$display}` can only be modified in its declaring `__construct` or `__clone` method" + ); + } + + if (!$this->isVarExpr($property->var) || $this->parseIdentifier($property->var) !== 'this_') { + $this->fatalError( + $property, + "Readonly property `{$display}` can only be modified on `\$this`" + ); + } + } + + protected function assertReadonlyPropertyReferenceForbidden( + NodeAbstract $expr, + NodeAbstract $errorNode, + bool $assignmentTarget, + ): void { + while ($expr instanceof Expr\ArrayDimFetch) { + $expr = $expr->var; + } + if (!$expr instanceof Expr\PropertyFetch || !$this->isIdExpr($expr->name)) { + return; + } + + $this->getPropertyIdentifier($expr, $expr->var, $expr->name); + $access = $this->getNativePropertyAccess($expr); + if ($access === null || !$access->getPropertyDef()->isReadonly()) { + return; + } + + $display = $access->resolution->declaringClass . '::$' . $this->parseIdentifier($expr->name); + $message = $assignmentTarget + ? "Cannot assign readonly property `{$display}` by reference" + : "Cannot take reference to readonly property `{$display}`"; + $this->fatalError($errorNode, $message); + } + private function assertPropertySetVisibility(NodeAbstract $property): void { if ($this->isPropertyHookBackingAccess($property)) { @@ -730,7 +804,9 @@ trait PropertyAccessTrait $lines[] = $array . '.offsetUnset(' . $dim . ');'; } } elseif ($this->isPropertyFetch($var)) { - $propertyWriteTarget = $this->preparePropertyWriteTarget($var); + // unset has its own unconditional readonly diagnostic below; + // it is forbidden even while __construct is running. + $propertyWriteTarget = $this->preparePropertyWriteTarget($var, false); $object = $this->getDynamicPropertyFetchObjectExpr($var, $propertyWriteTarget); $restoreDefault = null; if ($this->isIdExpr($var->name)) { diff --git a/src/Translator.php b/src/Translator.php index bb0e5090..3de716a5 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -4630,6 +4630,7 @@ CODE; $methodDef = new MethodDef($flags, $name); $methodDef->node = $methodStmt; $methodDef->traitOrigin = (string) $methodStmt->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE, ''); + $methodDef->traitMethod = (string) $methodStmt->getAttribute(self::TRAIT_METHOD_ATTRIBUTE, ''); $this->method = $name; $this->methodDef = $methodDef; diff --git a/src/TypeSystem/NativeTypeCompatibilityTrait.php b/src/TypeSystem/NativeTypeCompatibilityTrait.php index 4fd9c467..c1dda088 100644 --- a/src/TypeSystem/NativeTypeCompatibilityTrait.php +++ b/src/TypeSystem/NativeTypeCompatibilityTrait.php @@ -162,6 +162,7 @@ trait NativeTypeCompatibilityTrait if ($argInfo->byRef) { if ($this->isReferenceWrapperCall($arg->value)) { $inner = $this->unwrapReferenceWrapperCall($arg->value, $arg); + $this->assertReadonlyPropertyReferenceForbidden($inner, $arg, false); if ($this->isVarExpr($inner)) { $arg->value = $inner; } else { @@ -171,6 +172,8 @@ trait NativeTypeCompatibilityTrait } $this->fatalError($arg, 'The refval function only accepts a variable, array element, or object property'); } + } else { + $this->assertReadonlyPropertyReferenceForbidden($arg->value, $arg, false); } if ($this->isVarExpr($arg->value)) { $var = $this->parseVariable($arg->value); diff --git a/tests/compiler/object_property/readonly-clone-initialization.phpt b/tests/compiler/object_property/readonly-clone-initialization.phpt new file mode 100644 index 00000000..bd44add7 --- /dev/null +++ b/tests/compiler/object_property/readonly-clone-initialization.phpt @@ -0,0 +1,68 @@ +--TEST-- +TypePHP readonly properties may be updated while cloning +--FILE-- +base = 1; + } + + public function __clone(): void + { + $this->base++; + $this->base += 3; + } +} + +class ReadonlyCloneValue extends ReadonlyCloneBase +{ + public readonly string $name; + public readonly array $items; + + public function __construct() + { + parent::__construct(); + $this->name = 'original'; + $this->items = [1]; + } + + public function __clone(): void + { + parent::__clone(); + $this->name = 'clone'; + $this->name .= 'd'; + $this->items[] = 2; + $this->items[0] = 10; + } +} + +function main(): void +{ + $original = new ReadonlyCloneValue(); + $copy = clone $original; + var_dump($original->base, $original->name, $original->items); + var_dump($copy->base, $copy->name, $copy->items); +} +?> +--EXPECT-- +int(1) +string(8) "original" +array(1) { + [0]=> + int(1) +} +int(5) +string(6) "cloned" +array(2) { + [0]=> + int(10) + [1]=> + int(2) +} diff --git a/tests/compiler/object_property/readonly-constructor-only.phpt b/tests/compiler/object_property/readonly-constructor-only.phpt new file mode 100644 index 00000000..be429575 --- /dev/null +++ b/tests/compiler/object_property/readonly-constructor-only.phpt @@ -0,0 +1,42 @@ +--TEST-- +TypePHP readonly properties are mutable only during their declaring constructor +--FILE-- +number = 1; + $this->number = 2; + $this->number += 3; + ++$this->number; + + $this->text = 'a'; + $this->text .= 'b'; + + $this->items = []; + $this->items[] = 10; + $this->items[0] = 20; + } +} + +function main(): void +{ + $value = new ReadonlyConstructorOnly(); + var_dump($value->number, $value->text, $value->items); +} +?> +--EXPECT-- +int(6) +string(2) "ab" +array(1) { + [0]=> + int(20) +} diff --git a/tests/compiler/object_property/readonly-dynamic-clone-call.phpt b/tests/compiler/object_property/readonly-dynamic-clone-call.phpt new file mode 100644 index 00000000..00fd7a9b --- /dev/null +++ b/tests/compiler/object_property/readonly-dynamic-clone-call.phpt @@ -0,0 +1,35 @@ +--TEST-- +Readonly clone method cannot be called dynamically +--FILE-- +value = 1; + } + + public function __clone(): void + { + $this->value = 2; + } +} + +function main(): void +{ + $value = new ReadonlyDynamicCloneCall(); + $method = '__clone'; + try { + $value->$method(); + } catch (Error $error) { + echo $error->getMessage(), "\n"; + } + var_dump($value->value); +} +?> +--EXPECT-- +Clone method ReadonlyDynamicCloneCall::__clone() can only be invoked by clone +int(1) diff --git a/tests/compiler/object_property/readonly-dynamic-constructor-call.phpt b/tests/compiler/object_property/readonly-dynamic-constructor-call.phpt new file mode 100644 index 00000000..f95e07cd --- /dev/null +++ b/tests/compiler/object_property/readonly-dynamic-constructor-call.phpt @@ -0,0 +1,30 @@ +--TEST-- +Readonly constructor cannot be called dynamically after construction +--FILE-- +value = $value; + } +} + +function main(): void +{ + $value = new ReadonlyDynamicConstructorCall(1); + $method = '__construct'; + try { + $value->$method(2); + } catch (Error $error) { + echo $error->getMessage(), "\n"; + } + var_dump($value->value); +} +?> +--EXPECT-- +Constructor ReadonlyDynamicConstructorCall::__construct() can only be invoked by new +int(1)