Skip to content

Commit 65414a9

Browse files
committed
Fix double validation and state leaks in Attributes recursion
Recursive validation for nested objects guarded against validating a nested object twice by checking whether the property already carried #[Attributes] directly on the property. That guard did not recognise the rule inside a wrapper, and the state backing the recursion was never released, producing five defects: - #[NullOr(new Attributes())] on a class-typed property validated the nested object twice and reported every nested failure twice. This was the documented way of validating a nullable nested object before recursion existed, so upgrading silently duplicated messages. - The same object held by two sibling properties failed as a circular reference, because visited objects accumulated for the whole traversal instead of the current path. - An Attributes instance could only be used once: nothing ever cleared the visited objects, so every evaluation after the first failed. - A union type whose value satisfied more than one of its class members recursed once per member, and the second one reported the object it had just visited as a circular reference. - A custom validator attribute with cyclic internal state made wrapped-rule detection recurse indefinitely. Detect the rule anywhere inside a property's attributes rather than only at the top level, so a wrapped Attributes suppresses implicit recursion as an explicit one does. Make the rule immutable and give each recursion level its own instance carrying the path to the object being evaluated, so visited objects represent the path from the root rather than every object ever seen. Collapse union recursion into a single Given over the disjunction of its class members. Track the validators visited while inspecting a wrapped rule, preventing a cyclic validator graph from overflowing the stack. The detection only runs for properties whose type can hold an object, so attributes with large arguments, such as #[In], no longer pay for it.
1 parent 56239ca commit 65414a9

9 files changed

Lines changed: 276 additions & 30 deletions

File tree

docs/validators/Attributes.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,18 @@ When a property's type is a class (named, union, or intersection type), `Attribu
9191
- **Untyped properties** (no type declaration, or builtin types like `string`): are never recursively validated.
9292
- **Array properties**: `Attributes` **does not** recursively validate objects inside arrays. To validate each element, use the `#[Each]` attribute on the property (e.g., `#[Each(new Attributes())]`).
9393

94+
The implicit recursion is skipped whenever the property's own attributes already contain an `Attributes` rule, so the nested object is never validated twice. That holds for `#[Attributes]` written directly on the property and for an `Attributes` wrapped in another rule, like `#[NullOr(new Attributes())]`, `#[Each(new Attributes())]`, or `#[Given(new Instance(Address::class), new Attributes())]`. It holds even when the wrapper applies the rule to something other than the property itself, as in `#[Property('street', new Attributes())]`, so writing `Attributes` by hand always puts you in full control of what gets validated.
95+
96+
Any other attribute on the property is combined with the implicit recursion instead of replacing it, so `#[Instance(Address::class)] public Address $address` still validates the nested object's own attributes.
97+
9498
### Circular references
9599

96100
When a nested object graph contains a cycle (e.g., `$a->next = $b`, `$b->next = $a`), `Attributes` detects the revisit and fails with the `TEMPLATE_CIRCULAR_REFERENCE` template. This prevents infinite recursion and stack overflow.
97101

102+
Detection is per *path*: only objects between the root and the property being validated count as a revisit. The same object reachable from two sibling properties (e.g., `$order->billing` and `$order->shipping` holding one `Address`) is not a cycle, and is validated on each path. The cost of validating an object graph is therefore proportional to how many distinct paths it has, not to how many objects it has, which is worth keeping in mind for graphs where many properties point at the same deeply nested objects.
103+
104+
Detection covers the recursion `Attributes` performs on its own. An `Attributes` you write yourself in an attribute, such as `#[NullOr(new Attributes())]`, starts a traversal of its own and knows nothing about the objects already visited, so a cycle reached through it recurses infinitely.
105+
98106
Note that circular reference detection only works for direct object references. If a cycle passes through an array (e.g., `$a->items = [$b]`, `$b->parent = $a`), `Attributes` cannot track the reference and the validation will recurse infinitely, causing a stack overflow.
99107

100108
## Templates

src/Validators/Attributes.php

Lines changed: 98 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use ReflectionNamedType;
2020
use ReflectionObject;
2121
use ReflectionProperty;
22+
use ReflectionType;
2223
use ReflectionUnionType;
2324
use Respect\Fluent\Attributes\Composable;
2425
use Respect\Parameter\Resolver;
@@ -28,6 +29,8 @@
2829
use Respect\Validation\Validator;
2930
use Respect\Validation\Validators\Core\Reducer;
3031

32+
use function count;
33+
use function is_array;
3134
use function spl_object_id;
3235

3336
#[Composable(without: [All::class, Key::class, Property::class, Not::class, UndefOr::class])]
@@ -37,15 +40,16 @@
3740
'{{subject}} must contain a circular reference',
3841
Attributes::TEMPLATE_CIRCULAR_REFERENCE,
3942
)]
40-
final class Attributes implements Validator
43+
final readonly class Attributes implements Validator
4144
{
4245
public const string TEMPLATE_CIRCULAR_REFERENCE = '__circular_reference__';
4346

4447
/** @var array<int, true> */
45-
private array $visited = [];
48+
private array $path;
4649

47-
public function __construct(private readonly Resolver|null $resolver = null)
50+
public function __construct(private Resolver|null $resolver = null)
4851
{
52+
$this->path = self::rootPath();
4953
}
5054

5155
public function evaluate(mixed $input): Result
@@ -57,19 +61,24 @@ public function evaluate(mixed $input): Result
5761
}
5862

5963
$objectId = spl_object_id($input);
60-
if (isset($this->visited[$objectId])) {
64+
if (isset($this->path[$objectId])) {
6165
return Result::failed($input, $this, [], self::TEMPLATE_CIRCULAR_REFERENCE)->withId($id);
6266
}
6367

64-
$this->visited[$objectId] = true;
68+
$path = $this->path + [$objectId => true];
69+
$child = clone ($this, ['path' => $path]);
6570

6671
$reflection = new ReflectionObject($input);
67-
$validators = [...$this->getClassValidators($reflection), ...$this->getPropertyValidators($reflection)];
68-
if ($validators === []) {
69-
return (new AlwaysValid())->evaluate($input)->withId($id);
70-
}
72+
$validators = [...$child->getClassValidators($reflection), ...$child->getPropertyValidators($reflection)];
73+
$rule = $validators === [] ? new AlwaysValid() : new Reducer(...$validators);
74+
75+
return $rule->evaluate($input)->withId($id);
76+
}
7177

72-
return (new Reducer(...$validators))->evaluate($input)->withId($id);
78+
/** @return array<int, true> */
79+
private static function rootPath(): array
80+
{
81+
return [];
7382
}
7483

