Commit eb0d984
committed
feature #630 [DeepClone] Support closures declared in constant expressions (nicolas-grekas)
This PR was merged into the 1.x branch.
Discussion
----------
[DeepClone] Support closures declared in constant expressions
| Q | A
| ------------- | ---
| Branch? | 1.x
| Bug fix? | no
| New feature? | yes
| Deprecations? | no
| Issues | symfony/symfony#63228
| License | MIT
PHP 8.5 allows anonymous closures in constant expressions: attribute arguments, class constants, property and parameter defaults, property hooks. Frameworks now meet them when reading attributes (e.g. `#[Assert\When(static function () { ... })]`) and can no longer cache the resulting metadata, since closures refuse `serialize()` and `deepclone_to_array()` alike.
Const-expr closures are compile-time checked to be static and capture-free, so they are fully described by their declaration site. `deepclone_to_array()` now encodes them under the new mask marker `1` as `[class, site, attrIndex|null, closureIndex, startLine]`, where `site` is `""` (the class), `"NAME"` (constant or enum case), `"$name"` (property), `"name()"` (method), `"name()#N"` (parameter) or `"$name::get()"` / `"$name::set()#N"` (property hooks). `deepclone_from_array()` re-evaluates the addressed constant expression through reflection, picks the Nth closure of a depth-first walk (arrays in order, objects through their array-cast properties) and verifies the declaration line still matches, so payloads that outlived a code change fail loudly (`stale payload`) instead of resolving to a moved closure.
- **Identification.** The extension matches literals by op_array identity; userland cannot, so the polyfill identifies them by closure name, file, line span and signature over a per-class index of every const-expr site. The closure name encodes nesting and method context, and a token-level count of closure literals per source line guards the remaining aliasing case: a closure created at runtime never silently resolves to a const-expr literal, declaration sites the polyfill cannot tell apart are refused. The same literal reached through several surfaces (an attribute argument referencing a class constant, a trait alias, a promoted default applied by a constructor evaluated elsewhere) resolves to the first site that exposes it; promoted properties are addressed through their constructor parameter, the canonical surface.
- **Source-less deployments.** The aliasing guard needs to read the declaring file for method- and hook-scoped closures. When the file is not there (deployments shipping only precompiled opcodes), the refusal says so explicitly and points to the extension, which matches literals by op_array identity and needs no sources.
- **Security.** The payload carries no code, only names and indices; the resolved code is whatever the loaded class declares. `allowed_classes` gates both directions: `Closure` must be allowed before any constant expression is evaluated on the `to_array` side, and the payload-named class must be allow-listed before it is autoloaded on the `from_array` side, so unserialization cannot drive autoloading or constructor execution for classes outside the allow-list.
Payloads are byte-identical and interchangeable with the extension's, covered by tests that run against both implementations (the few cases where op_array identity outperforms the userland heuristic branch per implementation). Closures created at runtime keep throwing `NotInstantiableException`.
Companion extension implementation: symfony/php-ext-deepclone#21
Commits
-------
e08201b [DeepClone] Support closures declared in constant expressions3 files changed
Lines changed: 1046 additions & 17 deletions
0 commit comments