Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 30 additions & 37 deletions src/Cache/FileCacheStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,32 @@
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;
use function opendir;
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)
{
Expand All @@ -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();
}

/**
Expand All @@ -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,
"<?php declare(strict_types = 1);\n\n" . '// ' . $key . "\nreturn " . $exported . ';',
);
FileWriter::write($tmpPath, $serialized);

$renameSuccess = @rename($tmpPath, $path);
if ($renameSuccess) {
Expand All @@ -106,7 +109,9 @@ private function getFilePaths(string $key): array
$keyHash = hash('sha256', $key);
$firstDirectory = sprintf('%s/%s', $this->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,
Expand Down Expand Up @@ -136,25 +141,13 @@ public function clearUnusedFiles(): void
$iterator = new RecursiveDirectoryIterator($this->directory);
$iterator->setFlags(RecursiveDirectoryIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($iterator);
$beginFunction = sprintf(
"<?php declare(strict_types = 1);\n\n%s",
sprintf('// %s', 'variadic-function-'),
);
$beginMethod = sprintf(
"<?php declare(strict_types = 1);\n\n%s",
sprintf('// %s', 'variadic-method-'),
);
$beginNew = "<?php declare(strict_types = 1);\n\n//";
$serializedPrefix = sprintf('O:%d:"%s"', strlen(CacheItem::class), CacheItem::class);
$emptyDirectoriesToCheck = [];
foreach ($files as $file) {
try {
$path = $file->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;
}

Expand Down
74 changes: 74 additions & 0 deletions tests/PHPStan/Cache/FileCacheStorageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php declare(strict_types = 1);

namespace PHPStan\Cache;

use Override;
use PHPStan\Internal\DirectoryCreatorException;
use PHPUnit\Framework\TestCase;
use function exec;
use function escapeshellarg;
use function file_put_contents;
use function is_file;
use function mkdir;
use function sys_get_temp_dir;
use function uniqid;

class FileCacheStorageTest extends TestCase
{

private string $directory;

#[Override]
protected function setUp(): void
{
$this->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, "<?php declare(strict_types = 1);\n\n// legacy-key\nreturn 'legacy';");

$storage->clearUnusedFiles();

$this->assertFalse(is_file($legacyFile));
}

}
Loading