Skip to content

Commit e144d23

Browse files
feature #634 [DeepClone] Gate closures over named callables behind an allow_named_closures option (nicolas-grekas)
This PR was merged into the 1.x branch. Discussion ---------- [DeepClone] Gate closures over named callables behind an allow_named_closures option | Q | A | ------------- | --- | Branch? | 1.x | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT A by-name closure payload (a first-class callable over a named function or method, e.g. `strlen(...)` or `Cls::method(...)`, and `Closure::fromCallable()`) makes `deepclone_from_array()` a factory for a `Closure` over any function or method of that name -- with no visibility or staticness restriction and including internal functions such as `system()`. That is a stronger primitive than anything `unserialize()` exposes, and a one-step gadget when paired with an object whose `__wakeup`/`__unserialize`/destructor invokes a stored callback. `deepclone_to_array()` and `deepclone_from_array()` gain a third parameter, `bool $allowNamedClosures = false`, that **both ends must enable**. With it off (the default) `to_array` refuses to encode such a closure and `from_array` rejects any payload carrying a by-name closure marker before instantiating anything. This is a behavior change: closures over named callables previously serialized and resolved unconditionally. - **The attribute-cache use case does not regress.** First-class callables over a method of their own declaring class declared in a constant expression (e.g. `#[Assert\When(self::isStrict(...))]`, symfony/symfony#63228) are now encoded as a declaration-site reference (mask `1`), like anonymous const-expr closures, instead of by name; they resolve only to a closure the named class itself declares and round-trip **without** the opt-in. Userland identifies them by the target method's reflection key over the existing per-class const-expr index. - **What still needs the opt-in.** Cross-class or global-function callables, inherited methods, and any first-class callable created at runtime keep the by-name form and require `$allowNamedClosures` on both ends. - **`allowed_classes` is unchanged** and still gates `Closure` for both forms. Payloads stay byte-identical and interchangeable with the extension (verified both directions). Tests cover the const-expr routing, both refusals, cross-end enforcement, a `system()` payload rejected by default, and wholesale rejection of a nested by-name closure; existing named-closure tests pass the opt-in. Companion extension implementation: symfony/php-ext-deepclone#26 Commits ------- 3c0c20c [DeepClone] Gate closures over named callables behind an allow_named_closures option
2 parents 48715a3 + 3c0c20c commit e144d23

5 files changed

Lines changed: 216 additions & 29 deletions

File tree

src/DeepClone/DeepClone.php

Lines changed: 127 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@ final class DeepClone
4343
private static \stdClass $sentinel;
4444

4545
/**
46-
* @param list<string>|null $allowed_classes Classes that may be serialized
47-
* (null = all, [] = none)
46+
* @param list<string>|null $allowed_classes Classes that may be serialized
47+
* (null = all, [] = none)
48+
* @param bool $allow_named_closures Allow encoding closures over named
49+
* callables by name (both ends must opt in)
4850
*/
49-
public static function deepclone_to_array(mixed $value, ?array $allowed_classes = null): array
51+
public static function deepclone_to_array(mixed $value, ?array $allowed_classes = null, bool $allow_named_closures = false): array
5052
{
5153
if (\is_resource($value)) {
5254
throw new \DeepClone\NotInstantiableException('Type "'.get_resource_type($value).' resource" is not instantiable.');
@@ -76,7 +78,7 @@ public static function deepclone_to_array(mixed $value, ?array $allowed_classes
7678
$topMask = null;
7779

7880
try {
79-
$prepared = self::prepare([$value], $objectsPool, $refsPool, $objectsCount, $isStatic, $topMask, $allowedSet)[0];
81+
$prepared = self::prepare([$value], $objectsPool, $refsPool, $objectsCount, $isStatic, $topMask, $allowedSet, $allow_named_closures)[0];
8082
} finally {
8183
// Snapshot ref state while references still break cycles.
8284
foreach ($refsPool as $i => $v) {
@@ -249,7 +251,7 @@ public static function deepclone_to_array(mixed $value, ?array $allowed_classes
249251
* @param list<string>|null $allowed_classes Classes that may be instantiated
250252
* (null = all, [] = none)
251253
*/
252-
public static function deepclone_from_array(array $data, ?array $allowed_classes = null): mixed
254+
public static function deepclone_from_array(array $data, ?array $allowed_classes = null, bool $allow_named_closures = false): mixed
253255
{
254256
if (\array_key_exists('value', $data)) {
255257
return $data['value'];
@@ -324,6 +326,15 @@ public static function deepclone_from_array(array $data, ?array $allowed_classes
324326
}
325327
}
326328

329+
// Named closures (the by-name marker, 0) let a payload mint a Closure
330+
// over any function or method by name; they resolve only when the
331+
// caller opts in, which the producer must also have done. Const-expr
332+
// closure references (marker 1) are unaffected. The scan runs before
333+
// anything is instantiated so such a payload is rejected wholesale.
334+
if (!$allow_named_closures && self::payloadHasNamedClosure($data)) {
335+
throw new \ValueError('deepclone_from_array(): resolving a closure over a named callable requires enabling the allow_named_closures option');
336+
}
337+
327338
// $expectedStates maps ids that flag a state replay to their wakeup
328339
// sign (+1 / -1 is enough; the raw value is fine). Stays empty in
329340
// the common no-wakeup case so the later validation pass is O(1).
@@ -597,7 +608,7 @@ private static function valueName(mixed $value): string
597608
};
598609
}
599610

600-
private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCount, &$valuesAreStatic, &$mask = null, ?array $allowedSet = null)
611+
private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCount, &$valuesAreStatic, &$mask = null, ?array $allowedSet = null, bool $allowNamedClosures = false)
601612
{
602613
$sentinel = self::$sentinel ??= new \stdClass();
603614
$refs = $values;
@@ -624,7 +635,7 @@ private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCou
624635
if (\is_array($value)) {
625636
if ($value) {
626637
$m = null;
627-
$value = self::prepare($value, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet);
638+
$value = self::prepare($value, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet, $allowNamedClosures);
628639
if (null !== $m) {
629640
$mask[$k] = $m;
630641
}
@@ -647,13 +658,41 @@ private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCou
647658
$r = new \ReflectionFunction($value);
648659

649660
if (!(\PHP_VERSION_ID >= 80200 ? $r->isAnonymous() : str_contains($r->name, "@anonymous\0"))) {
661+
// First-class callable. When it references a method of its
662+
// own declaring class declared in a constant expression
663+
// (e.g. #[When(self::isStrict(...))]), encode it as a
664+
// declaration-site reference like the extension does, so it
665+
// round-trips without the allow_named_closures opt-in.
666+
// Closure is allow-list-gated first, mirroring the
667+
// extension (reported before any const-expr is evaluated).
668+
if (\PHP_VERSION_ID >= 80500 && !$r->getClosureThis() && $r->getClosureScopeClass()) {
669+
if (null !== $allowedSet && !isset($allowedSet['closure'])) {
670+
throw new \ValueError('deepclone_to_array(): class "Closure" is not allowed');
671+
}
672+
if (null !== $ref = self::locateConstExprClosure($r)) {
673+
$value = $ref;
674+
$mask[$k] = 1;
675+
676+
goto handle_value;
677+
}
678+
}
679+
680+
// Not addressable as a declaration-site reference (runtime
681+
// first-class callable, cross-class or global-function
682+
// target, internal function): encode it by name. That lets
683+
// deepclone_from_array() mint a Closure over any function or
684+
// method of that name, so it is gated behind
685+
// allow_named_closures, which both ends must enable.
686+
if (!$allowNamedClosures) {
687+
throw new \ValueError('deepclone_to_array(): serializing a closure over the named callable "'.$r->name.'" requires enabling the allow_named_closures option');
688+
}
650689
if (null !== $allowedSet && !isset($allowedSet['closure'])) {
651690
throw new \ValueError('deepclone_to_array(): class "Closure" is not allowed');
652691
}
653692
$callable = [$r->getClosureThis() ?? (\PHP_VERSION_ID >= 80111 ? $r->getClosureCalledClass() : $r->getClosureScopeClass())?->name, $r->name];
654693
$rm = $callable[0] ? new \ReflectionMethod(...$callable) : null;
655694
$unused = null;
656-
$callable = self::prepare($callable, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $unused, $allowedSet);
695+
$callable = self::prepare($callable, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $unused, $allowedSet, $allowNamedClosures);
657696
$value = !($rm?->isPublic() ?? true) ? [$callable, $rm->class, $rm->name] : $callable;
658697
$mask[$k] = 0;
659698

@@ -683,7 +722,7 @@ private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCou
683722
$arrayValue = (array) $value;
684723
$objectsPool[$oid] = [$id = \count($objectsPool)];
685724
$m = null;
686-
$properties = $arrayValue ? self::prepare(['stdClass' => $arrayValue], $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet) : [];
725+
$properties = $arrayValue ? self::prepare(['stdClass' => $arrayValue], $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet, $allowNamedClosures) : [];
687726
++$objectsCount;
688727
$objectsPool[$oid] = [$id, 'stdClass', $properties, 0, $value, $m];
689728
$value = $id;
@@ -791,7 +830,7 @@ private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCou
791830
prepare_value:
792831
$objectsPool[$oid] = [$id = \count($objectsPool)];
793832
$m = null;
794-
$properties = self::prepare($properties, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet);
833+
$properties = self::prepare($properties, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet, $allowNamedClosures);
795834
++$objectsCount;
796835
$objectsPool[$oid] = [$id, $class, $properties, $hasUnserialize ? -$objectsCount : ((self::$classInfo[$class][1] ??= $reflector->hasMethod('__wakeup')) ? $objectsCount : 0), $value, $m];
797836

@@ -1247,6 +1286,17 @@ private static function locateConstExprClosure(\ReflectionFunction $r): ?array
12471286
if (!$candidates = $index[$r->name.':'.$file.':'.$line.':'.$r->getEndLine().':'.self::closureSignature($r)] ?? null) {
12481287
return null;
12491288
}
1289+
1290+
// First-class callables are keyed by their target method's identity, so
1291+
// several declaration sites can map to the same key; they are
1292+
// equivalent and the first one wins, exactly like the extension. The
1293+
// anonymous-only checks below (the same-site ambiguity guard and the
1294+
// runtime-aliasing refusal) only concern anonymous closure literals,
1295+
// which are told apart by source position.
1296+
if (!$r->isAnonymous()) {
1297+
return [$scope->name, $candidates[0][0], $candidates[0][1], $candidates[0][2], $line];
1298+
}
1299+
12501300
$sites = [];
12511301
foreach ($candidates as $candidate) {
12521302
if (isset($sites[$siteKey = $candidate[0].'#'.$candidate[1]])) {
@@ -1330,11 +1380,21 @@ private static function indexConstExprClosures(\ReflectionClass $rc): array
13301380
}
13311381
$seen[$id] = true;
13321382
$r = new \ReflectionFunction($value);
1333-
if (!$r->isAnonymous() || $r->getClosureScopeClass()?->name !== $class) {
1383+
// Index anonymous closures declared in this class, and
1384+
// first-class callables over a method of this class (their
1385+
// scope is the target method's class): both are closures
1386+
// the class declares about itself. Cross-class and
1387+
// global-function callables have a different (or null)
1388+
// scope and are addressed by name instead.
1389+
if ($r->getClosureScopeClass()?->name !== $class) {
13341390
return;
13351391
}
13361392
$index[$r->name.':'.$r->getFileName().':'.$r->getStartLine().':'.$r->getEndLine().':'.self::closureSignature($r)][] = [$site, $attrIndex, $n];
1337-
$lines[$k = $r->getFileName().':'.$r->getStartLine()] = ($lines[$k] ?? 0) + 1;
1393+
if ($r->isAnonymous()) {
1394+
// The runtime-aliasing refusal keys off source lines and
1395+
// only concerns anonymous closure literals.
1396+
$lines[$k = $r->getFileName().':'.$r->getStartLine()] = ($lines[$k] ?? 0) + 1;
1397+
}
13381398
} elseif (\is_array($value)) {
13391399
foreach ($value as $v) {
13401400
$walk($v);
@@ -1618,6 +1678,61 @@ private static function maskHasClosure($mask): bool
16181678
return false;
16191679
}
16201680

1681+
/**
1682+
* Like maskHasClosure() but matches only the named-closure marker (0),
1683+
* ignoring const-expr-closure references (1).
1684+
*/
1685+
private static function maskHasNamedClosure($mask): bool
1686+
{
1687+
if (0 === $mask) {
1688+
return true;
1689+
}
1690+
if (\is_array($mask)) {
1691+
foreach ($mask as $v) {
1692+
if (self::maskHasNamedClosure($v)) {
1693+
return true;
1694+
}
1695+
}
1696+
}
1697+
1698+
return false;
1699+
}
1700+
1701+
/**
1702+
* Scans the payload regions that can carry closure markers (the top mask,
1703+
* the reference masks, the resolve table and the replayed state masks) for
1704+
* a named-closure marker. Mirrors the region set used by the
1705+
* allowed_classes "Closure" gate.
1706+
*/
1707+
private static function payloadHasNamedClosure(array $data): bool
1708+
{
1709+
foreach (['mask', 'refMasks'] as $key) {
1710+
if (isset($data[$key]) && self::maskHasNamedClosure($data[$key])) {
1711+
return true;
1712+
}
1713+
}
1714+
if (isset($data['resolve'])) {
1715+
foreach ($data['resolve'] as $scope) {
1716+
if (\is_array($scope)) {
1717+
foreach ($scope as $name) {
1718+
if (self::maskHasNamedClosure($name)) {
1719+
return true;
1720+
}
1721+
}
1722+
}
1723+
}
1724+
}
1725+
if (isset($data['states'])) {
1726+
foreach ($data['states'] as $state) {
1727+
if (\is_array($state) && isset($state[2]) && self::maskHasNamedClosure($state[2])) {
1728+
return true;
1729+
}
1730+
}
1731+
}
1732+
1733+
return false;
1734+
}
1735+
16211736
private static function replaceRefs($value, &$mask)
16221737
{
16231738
if (\is_array($value)) {

src/DeepClone/README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,24 @@ Symfony Polyfill / DeepClone
44
This package provides a pure-PHP implementation of the functions and exception
55
classes from the [deepclone extension](https://github.com/symfony/php-ext-deepclone):
66

7-
- `deepclone_to_array(mixed $value, ?array $allowedClasses = null): array`
7+
- `deepclone_to_array(mixed $value, ?array $allowedClasses = null, bool $allowNamedClosures = false): array`
88
— converts any serializable PHP value graph into a pure array (only scalars
99
and nested arrays, no objects).
10-
- `deepclone_from_array(array $data, ?array $allowedClasses = null): mixed`
10+
- `deepclone_from_array(array $data, ?array $allowedClasses = null, bool $allowNamedClosures = false): mixed`
1111
— rebuilds the value graph from the array, preserving object identity,
1212
references, cycles, and private property state.
1313
- `DeepClone\NotInstantiableException` and `DeepClone\ClassNotFoundException`
1414

15+
`$allowNamedClosures` (default `false`, required on both ends) gates the
16+
by-name encoding of closures over named callables (first-class callables such
17+
as `strlen(...)` or `Cls::method(...)`, and `Closure::fromCallable()`): a
18+
by-name payload can mint a `Closure` over any function or method of that name,
19+
so it should only travel between ends that trust each other. Closures declared
20+
in constant expressions — anonymous static closures and first-class callables
21+
over a method of their own declaring class, as found in attribute arguments —
22+
serialize as a reference to their declaration site and round-trip without this
23+
option.
24+
1525
When the native `deepclone` extension is loaded, this polyfill does nothing —
1626
the extension provides the same functions 4–5× faster.
1727

src/DeepClone/bootstrap81.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
}
1717

1818
if (!function_exists('deepclone_to_array')) {
19-
function deepclone_to_array(mixed $value, ?array $allowed_classes = null): array { return p\DeepClone::deepclone_to_array($value, $allowed_classes); }
19+
function deepclone_to_array(mixed $value, ?array $allowed_classes = null, bool $allow_named_closures = false): array { return p\DeepClone::deepclone_to_array($value, $allowed_classes, $allow_named_closures); }
2020
}
2121
if (!function_exists('deepclone_from_array')) {
22-
function deepclone_from_array(array $data, ?array $allowed_classes = null): mixed { return p\DeepClone::deepclone_from_array($data, $allowed_classes); }
22+
function deepclone_from_array(array $data, ?array $allowed_classes = null, bool $allow_named_closures = false): mixed { return p\DeepClone::deepclone_from_array($data, $allowed_classes, $allow_named_closures); }
2323
}
2424
if (!defined('DEEPCLONE_HYDRATE_CALL_HOOKS')) {
2525
define('DEEPCLONE_HYDRATE_CALL_HOOKS', 1 << 0);

0 commit comments

Comments
 (0)