diff --git a/src/Extensions/FileStore.php b/src/Extensions/FileStore.php index 04bfe853edb..ce1d5836343 100644 --- a/src/Extensions/FileStore.php +++ b/src/Extensions/FileStore.php @@ -5,9 +5,15 @@ use Illuminate\Cache\FileStore as LaravelFileStore; use Illuminate\Contracts\Cache\Store; use Statamic\Support\Str; +use Throwable; class FileStore extends LaravelFileStore implements Store { + /** + * The expiration Laravel writes for items stored forever. + */ + private const FOREVER = '9999999999'; + private ?string $dir = null; public function path($key) @@ -31,4 +37,57 @@ private function dir() ? $this->directory.'/' : $this->directory.'/stache/'; } + + /** + * Retrieve an item from the cache by key. + * + * Behaves like the parent, but the Stache reads far more often than it writes and + * almost everything it stores never expires. So this opens the file directly rather + * than checking it exists first, and only checks the clock for items that can expire. + */ + public function get($key) + { + if (! $handle = @fopen($this->path($key), 'rb')) { + return null; + } + + try { + // Like the parent, take a shared lock so we never read a file mid-write. + if (! flock($handle, LOCK_SH)) { + return null; + } + + $contents = stream_get_contents($handle); + + flock($handle, LOCK_UN); + } finally { + fclose($handle); + } + + $expire = substr($contents, 0, 10); + + if ($expire !== self::FOREVER && $this->currentTime() >= (int) $expire) { + $this->forget($key); + + return null; + } + + try { + return $this->unserializeValue(substr($contents, 10)); + } catch (Throwable) { + $this->forget($key); + + return null; + } + } + + private function unserializeValue(string $value) + { + // Laravel only supports restricting unserializable classes in newer versions. + if (method_exists(LaravelFileStore::class, 'unserialize')) { + return $this->unserialize($value); + } + + return unserialize($value); + } } diff --git a/tests/Extensions/FileStoreTest.php b/tests/Extensions/FileStoreTest.php index cfaa71d396a..79adce0f390 100644 --- a/tests/Extensions/FileStoreTest.php +++ b/tests/Extensions/FileStoreTest.php @@ -3,14 +3,35 @@ namespace Tests\Extensions; use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\FileStore as LaravelFileStore; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Orchestra\Testbench\Attributes\DefineEnvironment; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Extensions\FileStore; use Tests\TestCase; class FileStoreTest extends TestCase { + private $dir; + + public function setUp(): void + { + parent::setUp(); + + $this->dir = __DIR__.'/tmp-file-store'; + } + + public function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->dir); + Carbon::setTestNow(); + + parent::tearDown(); + } + #[Test] #[DefineEnvironment('cache')] public function it_overrides_file_driven_stores() @@ -44,4 +65,86 @@ public function cache($app) 'driver' => 'array', ]); } + + public static function keyProvider() + { + return [ + 'stache key' => ['stache::items::entries::articles::'], + 'other key' => ['other-'], + ]; + } + + #[Test] + #[DataProvider('keyProvider')] + public function it_reads_values($prefix) + { + $store = new FileStore(new Filesystem, $this->dir); + + $store->forever($prefix.'array', ['foo' => 'bar']); + $store->forever($prefix.'false', false); + $store->forever($prefix.'null', null); + $store->put($prefix.'expiring', 'baz', 60); + + $this->assertEquals(['foo' => 'bar'], $store->get($prefix.'array')); + $this->assertFalse($store->get($prefix.'false')); + $this->assertNull($store->get($prefix.'null')); + $this->assertEquals('baz', $store->get($prefix.'expiring')); + $this->assertNull($store->get($prefix.'missing')); + $this->assertEquals( + [$prefix.'array' => ['foo' => 'bar'], $prefix.'missing' => null], + $store->many([$prefix.'array', $prefix.'missing']) + ); + } + + #[Test] + #[DataProvider('keyProvider')] + public function expired_values_are_forgotten($prefix) + { + $store = new FileStore(new Filesystem, $this->dir); + + Carbon::setTestNow(Carbon::parse('2025-01-01 00:00:00')); + $store->put($prefix.'expiring', 'baz', 60); + $store->forever($prefix.'forever', 'qux'); + + Carbon::setTestNow(Carbon::parse('2025-01-01 00:01:00')); + + $this->assertNull($store->get($prefix.'expiring')); + $this->assertFileDoesNotExist($store->path($prefix.'expiring')); + $this->assertEquals('qux', $store->get($prefix.'forever')); + } + + #[Test] + #[DataProvider('corruptFileProvider')] + public function corrupt_files_are_forgotten($contents) + { + $store = new FileStore(new Filesystem, $this->dir); + $path = $store->path('stache::items::entries::articles::corrupt'); + @mkdir(dirname($path), 0777, true); + file_put_contents($path, $contents); + + $this->assertNull($store->get('stache::items::entries::articles::corrupt')); + $this->assertFileDoesNotExist($path); + } + + public static function corruptFileProvider() + { + return [ + 'empty' => [''], + 'only part of the expiration' => ['99999'], + 'truncated value' => ['9999999999'.substr(serialize(['foo' => str_repeat('bar', 100)]), 0, 50)], + ]; + } + + #[Test] + public function it_only_unserializes_allowed_classes_when_configured() + { + if (! method_exists(LaravelFileStore::class, 'unserialize')) { + $this->markTestSkipped('This version of Laravel does not support restricting unserializable classes.'); + } + + $store = new FileStore(new Filesystem, $this->dir, null, []); + $store->forever('stache::items::entries::articles::object', new \ArrayObject(['foo' => 'bar'])); + + $this->assertInstanceOf(\__PHP_Incomplete_Class::class, $store->get('stache::items::entries::articles::object')); + } }