diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b3ac1..2c61a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Changed +- Cache files are now written to a temporary file and atomically published, + preventing readers from observing partially written cache entries. - Added scalar, nullable, and `mixed` type declarations to the public cache API and its configuration properties. Calls that already use the documented value types remain compatible. @@ -19,6 +21,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Caching PHP objects is no longer supported. - Legacy serialized variable cache files are treated as invalid cache entries, dropped, and handled as cache miss. +- Using the implicit system temporary directory now emits an `E_USER_DEPRECATED` + warning once per request. Configure `SimplePhpCache::$cacheBaseDir`; the + fallback will be removed in version 2.0. ### Added - Additional tests for large variable payloads with multiline text and legacy invalid-cache handling. diff --git a/README.md b/README.md index 5bcef6b..9f09362 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,16 @@ Load Composer's autoloader and import the class: use Tschueller\SimplePhpCache\SimplePhpCache; -Set the cacheBaseDir (Optional, default is the system temp directory) +Set the cache base directory (required for production): - SimplePhpCache::$cacheBaseDir = "./"; + SimplePhpCache::$cacheBaseDir = __DIR__ . "/var/cache"; + +If no cache base directory is configured, SimplePhpCache temporarily falls back +to the system temporary directory for backward compatibility. This emits an +`E_USER_DEPRECATED` warning once per request and will be removed in version 2.0. +Set an application-owned directory outside the web root. Standard PHP error +configuration controls whether this warning is displayed or logged; for example, +exclude `E_USER_DEPRECATED` from `error_reporting` to suppress it. Set the max cache time in seconds (Optional, default is 86400 (1 day)): diff --git a/TODO.md b/TODO.md index ad11245..b332080 100644 --- a/TODO.md +++ b/TODO.md @@ -4,10 +4,6 @@ Deferred improvements — implement when explicitly requested or when capacity a ## v1.x -- **[MEDIUM] Make cache writes atomic and protect readers from partial files** - `LOCK_EX` does not prevent readers from observing a file while it is being - written. Add an atomic-write strategy and concurrent-read/write tests. - - **[MEDIUM] Publish to Packagist** Run `composer validate --strict`, submit to packagist.org. Prerequisite: stable tagged 1.0.0 release with namespace. @@ -25,8 +21,21 @@ Deferred improvements — implement when explicitly requested or when capacity a Edge cases: empty string IDs, very long IDs, unreadable cache directory, Unicode in cache IDs. +- **[OPTIONAL] Prevent cache stampedes for concurrent cache misses** + Add per-key coordination so concurrent requests do not regenerate the same + missing or expired entry. Relevant for expensive cache generation. + +- **[OPTIONAL] Define `clearCache()` behaviour during concurrent writes** + Decide whether an active writer may republish an entry after it was cleared; + implement synchronization only if strict clearing semantics are required. + ## v2.x +- **[MEDIUM] Remove the deprecated implicit cache directory** + The v1.x fallback to the system temporary directory emits `E_USER_DEPRECATED`. + In 2.0, require an explicit `$cacheBaseDir` and throw a clear exception when + it is missing. Keep the migration guidance in README.md. + - **[MEDIUM] Implement PSR-16 SimpleCache interface** Adds a separate PSR-16 cache API and storage model. Deliberately out of scope for the stable 1.x SimplePhpCache API; deserves its own major-version PR. diff --git a/demo/index.php b/demo/index.php index c84d201..74e7c8a 100644 --- a/demo/index.php +++ b/demo/index.php @@ -7,8 +7,6 @@ // Set the cacheBaseDir (default is the system temp directory). SimplePhpCache::$cacheBaseDir = __DIR__; -echo SimplePhpCache::$cacheBaseDir; - // Set the max cache time (if needed). Default is 86400; //SimplePhpCache::$maxCacheTime = 86400; diff --git a/docs/security-notes.md b/docs/security-notes.md index 757d9c8..2975efd 100644 --- a/docs/security-notes.md +++ b/docs/security-notes.md @@ -13,10 +13,11 @@ They are treated as invalid, deleted, and handled as cache miss. ## Cache Directory Permissions (Medium) The `.simplePhpCache` subdirectory is created with mode `0770` when it does not -already exist. The default base directory is the system temporary directory. -On shared systems, another local user could create the cache subdirectory first -or otherwise control its contents. This can enable cache poisoning and, with -unsafe filesystem permissions, symlink attacks. +already exist. The implicit fallback base directory is the system temporary +directory. Its use emits an `E_USER_DEPRECATED` warning once per request and +will be removed in version 2.0. On shared systems, another local user could +create the cache subdirectory first or otherwise control its contents. This can +enable cache poisoning and, with unsafe filesystem permissions, symlink attacks. Recommendations: diff --git a/src/SimplePhpCache.php b/src/SimplePhpCache.php index 9d92cad..8962ab4 100644 --- a/src/SimplePhpCache.php +++ b/src/SimplePhpCache.php @@ -26,6 +26,9 @@ class SimplePhpCache /** The cache base directory. */ public static ?string $cacheBaseDir = null; + /** Whether the implicit cache directory deprecation was already reported. */ + private static bool $defaultCacheDirectoryDeprecationReported = false; + /** The max cache time. */ public static int $maxCacheTime = 86400; @@ -100,8 +103,7 @@ public static function finishHTMLCaching(string $id): string throw new RuntimeException("Error reading output buffer"); } - if (file_put_contents($cacheFile, $content, LOCK_EX) === false) - throw new RuntimeException("Error writing cache: '$cacheFile'"); + self::writeCacheFile($cacheFile, $content); } self::$startedCache = null; @@ -311,8 +313,47 @@ private static function writeVarCachePayload(string $cacheFile, mixed $data): vo throw new RuntimeException("Error encoding variable cache payload: " . $e->getMessage(), 0, $e); } - if (file_put_contents($cacheFile, $payload, LOCK_EX) === false) { - throw new RuntimeException("Error writing cache: '$cacheFile'"); + self::writeCacheFile($cacheFile, $payload); + } + + /** + * Write a cache file through a temporary file and atomically publish it. + * + * Keeping the temporary file in the cache directory makes rename() an + * atomic replacement on supported filesystems, so readers see either the + * complete old file or the complete new file. + * + * @param string $cacheFile + * @param string $content + * @throws RuntimeException + */ + private static function writeCacheFile(string $cacheFile, string $content): void + { + $cacheDir = realpath(dirname($cacheFile)); + if ($cacheDir === false) { + throw new RuntimeException("Cache directory does not exist for: '$cacheFile'"); + } + + $temporaryFile = tempnam($cacheDir, '.simplephpcache-'); + if ($temporaryFile === false) { + throw new RuntimeException("Error creating temporary cache file for: '$cacheFile'"); + } + + try { + $writtenBytes = file_put_contents($temporaryFile, $content, LOCK_EX); + if ($writtenBytes === false || $writtenBytes !== strlen($content)) { + throw new RuntimeException("Error writing temporary cache: '$temporaryFile'"); + } + + if (!rename($temporaryFile, $cacheFile)) { + throw new RuntimeException("Error publishing cache: '$cacheFile'"); + } + + $temporaryFile = null; + } finally { + if ($temporaryFile !== null && file_exists($temporaryFile)) { + @unlink($temporaryFile); + } } } @@ -363,6 +404,14 @@ private static function fixPath(string $path): string private static function getCacheDir(): string { if (self::$cacheBaseDir == null) { + if (!self::$defaultCacheDirectoryDeprecationReported) { + trigger_error( + 'Using the system temporary directory as the SimplePhpCache cache base directory is deprecated. ' + . 'Set SimplePhpCache::$cacheBaseDir to an application-owned directory; the implicit default will be removed in 2.0.', + E_USER_DEPRECATED + ); + self::$defaultCacheDirectoryDeprecationReported = true; + } self::$cacheBaseDir = sys_get_temp_dir(); } $dir = self::fixPath(self::$cacheBaseDir) . "/.simplePhpCache"; diff --git a/tests/SimplePhpCacheTest.php b/tests/SimplePhpCacheTest.php index 4f4a043..9081e3a 100644 --- a/tests/SimplePhpCacheTest.php +++ b/tests/SimplePhpCacheTest.php @@ -52,6 +52,36 @@ private function getCacheFilePathForId(string $id): string return $cacheSubDir . '/' . urlencode(str_replace('\\', '/', $id)) . '-' . md5($id) . '.cache'; } + public function testImplicitDefaultCacheDirectoryTriggersDeprecation(): void + { + SimplePhpCache::$cacheBaseDir = null; + $reportedErrors = []; + + set_error_handler( + static function (int $severity, string $message) use (&$reportedErrors): bool { + $reportedErrors[] = [$severity, $message]; + return true; + } + ); + + try { + SimplePhpCache::getCacheCount(); + SimplePhpCache::getCacheCount(); + } finally { + restore_error_handler(); + SimplePhpCache::$cacheBaseDir = $this->testCacheDir; + } + + $this->assertSame( + [[ + E_USER_DEPRECATED, + 'Using the system temporary directory as the SimplePhpCache cache base directory is deprecated. ' + . 'Set SimplePhpCache::$cacheBaseDir to an application-owned directory; the implicit default will be removed in 2.0.', + ]], + $reportedErrors + ); + } + // --- Variable caching --- public function testVarCachingMissOnFirstCall(): void @@ -206,6 +236,29 @@ public function testVarCachingForceRefresh(): void $this->assertEquals('updated', $result); } + public function testCacheRefreshAtomicallyReplacesExistingFile(): void + { + $id = 'atomic_refresh'; + + SimplePhpCache::initVarCaching($id); + SimplePhpCache::setVarCaching($id, 'original'); + SimplePhpCache::finishVarCaching($id); + $this->resetStaticState(); + + SimplePhpCache::initVarCaching($id, true); + SimplePhpCache::setVarCaching($id, 'replacement'); + SimplePhpCache::finishVarCaching($id); + $this->resetStaticState(); + + $this->assertFalse(SimplePhpCache::initVarCaching($id)); + $this->assertSame('replacement', SimplePhpCache::finishVarCaching($id)); + $this->assertSame( + [], + glob($this->testCacheDir . '/.simplePhpCache/.simplephpcache-*') ?: [], + 'Successful cache writes must not leave temporary files behind.' + ); + } + public function testInitVarThrowsWhenAlreadyStarted(): void { $this->expectException(RuntimeException::class);