7584
/** @return array<Validator> */
@@ -110,46 +119,105 @@ private function getPropertyValidators(ReflectionObject $reflection): array
110119
private function getPropertyInnerValidators(ReflectionProperty $property): array
111120
{
112121
$propertyValidators = [];
113-
$hasExplicitAttributes = false;
114122
foreach ($property->getAttributes(Validator::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
115123
if ($attribute->getName() === self::class) {
116-
$propertyValidator = $this;
117-
} else {
118-
$propertyValidator = $this->instantiateAttribute($attribute);
124+
$propertyValidators[] = $this;
125+
126+
continue;
119127
}
120128

121-
$hasExplicitAttributes = $hasExplicitAttributes || $propertyValidator === $this;
122-
$propertyValidators[] = $propertyValidator;
129+
$propertyValidators[] = $this->instantiateAttribute($attribute);
123130
}
124131

125-
if ($hasExplicitAttributes) {
132+
$recursion = $this->getRecursionValidator($property->getType());
133+
if ($recursion === null) {
126134
return $propertyValidators;
127135
}
128136

129-
$type = $property->getType();
130-
if ($type instanceof ReflectionNamedType) {
131-
if (!$type->isBuiltin()) {
132-
$propertyValidators[] = $this;
137+
foreach ($propertyValidators as $propertyValidator) {
138+
if (self::containsSelf($propertyValidator)) {
139+
return $propertyValidators;
133140
}
134141
}
135142

143+
$propertyValidators[] = $recursion;
144+
145+
return $propertyValidators;
146+
}
147+
148+
private function getRecursionValidator(ReflectionType|null $type): Validator|null
149+
{
150+
if ($type instanceof ReflectionNamedType) {
151+
return $type->isBuiltin() ? null : $this;
152+
}
153+
136154
if ($type instanceof ReflectionIntersectionType) {
137-
$propertyValidators[] = $this;
155+
return $this;
138156
}
139157

140-
if ($type instanceof ReflectionUnionType) {
141-
foreach ($type->getTypes() as $innerType) {
142-
if (!$innerType instanceof ReflectionNamedType || $innerType->isBuiltin()) {
143-
continue;
158+
if (!$type instanceof ReflectionUnionType) {
159+
return null;
160+
}
161+
162+
$instances = [];
163+
foreach ($type->getTypes() as $innerType) {
164+
if (!$innerType instanceof ReflectionNamedType || $innerType->isBuiltin()) {
165+
continue;
166+
}
167+
168+
/** @var class-string $class */
169+
$class = $innerType->getName();
170+
$instances[] = new Instance($class);
171+
}
172+
173+
if ($instances === []) {
174+
return null;
175+
}
176+
177+
return new Given(count($instances) === 1 ? $instances[0] : new AnyOf(...$instances), $this);
178+
}
179+
180+
private static function containsSelf(mixed $value): bool
181+
{
182+
$visited = [];
183+
184+
return self::containsSelfIn($value, $visited);
185+
}
186+
187+
/** @param array<int, true> $visited */
188+
private static function containsSelfIn(mixed $value, array &$visited): bool
189+
{
190+
if ($value instanceof self) {
191+
return true;
192+
}
193+
194+
if (is_array($value)) {
195+
foreach ($value as $item) {
196+
if (self::containsSelfIn($item, $visited)) {
197+
return true;
144198
}
199+
}
200+
201+
return false;
202+
}
145203

146-
/** @var class-string $class */
147-
$class = $innerType->getName();
148-
$propertyValidators[] = new Given(new Instance($class), $this);
204+
if (!$value instanceof Validator) {
205+
return false;
206+
}
207+
208+
$objectId = spl_object_id($value);
209+
if (isset($visited[$objectId])) {
210+
return false;
211+
}
212+
213+
$visited[$objectId] = true;
214+
foreach ((new ReflectionObject($value))->getProperties() as $property) {
215+
if ($property->isInitialized($value) && self::containsSelfIn($property->getValue($value), $visited)) {
216+
return true;
149217
}
150218
}
151219

152-
return $propertyValidators;
220+
return false;
153221
}
154222

155223
/** @return array<ReflectionProperty> */

tests/feature/Validators/AttributesTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Respect\Validation\Test\Stubs\WithIntersectionTypeNested;
1818
use Respect\Validation\Test\Stubs\WithNestedAttributes;
1919
use Respect\Validation\Test\Stubs\WithUnionTypeNested;
20+
use Respect\Validation\Test\Stubs\WithWrappedAttributesOnNested;
2021

2122
test('Default', catchAll(
2223
fn() => v::attributes()->assert(new WithAttributes('', '2024-06-23', 'john.doe@gmail.com')),
@@ -118,6 +119,14 @@
118119
->and($messages)->toBe(['address' => '`.address.street` must be defined']),
119120
));
120121

122+
test('Recursive: wrapped Attributes on nested property is not duplicated', catchAll(
123+
fn() => v::attributes()->assert(new WithWrappedAttributesOnNested(new NestedAddress('', 'Springfield'))),
124+
fn(string $message, string $fullMessage, array $messages) => expect()
125+
->and($message)->toBe('`.address.street` must be defined')
126+
->and($fullMessage)->toBe('- `.address.street` must be defined')
127+
->and($messages)->toBe(['street' => '`.address.street` must be defined']),
128+
));
129+
121130
test('Recursive: intersection type with invalid nested object property', catchAll(
122131
fn() => v::attributes()->assert(new WithIntersectionTypeNested('John Doe', new NestedWithAttributes('', 'Springfield'))),
123132
fn(string $message, string $fullMessage, array $messages) => expect()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\Validation\Test\Stubs;
12+
13+
use Attribute;
14+
use Respect\Validation\Result;
15+
use Respect\Validation\Validator;
16+
use Respect\Validation\Validators\AlwaysValid;
17+
18+
#[Attribute(Attribute::TARGET_PROPERTY)]
19+
final class CyclicValidator implements Validator
20+
{
21+
/** @var array{self, string} */
22+
private array $children;
23+
24+
public function __construct()
25+
{
26+
$this->children = [$this, 'cyclic'];
27+
}
28+
29+
public function evaluate(mixed $input): Result
30+
{
31+
return (new AlwaysValid())->evaluate($input);
32+
}
33+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\Validation\Test\Stubs;
12+
13+
final class WithCyclicValidator
14+
{
15+
public function __construct(
16+
#[CyclicValidator]
17+
public NestedAddress $address,
18+
) {
19+
}
20+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\Validation\Test\Stubs;
12+
13+
final class WithOverlappingUnionTypeNested
14+
{
15+
public function __construct(
16+
public NestedWithAttributes|Nested $address,
17+
) {
18+
}
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\Validation\Test\Stubs;
12+
13+
final class WithSharedNested
14+
{
15+
public function __construct(
16+
public NestedAddress $billing,
17+
public NestedAddress $shipping,
18+
) {
19+
}
20+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\Validation\Test\Stubs;
12+
13+
use Respect\Validation\Validators as Rule;
14+
15+
final class WithWrappedAttributesOnNested
16+
{
17+
public function __construct(
18+
#[Rule\NullOr(new Rule\Attributes())]
19+
public NestedAddress|null $address = null,
20+
) {
21+
}
22+
}

0 commit comments

Comments
 (0)