From dd417966d19910be9eae37c36fb965e8ce8b9a9c Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Tue, 10 Nov 2026 12:51:05 +0100 Subject: [PATCH 01/11] Treat a listed asset with no file as missing. A cached container listing can still name a file that is no longer on disk. Reading that asset asked Flysystem for its size and threw, and the path was still handed back as the current asset. --- src/Assets/Asset.php | 96 +++++++++++++++++++++++++---- src/Assets/AssetContainer.php | 8 ++- src/Assets/QueryBuilder.php | 7 +++ tests/Assets/AssetContainerTest.php | 80 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 14 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index a432150acbb..b3df246fb06 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -8,6 +8,8 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use League\Flysystem\PathTraversalDetected; +use League\Flysystem\UnableToReadFile; +use League\Flysystem\UnableToRetrieveMetadata; use Statamic\Assets\AssetUploader as Uploader; use Statamic\Contracts\Assets\Asset as AssetContract; use Statamic\Contracts\Assets\AssetContainer as AssetContainerContract; @@ -76,6 +78,7 @@ class Asset implements Arrayable, ArrayAccess, AssetContract, Augmentable, Conta protected $withEvents = true; protected $shouldHydrate = true; protected $removedData = []; + protected $forgettingMissingFile = false; public function syncOriginal() { @@ -230,6 +233,34 @@ public function exists() return $this->container()->files()->contains($path); } + /** + * Confirm a cached listing entry against the disk. + * + * The listing can still name a file that has been removed. Metadata reads + * then ask Flysystem for the file size and throw. Drop that path and + * report the asset as missing. + */ + public function verifyExists(): bool + { + if (! $this->exists()) { + return false; + } + + try { + $exists = $this->disk()->exists($this->path()); + } catch (UnableToRetrieveMetadata|UnableToReadFile) { + $exists = false; + } + + if ($exists) { + return true; + } + + $this->forgetMissingFile(); + + return false; + } + public function getRawMeta() { return $this->meta; @@ -260,15 +291,25 @@ public function meta($key = null) return $meta; } - return $this->meta = $this->cacheStore()->rememberForever($this->metaCacheKey(), function () { + $meta = $this->cacheStore()->rememberForever($this->metaCacheKey(), function () { if ($contents = $this->disk()->get($path = $this->metaPath())) { return YAML::file($path)->parse($contents); } - $this->writeMeta($meta = $this->generateMeta()); + $meta = $this->generateMeta(); + + if ($this->exists()) { + $this->writeMeta($meta); + } return $meta; }); + + if (! $this->exists()) { + $this->cacheStore()->forget($this->metaCacheKey()); + } + + return $this->meta = $meta; } private function metaValue($key) @@ -281,7 +322,11 @@ private function metaValue($key) $this->cacheStore()->forget($this->metaCacheKey()); - $this->writeMeta($meta = $this->generateMeta()); + $meta = $this->generateMeta(); + + if ($this->exists()) { + $this->writeMeta($meta); + } return Arr::get($meta, $key); } @@ -291,21 +336,46 @@ public function generateMeta() $meta = ['data' => $this->data->all()]; if ($this->exists()) { - $attributes = Attributes::asset($this)->get(); - - $meta = array_merge($meta, [ - 'size' => $this->disk()->size($this->path()), - 'last_modified' => $this->disk()->lastModified($this->path()), - 'width' => Arr::get($attributes, 'width'), - 'height' => Arr::get($attributes, 'height'), - 'mime_type' => $this->disk()->mimeType($this->path()), - 'duration' => Arr::get($attributes, 'duration'), - ]); + try { + $attributes = Attributes::asset($this)->get(); + + $meta = array_merge($meta, [ + 'size' => $this->disk()->size($this->path()), + 'last_modified' => $this->disk()->lastModified($this->path()), + 'width' => Arr::get($attributes, 'width'), + 'height' => Arr::get($attributes, 'height'), + 'mime_type' => $this->disk()->mimeType($this->path()), + 'duration' => Arr::get($attributes, 'duration'), + ]); + } catch (UnableToRetrieveMetadata|UnableToReadFile) { + $this->forgetMissingFile(); + + return ['data' => $this->data->all()]; + } } return $meta; } + /** + * The cached directory listing still names this path, but the file is gone. + * Drop the path so later reads treat it as missing instead of asking Flysystem + * for metadata and throwing. + */ + private function forgetMissingFile(): void + { + if ($this->forgettingMissingFile) { + return; + } + + $this->forgettingMissingFile = true; + + $this->meta = null; + $this->cacheStore()->forget($this->metaCacheKey()); + + Facades\Asset::delete($this); + } + public function metaPath() { $path = dirname($this->path()).'/.meta/'.$this->basename().'.yaml'; diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php index 39a733477d6..e85017ed8da 100644 --- a/src/Assets/AssetContainer.php +++ b/src/Assets/AssetContainer.php @@ -474,11 +474,17 @@ public function asset($path) { $asset = $this->makeAsset($path); + if (! $asset->verifyExists()) { + return null; + } + + $asset->hydrate()->syncOriginal(); + if (! $asset->exists()) { return null; } - return $asset->hydrate()->syncOriginal(); + return $asset; } /** diff --git a/src/Assets/QueryBuilder.php b/src/Assets/QueryBuilder.php index 247d6d10644..d2f361b3341 100644 --- a/src/Assets/QueryBuilder.php +++ b/src/Assets/QueryBuilder.php @@ -110,6 +110,13 @@ protected function collect($items = []) return AssetCollection::make($items); } + protected function getItems($keys) + { + return parent::getItems($keys) + ->filter(fn ($asset) => $asset->verifyExists()) + ->values(); + } + protected function getWhereColumnKeyValuesByIndex($column) { $container = $this->getContainer()->handle(); diff --git a/tests/Assets/AssetContainerTest.php b/tests/Assets/AssetContainerTest.php index 21256960450..ccb7fdddbfb 100644 --- a/tests/Assets/AssetContainerTest.php +++ b/tests/Assets/AssetContainerTest.php @@ -816,6 +816,39 @@ public function it_gets_an_asset_with_data() $this->assertNull($container->asset('non-existent.txt')); } + #[Test] + public function a_listed_path_whose_file_is_missing_is_not_an_asset() + { + [$container, $missing] = $this->containerWithStaleListedFile(); + + $this->assertNull($container->asset($missing)); + $this->assertMissingPathWasDropped($container, $missing); + + $kept = $container->asset('keep.txt'); + + $this->assertInstanceOf(AssetContract::class, $kept); + $this->assertSame('keep.txt', $kept->path()); + + [$container, $missing] = $this->containerWithStaleListedFile(); + + $this->assertNull($container->queryAssets()->where('path', $missing)->first()); + $this->assertMissingPathWasDropped($container, $missing); + } + + #[Test] + public function reading_meta_for_a_listed_path_whose_file_is_missing_does_not_throw() + { + [$container, $missing] = $this->containerWithStaleListedFile(); + + $asset = $container->makeAsset($missing); + + $this->assertSame(['data' => []], $asset->meta()); + $this->assertSame(['data' => []], $asset->generateMeta()); + $this->assertFalse($asset->exists()); + $this->assertFalse(Storage::disk('stale_assets')->exists('social_images/collection-events/.meta/missing.png.yaml')); + $this->assertMissingPathWasDropped($container, $missing); + } + #[Test] public function it_makes_an_asset_at_given_path() { @@ -1100,4 +1133,51 @@ private function containerWithDisk($fixture = 'container') return $container; } + + private function containerWithStaleListedFile(): array + { + Blink::flush(); + + Storage::fake('stale_assets'); + Storage::disk('stale_assets')->put('keep.txt', 'still here'); + + $missing = 'social_images/collection-events/missing.png'; + $handle = 'stale_'.md5($missing.microtime()); + + Cache::put('asset-list-contents-'.$handle, collect([ + 'keep.txt' => [ + 'type' => 'file', + 'path' => 'keep.txt', + 'basename' => 'keep.txt', + 'filename' => 'keep', + 'dirname' => '', + 'extension' => 'txt', + ], + $missing => [ + 'type' => 'file', + 'path' => $missing, + 'basename' => 'missing.png', + 'filename' => 'missing', + 'dirname' => 'social_images/collection-events', + 'extension' => 'png', + ], + ])); + + $container = (new AssetContainer)->handle($handle)->disk('stale_assets'); + + Facades\AssetContainer::shouldReceive('findByHandle')->with($handle)->andReturn($container); + + return [$container, $missing]; + } + + private function assertMissingPathWasDropped($container, string $missing): void + { + $listed = Cache::get('asset-list-contents-'.$container->handle()); + + $this->assertFalse( + $listed?->has($missing) ?? false, + 'Missing path is still listed: '.json_encode($listed?->keys()->all()) + ); + $this->assertTrue($listed->has('keep.txt')); + } } From 6472441ab5d7001c9efed0c9f1aa64eeeb1aac0c Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Tue, 10 Nov 2026 15:17:50 +0100 Subject: [PATCH 02/11] Separate listing reconciliation from the existence check. reconcile() drops a listed path whose file is gone. exists() stays a listing lookup, so callers can tell those two steps apart. --- src/Assets/Asset.php | 18 ++++++++---------- src/Assets/AssetContainer.php | 2 +- src/Assets/QueryBuilder.php | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index b3df246fb06..a8f74645b50 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -234,16 +234,16 @@ public function exists() } /** - * Confirm a cached listing entry against the disk. + * Make the cached listing agree with the disk. * * The listing can still name a file that has been removed. Metadata reads - * then ask Flysystem for the file size and throw. Drop that path and - * report the asset as missing. + * then ask Flysystem for the file size and throw. Drop that path from the + * listing and the asset index so exists() reports the asset as missing. */ - public function verifyExists(): bool + public function reconcile(): static { if (! $this->exists()) { - return false; + return $this; } try { @@ -252,13 +252,11 @@ public function verifyExists(): bool $exists = false; } - if ($exists) { - return true; + if (! $exists) { + $this->forgetMissingFile(); } - $this->forgetMissingFile(); - - return false; + return $this; } public function getRawMeta() diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php index e85017ed8da..ba37273e304 100644 --- a/src/Assets/AssetContainer.php +++ b/src/Assets/AssetContainer.php @@ -474,7 +474,7 @@ public function asset($path) { $asset = $this->makeAsset($path); - if (! $asset->verifyExists()) { + if (! $asset->reconcile()->exists()) { return null; } diff --git a/src/Assets/QueryBuilder.php b/src/Assets/QueryBuilder.php index d2f361b3341..443baefdfd0 100644 --- a/src/Assets/QueryBuilder.php +++ b/src/Assets/QueryBuilder.php @@ -113,7 +113,7 @@ protected function collect($items = []) protected function getItems($keys) { return parent::getItems($keys) - ->filter(fn ($asset) => $asset->verifyExists()) + ->filter(fn ($asset) => $asset->reconcile()->exists()) ->values(); } From 8a4a0f3b15a2b668e316abe8ed2b1140897d4cc0 Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Tue, 10 Nov 2026 15:25:04 +0100 Subject: [PATCH 03/11] Return the meta already built when a file read fails. The file metadata is only merged after those reads succeed, so a throw leaves the original data array for the existing return. --- src/Assets/Asset.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index a8f74645b50..561d8950ce3 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -347,8 +347,6 @@ public function generateMeta() ]); } catch (UnableToRetrieveMetadata|UnableToReadFile) { $this->forgetMissingFile(); - - return ['data' => $this->data->all()]; } } From e9bacea261006a67dba4110f556e39aced9752bc Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Tue, 10 Nov 2026 15:35:12 +0100 Subject: [PATCH 04/11] Skip writing a meta file when the asset is no longer listed. A missing file is dropped during meta generation. writeMeta() now checks the listing itself, so no caller can save a meta file for that path. --- src/Assets/Asset.php | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index 561d8950ce3..1892477fc3a 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -294,11 +294,7 @@ public function meta($key = null) return YAML::file($path)->parse($contents); } - $meta = $this->generateMeta(); - - if ($this->exists()) { - $this->writeMeta($meta); - } + $this->writeMeta($meta = $this->generateMeta()); return $meta; }); @@ -320,11 +316,7 @@ private function metaValue($key) $this->cacheStore()->forget($this->metaCacheKey()); - $meta = $this->generateMeta(); - - if ($this->exists()) { - $this->writeMeta($meta); - } + $this->writeMeta($meta = $this->generateMeta()); return Arr::get($meta, $key); } @@ -386,6 +378,10 @@ protected function metaExists() public function writeMeta($meta) { + if (! $this->exists()) { + return; + } + $meta['data'] = Arr::removeNullValues($meta['data']); $contents = YAML::dump($meta); From c4a97528d99bbbe35505dfe89fd2001f6113759e Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Tue, 10 Nov 2026 16:01:10 +0100 Subject: [PATCH 05/11] Drop the re-entry guard around forgetting a missing asset. The repository delete updates the listing and the asset index without reading metadata, so it never calls forgetMissingFile() again. --- src/Assets/Asset.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index 1892477fc3a..c0d418f2c1a 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -78,7 +78,6 @@ class Asset implements Arrayable, ArrayAccess, AssetContract, Augmentable, Conta protected $withEvents = true; protected $shouldHydrate = true; protected $removedData = []; - protected $forgettingMissingFile = false; public function syncOriginal() { @@ -352,12 +351,6 @@ public function generateMeta() */ private function forgetMissingFile(): void { - if ($this->forgettingMissingFile) { - return; - } - - $this->forgettingMissingFile = true; - $this->meta = null; $this->cacheStore()->forget($this->metaCacheKey()); From 0028524f9dd65dd3a7fa92fa4683d8250a8a4c2c Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Tue, 10 Nov 2026 16:23:14 +0100 Subject: [PATCH 06/11] Keep unlisted assets in query results. A Stache asset is not missing just because the container listing does not name it yet. Only a listed path whose file is gone should be reconciled and dropped. --- src/Assets/QueryBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Assets/QueryBuilder.php b/src/Assets/QueryBuilder.php index 443baefdfd0..c78f195a106 100644 --- a/src/Assets/QueryBuilder.php +++ b/src/Assets/QueryBuilder.php @@ -113,7 +113,7 @@ protected function collect($items = []) protected function getItems($keys) { return parent::getItems($keys) - ->filter(fn ($asset) => $asset->reconcile()->exists()) + ->filter(fn ($asset) => ! $asset->exists() || $asset->reconcile()->exists()) ->values(); } From c4eedbad2eea878ded8d5af22c0c15f4c604dc3c Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Wed, 11 Nov 2026 09:46:13 +0100 Subject: [PATCH 07/11] Drop a missing asset from the listing without deleting its stored data. The asset repository's eloquent delete removes the meta row. Forget the cached path directly so a missing file no longer takes authored data with it. --- src/Assets/Asset.php | 13 ++++++++++--- tests/Assets/AssetContainerTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index c0d418f2c1a..787fdc836f9 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -39,6 +39,7 @@ use Statamic\Facades\Blink; use Statamic\Facades\Image; use Statamic\Facades\Path; +use Statamic\Facades\Stache; use Statamic\Facades\URL; use Statamic\Facades\YAML; use Statamic\GraphQL\ResolvesValues; @@ -346,15 +347,21 @@ public function generateMeta() /** * The cached directory listing still names this path, but the file is gone. - * Drop the path so later reads treat it as missing instead of asking Flysystem - * for metadata and throwing. + * Drop the path from the listing and, when assets are indexed in the Stache, + * from that index. Stored asset data stays put: this does not go through the + * asset repository, whose eloquent implementation deletes the meta row. */ private function forgetMissingFile(): void { $this->meta = null; $this->cacheStore()->forget($this->metaCacheKey()); - Facades\Asset::delete($this); + $this->container()->contents()->forget($this->path())->save(); + + // The eloquent driver keeps asset data in the database and excludes this store. + if (config('statamic.eloquent-driver.assets.driver') !== 'eloquent') { + Stache::store('assets::'.$this->containerHandle())->delete($this); + } } public function metaPath() diff --git a/tests/Assets/AssetContainerTest.php b/tests/Assets/AssetContainerTest.php index ccb7fdddbfb..edc7d4c2053 100644 --- a/tests/Assets/AssetContainerTest.php +++ b/tests/Assets/AssetContainerTest.php @@ -19,8 +19,10 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Assets\Asset; use Statamic\Assets\AssetContainer; +use Statamic\Assets\AssetRepository; use Statamic\Contracts\Assets\Asset as AssetContract; use Statamic\Contracts\Assets\AssetFolder; +use Statamic\Contracts\Assets\AssetRepository as AssetRepositoryContract; use Statamic\Events\AssetContainerCreated; use Statamic\Events\AssetContainerCreating; use Statamic\Events\AssetContainerDeleted; @@ -849,6 +851,28 @@ public function reading_meta_for_a_listed_path_whose_file_is_missing_does_not_th $this->assertMissingPathWasDropped($container, $missing); } + #[Test] + public function dropping_a_missing_file_leaves_the_asset_repository_alone() + { + $repository = $this->partialMock(AssetRepository::class); + $repository->shouldNotReceive('delete'); + $this->instance(AssetRepositoryContract::class, $repository); + + [$container, $missing] = $this->containerWithStaleListedFile(); + + Storage::disk('stale_assets')->put( + 'social_images/collection-events/.meta/missing.png.yaml', + "data:\n alt: 'Event poster'\n" + ); + + $this->assertNull($container->asset($missing)); + $this->assertMissingPathWasDropped($container, $missing); + $this->assertSame( + "data:\n alt: 'Event poster'\n", + Storage::disk('stale_assets')->get('social_images/collection-events/.meta/missing.png.yaml') + ); + } + #[Test] public function it_makes_an_asset_at_given_path() { From 4f0e0100bc5112b6c74ad6709cd9db331796b450 Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Wed, 11 Nov 2026 10:13:06 +0100 Subject: [PATCH 08/11] Confirm the file is gone before dropping a listed asset. A timeout or a 403 throws the same metadata exceptions as a missing file. Only drop the path when the disk reports it missing, and leave a failed read uncached so the next request can try again. --- src/Assets/Asset.php | 31 +++++++++++--- tests/Assets/AssetContainerTest.php | 66 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index 787fdc836f9..e0d1a6f9dd1 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -294,16 +294,26 @@ public function meta($key = null) return YAML::file($path)->parse($contents); } - $this->writeMeta($meta = $this->generateMeta()); + $meta = $this->generateMeta(); + + if ($this->hasGeneratedMeta($meta)) { + $this->writeMeta($meta); + } return $meta; }); - if (! $this->exists()) { + $generated = $this->hasGeneratedMeta($meta); + + if (! $generated) { $this->cacheStore()->forget($this->metaCacheKey()); } - return $this->meta = $meta; + if ($generated || ! $this->exists()) { + $this->meta = $meta; + } + + return $meta; } private function metaValue($key) @@ -316,7 +326,11 @@ private function metaValue($key) $this->cacheStore()->forget($this->metaCacheKey()); - $this->writeMeta($meta = $this->generateMeta()); + $meta = $this->generateMeta(); + + if ($this->hasGeneratedMeta($meta)) { + $this->writeMeta($meta); + } return Arr::get($meta, $key); } @@ -338,13 +352,20 @@ public function generateMeta() 'duration' => Arr::get($attributes, 'duration'), ]); } catch (UnableToRetrieveMetadata|UnableToReadFile) { - $this->forgetMissingFile(); + if (! $this->disk()->exists($this->path())) { + $this->forgetMissingFile(); + } } } return $meta; } + private function hasGeneratedMeta(array $meta): bool + { + return isset($meta['size']); + } + /** * The cached directory listing still names this path, but the file is gone. * Drop the path from the listing and, when assets are indexed in the Stache, diff --git a/tests/Assets/AssetContainerTest.php b/tests/Assets/AssetContainerTest.php index edc7d4c2053..8d8e6f5171f 100644 --- a/tests/Assets/AssetContainerTest.php +++ b/tests/Assets/AssetContainerTest.php @@ -5,6 +5,7 @@ use Facades\Statamic\Fields\BlueprintRepository; use Illuminate\Cache\Events\CacheHit; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -15,6 +16,7 @@ use League\Flysystem\DirectoryListing; use League\Flysystem\FileAttributes; use League\Flysystem\PathTraversalDetected; +use League\Flysystem\UnableToRetrieveMetadata; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Assets\Asset; @@ -851,6 +853,46 @@ public function reading_meta_for_a_listed_path_whose_file_is_missing_does_not_th $this->assertMissingPathWasDropped($container, $missing); } + #[Test] + public function a_metadata_read_failure_keeps_a_file_that_still_exists() + { + $path = 'notes.txt'; + $filesystem = \Mockery::mock(FilesystemAdapter::class); + $filesystem->shouldReceive('get')->andReturn(null); + $filesystem->shouldReceive('size')->andThrow(UnableToRetrieveMetadata::create($path, 'file_size')); + $filesystem->shouldReceive('exists')->twice()->andReturn(true); + $filesystem->shouldReceive('put')->never(); + + [$container, $asset] = $this->containerWithListedFile($path, $filesystem); + + $this->assertSame(['data' => []], $asset->meta()); + $this->assertSame(['data' => []], $asset->meta()); + $this->assertNull($asset->getRawMeta()); + $this->assertNull(Cache::get($asset->metaCacheKey())); + $this->assertTrue(Cache::get('asset-list-contents-'.$container->handle())->has($path)); + } + + #[Test] + public function a_metadata_read_failure_throws_when_the_existence_check_fails() + { + $path = 'notes.txt'; + $filesystem = \Mockery::mock(FilesystemAdapter::class); + $filesystem->shouldReceive('get')->andReturn(null); + $filesystem->shouldReceive('size')->andThrow(UnableToRetrieveMetadata::create($path, 'file_size')); + $filesystem->shouldReceive('exists')->andThrow(UnableToRetrieveMetadata::create($path, 'file_size')); + $filesystem->shouldReceive('put')->never(); + + [$container, $asset] = $this->containerWithListedFile($path, $filesystem); + + try { + $asset->meta(); + $this->fail('The existence check should throw.'); + } catch (UnableToRetrieveMetadata) { + $this->assertNull(Cache::get($asset->metaCacheKey())); + $this->assertTrue(Cache::get('asset-list-contents-'.$container->handle())->has($path)); + } + } + #[Test] public function dropping_a_missing_file_leaves_the_asset_repository_alone() { @@ -1158,6 +1200,30 @@ private function containerWithDisk($fixture = 'container') return $container; } + private function containerWithListedFile(string $path, FilesystemAdapter $filesystem): array + { + Blink::flush(); + + $handle = 'listed_'.md5($path.microtime()); + + Cache::put('asset-list-contents-'.$handle, collect([ + $path => [ + 'type' => 'file', + 'path' => $path, + 'basename' => basename($path), + 'filename' => pathinfo($path, PATHINFO_FILENAME), + 'dirname' => '', + 'extension' => pathinfo($path, PATHINFO_EXTENSION), + ], + ])); + + $container = (new AssetContainer)->handle($handle)->disk($filesystem); + + Facades\AssetContainer::shouldReceive('findByHandle')->with($handle)->andReturn($container); + + return [$container, $container->makeAsset($path)]; + } + private function containerWithStaleListedFile(): array { Blink::flush(); From d36a8bab4780098d1b59798c988cc6e188e70a45 Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Wed, 11 Nov 2026 10:56:47 +0100 Subject: [PATCH 09/11] Stop statting the disk on every asset lookup. A missing file is dropped when its metadata is generated. Lookups and queries only need the listing, so the public reconcile method can go. --- src/Assets/Asset.php | 26 -------------------------- src/Assets/AssetContainer.php | 2 +- src/Assets/QueryBuilder.php | 7 ------- tests/Assets/AssetContainerTest.php | 14 ++++++++------ 4 files changed, 9 insertions(+), 40 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index e0d1a6f9dd1..7ac7ca1de79 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -233,32 +233,6 @@ public function exists() return $this->container()->files()->contains($path); } - /** - * Make the cached listing agree with the disk. - * - * The listing can still name a file that has been removed. Metadata reads - * then ask Flysystem for the file size and throw. Drop that path from the - * listing and the asset index so exists() reports the asset as missing. - */ - public function reconcile(): static - { - if (! $this->exists()) { - return $this; - } - - try { - $exists = $this->disk()->exists($this->path()); - } catch (UnableToRetrieveMetadata|UnableToReadFile) { - $exists = false; - } - - if (! $exists) { - $this->forgetMissingFile(); - } - - return $this; - } - public function getRawMeta() { return $this->meta; diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php index ba37273e304..a6c0e1b7657 100644 --- a/src/Assets/AssetContainer.php +++ b/src/Assets/AssetContainer.php @@ -474,7 +474,7 @@ public function asset($path) { $asset = $this->makeAsset($path); - if (! $asset->reconcile()->exists()) { + if (! $asset->exists()) { return null; } diff --git a/src/Assets/QueryBuilder.php b/src/Assets/QueryBuilder.php index c78f195a106..247d6d10644 100644 --- a/src/Assets/QueryBuilder.php +++ b/src/Assets/QueryBuilder.php @@ -110,13 +110,6 @@ protected function collect($items = []) return AssetCollection::make($items); } - protected function getItems($keys) - { - return parent::getItems($keys) - ->filter(fn ($asset) => ! $asset->exists() || $asset->reconcile()->exists()) - ->values(); - } - protected function getWhereColumnKeyValuesByIndex($column) { $container = $this->getContainer()->handle(); diff --git a/tests/Assets/AssetContainerTest.php b/tests/Assets/AssetContainerTest.php index 8d8e6f5171f..8f5dce93bd5 100644 --- a/tests/Assets/AssetContainerTest.php +++ b/tests/Assets/AssetContainerTest.php @@ -832,11 +832,6 @@ public function a_listed_path_whose_file_is_missing_is_not_an_asset() $this->assertInstanceOf(AssetContract::class, $kept); $this->assertSame('keep.txt', $kept->path()); - - [$container, $missing] = $this->containerWithStaleListedFile(); - - $this->assertNull($container->queryAssets()->where('path', $missing)->first()); - $this->assertMissingPathWasDropped($container, $missing); } #[Test] @@ -907,7 +902,14 @@ public function dropping_a_missing_file_leaves_the_asset_repository_alone() "data:\n alt: 'Event poster'\n" ); - $this->assertNull($container->asset($missing)); + $asset = $container->asset($missing); + + $this->assertInstanceOf(AssetContract::class, $asset); + $this->assertSame('Event poster', $asset->get('alt')); + $this->assertTrue(Cache::get('asset-list-contents-'.$container->handle())->has($missing)); + + $asset->generateMeta(); + $this->assertMissingPathWasDropped($container, $missing); $this->assertSame( "data:\n alt: 'Event poster'\n", From 5a7cc0f0a2d91e18be83ce585e844041f35216e5 Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Wed, 11 Nov 2026 11:10:39 +0100 Subject: [PATCH 10/11] Tighten the missing-asset tests. Drop assertions that only described behavior removed before the pull request, and name the remaining tests like the rest of the file. --- tests/Assets/AssetContainerTest.php | 39 +++++++++++------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/tests/Assets/AssetContainerTest.php b/tests/Assets/AssetContainerTest.php index 8f5dce93bd5..65a62a32eb7 100644 --- a/tests/Assets/AssetContainerTest.php +++ b/tests/Assets/AssetContainerTest.php @@ -17,6 +17,7 @@ use League\Flysystem\FileAttributes; use League\Flysystem\PathTraversalDetected; use League\Flysystem\UnableToRetrieveMetadata; +use Mockery; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Assets\Asset; @@ -821,7 +822,7 @@ public function it_gets_an_asset_with_data() } #[Test] - public function a_listed_path_whose_file_is_missing_is_not_an_asset() + public function it_does_not_return_an_asset_when_a_listed_file_is_missing() { [$container, $missing] = $this->containerWithStaleListedFile(); @@ -835,43 +836,39 @@ public function a_listed_path_whose_file_is_missing_is_not_an_asset() } #[Test] - public function reading_meta_for_a_listed_path_whose_file_is_missing_does_not_throw() + public function it_does_not_throw_when_reading_meta_for_a_missing_listed_file() { [$container, $missing] = $this->containerWithStaleListedFile(); $asset = $container->makeAsset($missing); $this->assertSame(['data' => []], $asset->meta()); - $this->assertSame(['data' => []], $asset->generateMeta()); - $this->assertFalse($asset->exists()); $this->assertFalse(Storage::disk('stale_assets')->exists('social_images/collection-events/.meta/missing.png.yaml')); $this->assertMissingPathWasDropped($container, $missing); } #[Test] - public function a_metadata_read_failure_keeps_a_file_that_still_exists() + public function it_keeps_a_listed_file_when_metadata_cannot_be_read() { $path = 'notes.txt'; - $filesystem = \Mockery::mock(FilesystemAdapter::class); + $filesystem = Mockery::mock(FilesystemAdapter::class); $filesystem->shouldReceive('get')->andReturn(null); $filesystem->shouldReceive('size')->andThrow(UnableToRetrieveMetadata::create($path, 'file_size')); - $filesystem->shouldReceive('exists')->twice()->andReturn(true); + $filesystem->shouldReceive('exists')->once()->andReturn(true); $filesystem->shouldReceive('put')->never(); [$container, $asset] = $this->containerWithListedFile($path, $filesystem); $this->assertSame(['data' => []], $asset->meta()); - $this->assertSame(['data' => []], $asset->meta()); - $this->assertNull($asset->getRawMeta()); $this->assertNull(Cache::get($asset->metaCacheKey())); $this->assertTrue(Cache::get('asset-list-contents-'.$container->handle())->has($path)); } #[Test] - public function a_metadata_read_failure_throws_when_the_existence_check_fails() + public function it_does_not_drop_a_listed_file_when_the_existence_check_throws() { $path = 'notes.txt'; - $filesystem = \Mockery::mock(FilesystemAdapter::class); + $filesystem = Mockery::mock(FilesystemAdapter::class); $filesystem->shouldReceive('get')->andReturn(null); $filesystem->shouldReceive('size')->andThrow(UnableToRetrieveMetadata::create($path, 'file_size')); $filesystem->shouldReceive('exists')->andThrow(UnableToRetrieveMetadata::create($path, 'file_size')); @@ -881,15 +878,18 @@ public function a_metadata_read_failure_throws_when_the_existence_check_fails() try { $asset->meta(); - $this->fail('The existence check should throw.'); } catch (UnableToRetrieveMetadata) { $this->assertNull(Cache::get($asset->metaCacheKey())); $this->assertTrue(Cache::get('asset-list-contents-'.$container->handle())->has($path)); + + return; } + + $this->fail('Exception was not thrown.'); } #[Test] - public function dropping_a_missing_file_leaves_the_asset_repository_alone() + public function it_keeps_stored_asset_data_when_dropping_a_missing_file() { $repository = $this->partialMock(AssetRepository::class); $repository->shouldNotReceive('delete'); @@ -902,13 +902,7 @@ public function dropping_a_missing_file_leaves_the_asset_repository_alone() "data:\n alt: 'Event poster'\n" ); - $asset = $container->asset($missing); - - $this->assertInstanceOf(AssetContract::class, $asset); - $this->assertSame('Event poster', $asset->get('alt')); - $this->assertTrue(Cache::get('asset-list-contents-'.$container->handle())->has($missing)); - - $asset->generateMeta(); + $container->makeAsset($missing)->generateMeta(); $this->assertMissingPathWasDropped($container, $missing); $this->assertSame( @@ -1266,10 +1260,7 @@ private function assertMissingPathWasDropped($container, string $missing): void { $listed = Cache::get('asset-list-contents-'.$container->handle()); - $this->assertFalse( - $listed?->has($missing) ?? false, - 'Missing path is still listed: '.json_encode($listed?->keys()->all()) - ); + $this->assertFalse($listed->has($missing)); $this->assertTrue($listed->has('keep.txt')); } } From f22809b97d3ec30bdca459f1dc5c5990573651d5 Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Wed, 11 Nov 2026 12:11:40 +0100 Subject: [PATCH 11/11] Drop a listed asset when an image read finds the file gone. A warm meta cache never reaches generateMeta(), so a deleted file stayed in the listing after the thumbnail 404'd. --- src/Assets/Asset.php | 2 +- src/Imaging/ImageGenerator.php | 6 ++ tests/Feature/Assets/ImageThumbnailTest.php | 65 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index 7ac7ca1de79..3018fbf62b9 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -346,7 +346,7 @@ private function hasGeneratedMeta(array $meta): bool * from that index. Stored asset data stays put: this does not go through the * asset repository, whose eloquent implementation deletes the meta row. */ - private function forgetMissingFile(): void + public function forgetMissingFile(): void { $this->meta = null; $this->cacheStore()->forget($this->metaCacheKey()); diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index 99116c6fd06..30fe8edb70a 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -90,6 +90,7 @@ public function generateByPath($path, array $params) private function doGenerateByPath($path, array $params, $sourceFilesystemRoot = null) { + $this->asset = null; $this->path = $path; $this->setParams($params); @@ -116,6 +117,7 @@ public function generateByUrl($url, array $params) private function doGenerateByUrl($url, array $params) { + $this->asset = null; $this->skip_validation = true; $this->setParams($params); @@ -318,6 +320,10 @@ private function generate($image) try { $path = $this->server->makeImage($image, $this->params); } catch (GlideFileNotFoundException $e) { + if (! $this->asset?->disk()?->exists($this->asset->path())) { + $this->asset?->forgetMissingFile(); + } + throw UnableToReadFile::fromLocation($image); } diff --git a/tests/Feature/Assets/ImageThumbnailTest.php b/tests/Feature/Assets/ImageThumbnailTest.php index ba1c65a5575..c73f270cabf 100644 --- a/tests/Feature/Assets/ImageThumbnailTest.php +++ b/tests/Feature/Assets/ImageThumbnailTest.php @@ -3,9 +3,17 @@ namespace Tests\Feature\Assets; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Attributes\Test; +use Statamic\Assets\AssetRepository; +use Statamic\Contracts\Assets\AssetRepository as AssetRepositoryContract; use Statamic\Facades\AssetContainer; +use Statamic\Facades\Blink; +use Statamic\Facades\File; +use Statamic\Facades\Glide; use Statamic\Facades\User; +use Statamic\Facades\YAML; use Tests\FakesRoles; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -25,6 +33,9 @@ public function setUp(): void 'driver' => 'local', 'root' => $this->tempDir = __DIR__.'/tmp', ]]); + + Glide::cacheStore()->flush(); + File::delete(storage_path('statamic/glide')); } public function tearDown(): void @@ -81,4 +92,58 @@ public function it_denies_access_without_permission_to_view_asset() ->getJson('/cp/thumbnails/'.base64_encode('test::one.png')) ->assertForbidden(); } + + #[Test] + public function it_drops_a_missing_file_from_the_browser_after_the_thumbnail_read_fails() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $asset = $container + ->makeAsset('one.png') + ->upload(UploadedFile::fake()->image('one.png', 30, 60)); + $asset->data(['alt' => 'Event poster'])->save(); + $container + ->makeAsset('keep.png') + ->upload(UploadedFile::fake()->image('keep.png', 30, 60)); + + $cached = Cache::get($asset->metaCacheKey()); + + $this->assertIsArray($cached); + $this->assertArrayHasKey('size', $cached); + $this->assertArrayHasKey('mime_type', $cached); + + Storage::disk('test')->delete('one.png'); + + $repository = $this->partialMock(AssetRepository::class); + $repository->shouldNotReceive('delete'); + $this->instance(AssetRepositoryContract::class, $repository); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/assets/browse/folders/test/') + ->assertSuccessful() + ->assertJsonFragment(['path' => 'one.png']) + ->assertJsonFragment(['path' => 'keep.png']); + + $this + ->actingAs($user) + ->getJson('/cp/thumbnails/'.base64_encode('test::one.png')) + ->assertNotFound(); + + Blink::flush(); + + $this + ->actingAs($user) + ->getJson('/cp/assets/browse/folders/test/') + ->assertSuccessful() + ->assertJsonMissing(['path' => 'one.png']) + ->assertJsonFragment(['path' => 'keep.png']); + + $this->assertSame( + 'Event poster', + YAML::parse(Storage::disk('test')->get('.meta/one.png.yaml'))['data']['alt'] + ); + } }