From e2ad1f50141c8c9c694ded227627dc04ba17278b Mon Sep 17 00:00:00 2001 From: Laurens Kuiper Date: Thu, 27 Aug 2026 10:40:04 +0200 Subject: [PATCH 1/2] fix(entries): stop dirty state sync from resolving the origin --- src/Entries/Entry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index a96829a49c1..17b4014c051 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -235,7 +235,7 @@ public function getCurrentDirtyStateAttributes(): array return array_merge([ 'collection' => $this->collectionHandle(), 'locale' => $this->locale(), - 'origin' => $this->hasOrigin() ? $this->origin()->id() : null, + 'origin' => $this->origin, 'slug' => $this->slug(), 'date' => optional($this->date())->format('Y-m-d-Hi'), 'published' => $this->published(), From 174014a3efc0c2ad33ceae68a3fb0d1e024ed30e Mon Sep 17 00:00:00 2001 From: Laurens Kuiper Date: Thu, 27 Aug 2026 10:40:11 +0200 Subject: [PATCH 2/2] fix(entries): reject cyclic origins on save --- src/Data/HasOrigin.php | 24 +++- src/Entries/Entry.php | 21 +++- .../EntryOriginRecursionException.php | 32 +++++ tests/Data/Entries/EntryTest.php | 114 ++++++++++++++++++ 4 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 src/Exceptions/EntryOriginRecursionException.php diff --git a/src/Data/HasOrigin.php b/src/Data/HasOrigin.php index 5d390cc4b16..4b7a9a22ac6 100644 --- a/src/Data/HasOrigin.php +++ b/src/Data/HasOrigin.php @@ -113,12 +113,28 @@ public function isRoot() public function root() { - $entry = $this; + $chain = $this->originChain(); - while ($entry->hasOrigin()) { - $entry = $entry->origin(); + return empty($chain) ? $this : end($chain); + } + + protected function originChain() + { + $chain = []; + $seen = [$this->originChainKey($this)]; + $item = $this->origin(); + + while ($item && ! in_array($key = $this->originChainKey($item), $seen, true)) { + $chain[] = $item; + $seen[] = $key; + $item = $item->origin(); } - return $entry; + return $chain; + } + + private function originChainKey($item) + { + return method_exists($item, 'id') && ($id = $item->id()) ? $id : spl_object_id($item); } } diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 17b4014c051..41e7ea6eba6 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -38,6 +38,7 @@ use Statamic\Events\EntrySaved; use Statamic\Events\EntrySaving; use Statamic\Exceptions\BlueprintNotFoundException; +use Statamic\Exceptions\EntryOriginRecursionException; use Statamic\Facades; use Statamic\Facades\Antlers; use Statamic\Facades\Blink; @@ -399,6 +400,8 @@ public function saveQuietly() public function save() { + $this->ensureOriginIsNotRecursive(); + $isNew = is_null(Facades\Entry::find($this->id())); $withEvents = $this->withEvents; @@ -847,16 +850,22 @@ public function in($locale) public function ancestors() { - $ancestors = collect(); + return collect($this->originChain()); + } - $origin = $this->origin(); + private function ensureOriginIsNotRecursive() + { + if (! $this->origin) { + return; + } - while ($origin) { - $ancestors->push($origin); - $origin = $origin->origin(); + $last = collect($this->originChain())->last() ?? $this; + + if ($last->origin() === null) { + return; } - return $ancestors; + throw new EntryOriginRecursionException($this->id(), $this->origin); } public function directDescendants() diff --git a/src/Exceptions/EntryOriginRecursionException.php b/src/Exceptions/EntryOriginRecursionException.php new file mode 100644 index 00000000000..9707269a0dd --- /dev/null +++ b/src/Exceptions/EntryOriginRecursionException.php @@ -0,0 +1,32 @@ +entry; + } + + public function getOrigin() + { + return $this->origin; + } + + public function getSolution(): Solution + { + return BaseSolution::create('Avoid infinite recursion') + ->setSolutionDescription("The entry `$this->origin` already originates from `$this->entry`, directly or through another entry. Pick an origin that isn't a descendant of this entry."); + } +} diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index 2f800df2f60..91d45869023 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -29,6 +29,7 @@ use Statamic\Events\EntryDeleting; use Statamic\Events\EntrySaved; use Statamic\Events\EntrySaving; +use Statamic\Exceptions\EntryOriginRecursionException; use Statamic\Facades; use Statamic\Facades\Blink; use Statamic\Fields\Blueprint; @@ -1655,6 +1656,109 @@ public function it_includes_descendants_nested_via_an_origin_chain() $this->assertSame($de, $descendants->get('de')); } + #[Test] + public function the_root_of_an_entry_in_an_origin_cycle_does_not_recurse() + { + $collection = tap(Collection::make('test'))->save(); + + $a = (new CyclicOriginEntry)->id('a')->locale('en')->collection($collection); + $b = (new CyclicOriginEntry)->id('b')->locale('fr')->collection($collection); + + CyclicOriginEntry::$entries = ['a' => $a, 'b' => $b]; + + $a->origin('b'); + $b->origin('a'); + + // The walk stops where the cycle closes rather than looping forever. + $this->assertSame($b, $a->root()); + $this->assertSame($a, $b->root()); + } + + #[Test] + public function it_walks_a_valid_origin_chain_to_the_root() + { + $collection = tap(Collection::make('test'))->save(); + + $a = (new CyclicOriginEntry)->id('a')->locale('en')->collection($collection); + $b = (new CyclicOriginEntry)->id('b')->locale('fr')->collection($collection); + $c = (new CyclicOriginEntry)->id('c')->locale('de')->collection($collection); + + CyclicOriginEntry::$entries = ['a' => $a, 'b' => $b, 'c' => $c]; + + // a -> b -> c, no cycle. + $a->origin('b'); + $b->origin('c'); + + $this->assertSame($c, $a->root()); + $this->assertSame([$b, $c], $a->ancestors()->all()); + } + + #[Test] + public function it_cannot_be_saved_with_an_origin_that_points_back_at_it() + { + tap(Collection::make('test')->sites(['en', 'fr']))->save(); + + $a = EntryFactory::id('a')->collection('test')->locale('en')->create(); + $b = EntryFactory::id('b')->collection('test')->locale('fr')->origin('a')->create(); + + $this->expectException(EntryOriginRecursionException::class); + $this->expectExceptionMessage('Entry [a] cannot use origin [b] because it would create a loop.'); + + $a->origin($b)->save(); + } + + #[Test] + public function it_cannot_be_saved_with_an_origin_further_up_its_own_chain() + { + tap(Collection::make('test')->sites(['en', 'fr', 'de']))->save(); + + $a = EntryFactory::id('a')->collection('test')->locale('en')->create(); + $b = EntryFactory::id('b')->collection('test')->locale('fr')->origin('a')->create(); + $c = EntryFactory::id('c')->collection('test')->locale('de')->origin('b')->create(); + + $this->expectException(EntryOriginRecursionException::class); + + $a->origin($c)->save(); + } + + #[Test] + public function it_cannot_be_saved_with_itself_as_origin() + { + tap(Collection::make('test')->sites(['en']))->save(); + + $a = EntryFactory::id('a')->collection('test')->locale('en')->create(); + + $this->expectException(EntryOriginRecursionException::class); + + $a->origin('a')->save(); + } + + #[Test] + public function it_can_be_saved_when_an_origin_further_up_the_chain_no_longer_exists() + { + tap(Collection::make('test')->sites(['en', 'fr', 'de']))->save(); + + $b = EntryFactory::id('b')->collection('test')->locale('fr')->origin('missing')->create(); + $c = EntryFactory::id('c')->collection('test')->locale('de')->create(); + + $c->origin($b)->save(); + + $this->assertSame('b', $c->origin()->id()); + } + + #[Test] + public function it_can_be_saved_with_an_origin_that_does_not_loop() + { + tap(Collection::make('test')->sites(['en', 'fr']))->save(); + + $a = EntryFactory::id('a')->collection('test')->locale('en')->create(); + $b = EntryFactory::id('b')->collection('test')->locale('fr')->create(); + + $b->origin($a)->save(); + + $this->assertSame($a->id(), $b->origin()->id()); + } + private function fakeDescendantsQuery($results, ?array $whereInOrigins = null): QueryBuilder { $query = Mockery::mock(QueryBuilder::class); @@ -2955,6 +3059,16 @@ public function entries_can_be_serialized_after_resolving_values() } } +class CyclicOriginEntry extends Entry +{ + public static $entries = []; + + protected function getOriginByString($origin) + { + return static::$entries[$origin] ?? null; + } +} + class CustomEntry extends Entry { public static function fromEntry(Entry $entry)