diff --git a/src/Cache/FileCacheStorage.php b/src/Cache/FileCacheStorage.php index 6929806f694..db42f27c3fe 100644 --- a/src/Cache/FileCacheStorage.php +++ b/src/Cache/FileCacheStorage.php @@ -13,10 +13,11 @@ use PHPStan\ShouldNotHappenException; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use Throwable; use function array_keys; use function closedir; use function dirname; -use function error_get_last; +use function file_get_contents; use function hash; use function is_dir; use function is_file; @@ -24,19 +25,20 @@ use function readdir; use function rename; use function rmdir; +use function serialize; use function sprintf; use function str_starts_with; use function strlen; use function substr; use function uksort; use function unlink; -use function var_export; +use function unserialize; use const DIRECTORY_SEPARATOR; final class FileCacheStorage implements CacheStorage { - private const CACHED_CLEARED_VERSION = 'v2-new'; + private const CACHED_CLEARED_VERSION = 'v3-serialized'; public function __construct(private string $directory) { @@ -49,17 +51,22 @@ public function load(string $key, string $variableKey) { [,, $filePath] = $this->getFilePaths($key); - return (static function ($variableKey, $filePath) { - $cacheItem = @include $filePath; - if (!$cacheItem instanceof CacheItem) { - return null; - } - if (!$cacheItem->isVariableKeyValid($variableKey)) { - return null; - } + $contents = @file_get_contents($filePath); + if ($contents === false) { + return null; + } - return $cacheItem->getData(); - })($variableKey, $filePath); + // entries written by older versions in the var_export/include format + // fail to unserialize and simply count as a cache miss + $cacheItem = @unserialize($contents); + if (!$cacheItem instanceof CacheItem) { + return null; + } + if (!$cacheItem->isVariableKeyValid($variableKey)) { + return null; + } + + return $cacheItem->getData(); } /** @@ -74,16 +81,12 @@ public function save(string $key, string $variableKey, $data): void DirectoryCreator::ensureDirectoryExists($secondDirectory, 0777); $tmpPath = sprintf('%s/%s.tmp', $this->directory, Random::generate()); - $errorBefore = error_get_last(); - $exported = @var_export(new CacheItem($variableKey, $data), true); - $errorAfter = error_get_last(); - if ($errorAfter !== null && $errorBefore !== $errorAfter) { - throw new ShouldNotHappenException(sprintf('Error occurred while saving item %s (%s) to cache: %s', $key, $variableKey, $errorAfter['message'])); + try { + $serialized = serialize(new CacheItem($variableKey, $data)); + } catch (Throwable $e) { + throw new ShouldNotHappenException(sprintf('Error occurred while saving item %s (%s) to cache: %s', $key, $variableKey, $e->getMessage())); } - FileWriter::write( - $tmpPath, - "directory, substr($keyHash, 0, 2)); $secondDirectory = sprintf('%s/%s', $firstDirectory, substr($keyHash, 2, 2)); - $filePath = sprintf('%s/%s.php', $secondDirectory, $keyHash); + // .dat, not .php: an older PHPStan version sharing the same tmpDir would + // include a .php cache file and echo the serialized payload to stdout + $filePath = sprintf('%s/%s.dat', $secondDirectory, $keyHash); return [ $firstDirectory, @@ -136,25 +141,13 @@ public function clearUnusedFiles(): void $iterator = new RecursiveDirectoryIterator($this->directory); $iterator->setFlags(RecursiveDirectoryIterator::SKIP_DOTS); $files = new RecursiveIteratorIterator($iterator); - $beginFunction = sprintf( - "getPathname(); $contents = FileReader::read($path); - if ( - !str_starts_with($contents, $beginFunction) - && !str_starts_with($contents, $beginMethod) - && str_starts_with($contents, $beginNew) - ) { + if (str_starts_with($contents, $serializedPrefix)) { continue; } diff --git a/tests/PHPStan/Cache/FileCacheStorageTest.php b/tests/PHPStan/Cache/FileCacheStorageTest.php new file mode 100644 index 00000000000..aef78a61dec --- /dev/null +++ b/tests/PHPStan/Cache/FileCacheStorageTest.php @@ -0,0 +1,74 @@ +directory = sys_get_temp_dir() . '/phpstan-file-cache-storage-test-' . uniqid(); + mkdir($this->directory, 0777, true); + } + + #[Override] + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->directory)); + } + + /** + * @throws DirectoryCreatorException + */ + public function testSaveAndLoadRoundTrip(): void + { + $storage = new FileCacheStorage($this->directory); + $storage->save('some-key', 'variable-key', ['data' => [1, 2, 3]]); + + $this->assertSame(['data' => [1, 2, 3]], $storage->load('some-key', 'variable-key')); + $this->assertNull($storage->load('some-key', 'different-variable-key')); + $this->assertNull($storage->load('unknown-key', 'variable-key')); + } + + /** + * @throws DirectoryCreatorException + */ + public function testClearUnusedFilesKeepsCurrentFormatEntries(): void + { + $storage = new FileCacheStorage($this->directory); + $storage->save('some-key', 'variable-key', 'cached-value'); + + // no cache-cleared marker exists yet - cleanup must not treat + // current-format entries as legacy garbage + $storage->clearUnusedFiles(); + + $this->assertSame('cached-value', $storage->load('some-key', 'variable-key')); + } + + public function testClearUnusedFilesRemovesLegacyFormatEntries(): void + { + $storage = new FileCacheStorage($this->directory); + $legacyFile = $this->directory . '/ab/cd/legacy.php'; + mkdir($this->directory . '/ab/cd', 0777, true); + file_put_contents($legacyFile, "clearUnusedFiles(); + + $this->assertFalse(is_file($legacyFile)); + } + +}