From 89d0eed732ac14cdc3243a6ab08d89879f24a34b Mon Sep 17 00:00:00 2001 From: Philipp Daun Date: Wed, 29 Jul 2026 15:26:08 +0200 Subject: [PATCH] Add cheap reference check before updating references --- src/Data/DataReferenceUpdater.php | 25 +++++ tests/Data/DataReferenceUpdaterTest.php | 134 ++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/Data/DataReferenceUpdaterTest.php diff --git a/src/Data/DataReferenceUpdater.php b/src/Data/DataReferenceUpdater.php index 8f861c90125..73a42376a01 100644 --- a/src/Data/DataReferenceUpdater.php +++ b/src/Data/DataReferenceUpdater.php @@ -69,6 +69,10 @@ public function updateReferences($originalValue, $newValue) $this->originalValue = $originalValue; $this->newValue = $newValue; + if (! $this->itemMayContainReferences()) { + return false; + } + $this->recursivelyUpdateFields($this->getTopLevelFields()); if ($this->updated) { @@ -78,6 +82,27 @@ public function updateReferences($originalValue, $newValue) return (bool) $this->updated; } + protected function itemMayContainReferences() + { + if (! is_string($this->originalValue) || $this->originalValue === '') { + return true; + } + + try { + $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + + if (! is_string($data = json_encode($this->item->data()->all(), $flags))) { + return true; + } + + $needle = substr(json_encode($this->originalValue, $flags), 1, -1); + + return str_contains($data, $needle); + } catch (\Throwable $e) { + return true; + } + } + /** * Get top level fields off item blueprint. * diff --git a/tests/Data/DataReferenceUpdaterTest.php b/tests/Data/DataReferenceUpdaterTest.php new file mode 100644 index 00000000000..1d7944e2904 --- /dev/null +++ b/tests/Data/DataReferenceUpdaterTest.php @@ -0,0 +1,134 @@ +data = collect($data); + } + + public function data() + { + return $this->data; + } + + public function blueprint() + { + $this->blueprintResolved = true; + + return Blueprint::makeFromFields([]); + } + + public function save() + { + // + } + }; + } + + #[Test] + public function it_skips_blueprint_traversal_when_data_cannot_contain_the_original_value() + { + $item = $this->makeItem(['hero' => 'unrelated.jpg']); + + $updated = AssetReferenceUpdater::item($item) + ->filterByContainer('assets') + ->updateReferences('img/hoff.jpg', 'img/new-hoff.jpg'); + + $this->assertFalse($updated); + $this->assertFalse($item->blueprintResolved); + } + + #[Test] + public function it_traverses_blueprint_when_data_contains_the_original_value() + { + $item = $this->makeItem(['hero' => 'img/hoff.jpg']); + + AssetReferenceUpdater::item($item) + ->filterByContainer('assets') + ->updateReferences('img/hoff.jpg', 'img/new-hoff.jpg'); + + $this->assertTrue($item->blueprintResolved); + } + + #[Test] + public function it_traverses_blueprint_when_original_value_appears_within_a_larger_string() + { + $item = $this->makeItem(['content' => '[link](statamic://asset::assets::img/hoff.jpg)']); + + AssetReferenceUpdater::item($item) + ->filterByContainer('assets') + ->updateReferences('img/hoff.jpg', 'img/new-hoff.jpg'); + + $this->assertTrue($item->blueprintResolved); + } + + #[Test] + public function it_traverses_blueprint_when_original_value_contains_non_ascii_characters() + { + $item = $this->makeItem(['hero' => 'img/föö-bär.jpg']); + + AssetReferenceUpdater::item($item) + ->filterByContainer('assets') + ->updateReferences('img/föö-bär.jpg', 'img/new.jpg'); + + $this->assertTrue($item->blueprintResolved); + } + + #[Test] + public function it_traverses_blueprint_when_original_value_contains_json_special_characters() + { + $item = $this->makeItem(['hero' => 'img/we"ird\\file.jpg']); + + AssetReferenceUpdater::item($item) + ->filterByContainer('assets') + ->updateReferences('img/we"ird\\file.jpg', 'img/new.jpg'); + + $this->assertTrue($item->blueprintResolved); + } + + #[Test] + public function it_traverses_blueprint_when_json_encoding_data_throws() + { + $throwing = new class implements \JsonSerializable + { + public function jsonSerialize(): mixed + { + throw new \Exception('Cannot be serialized.'); + } + }; + + $item = $this->makeItem(['object' => $throwing, 'hero' => 'unrelated.jpg']); + + AssetReferenceUpdater::item($item) + ->filterByContainer('assets') + ->updateReferences('img/hoff.jpg', 'img/new-hoff.jpg'); + + $this->assertTrue($item->blueprintResolved); + } + + #[Test] + public function it_traverses_blueprint_when_data_cannot_be_json_encoded() + { + $item = $this->makeItem(['broken' => "\xB1\x31", 'hero' => 'unrelated.jpg']); + + AssetReferenceUpdater::item($item) + ->filterByContainer('assets') + ->updateReferences('img/hoff.jpg', 'img/new-hoff.jpg'); + + $this->assertTrue($item->blueprintResolved); + } +}