Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)):

Expand Down
17 changes: 13 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
2 changes: 0 additions & 2 deletions demo/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
9 changes: 5 additions & 4 deletions docs/security-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
57 changes: 53 additions & 4 deletions src/SimplePhpCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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";
Expand Down
53 changes: 53 additions & 0 deletions tests/SimplePhpCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading