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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added
- Additional tests for large variable payloads with multiline text and legacy invalid-cache handling.
- Cache entries can now safely store `null` values, and cache session state is
reset between sessions.
- Security hardening guidance for the system temporary directory default.

## [0.2.0] - 2026-08-13

Expand Down
8 changes: 6 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ Deferred improvements — implement when explicitly requested or when capacity a
Convenience method to check cache existence without starting a session.

- **[LOW] Expand test suite**
Edge cases: empty string IDs, very long IDs, concurrent write simulation,
unreadable cache directory, Unicode in cache IDs.
Edge cases: empty string IDs, very long IDs, unreadable cache directory,
Unicode in cache IDs.

- **[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.

- **[LOW] Publish to Packagist**
Run `composer validate --strict`, submit to packagist.org.
Expand Down
44 changes: 30 additions & 14 deletions class/SimplePhpCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class SimplePhpCache
/** The cached content. */
private static $cacheContent = null;

/** Whether the current cache session contains a cached value. */
private static bool $hasCacheContent = false;

/** The cache base directory. */
public static $cacheBaseDir = null;

Expand All @@ -42,15 +45,19 @@ public static function initHTMLCaching($id, $refresh = false)

self::$startedCache = $id;
self::$cacheContent = null;
self::$hasCacheContent = false;

$cacheFile = self::getCacheDir() . "/" . self::getFilename($id);

// Check if the cached file is older then the configured time
if(!$refresh && file_exists($cacheFile) &&
(time() - filemtime($cacheFile)) < self::$maxCacheTime)
{
self::$cacheContent = file_get_contents($cacheFile);
return false;
if (!$refresh && file_exists($cacheFile) &&
(time() - filemtime($cacheFile)) < self::$maxCacheTime) {
$content = file_get_contents($cacheFile);
if ($content !== false) {
self::$cacheContent = $content;
self::$hasCacheContent = true;
return false;
}
}

ob_start();
Expand All @@ -73,7 +80,7 @@ public static function finishHTMLCaching($id)
throw new RuntimeException("Cache isn't started");
}

if (self::$cacheContent != null)
if (self::$hasCacheContent)
{
$content = self::$cacheContent;
}
Expand All @@ -86,6 +93,8 @@ public static function finishHTMLCaching($id)
}

self::$startedCache = null;
self::$cacheContent = null;
self::$hasCacheContent = false;

return $content;
}
Expand All @@ -111,6 +120,8 @@ public static function initVarCaching($id, $refresh = false)
}

self::$startedCache = $id;
self::$cacheContent = null;
self::$hasCacheContent = false;

$cacheFile = self::getCacheDir() . "/" . self::getFilename($id);

Expand All @@ -120,9 +131,10 @@ public static function initVarCaching($id, $refresh = false)
{
$raw = file_get_contents($cacheFile);
if ($raw !== false) {
$decoded = self::decodeVarCachePayload($raw);
if ($decoded !== null) {
[$isValid, $decoded] = self::decodeVarCachePayload($raw);
if ($isValid) {
self::$cacheContent = $decoded;
self::$hasCacheContent = true;

return false;
}
Expand Down Expand Up @@ -161,6 +173,7 @@ public static function setVarCaching($id, $data)
}

self::$cacheContent = $data;
self::$hasCacheContent = true;
self::writeVarCachePayload($cacheFile, $data);
}

Expand All @@ -180,9 +193,12 @@ public static function finishVarCaching($id)
throw new RuntimeException("Cache isn't started");
}

$content = self::$cacheContent;
self::$startedCache = null;
self::$cacheContent = null;
self::$hasCacheContent = false;

return self::$cacheContent;
return $content;
}


Expand Down Expand Up @@ -249,20 +265,20 @@ private static function getFilename($id)
* Decode cache payload from the JSON cache format.
*
* @param string $raw
* @return mixed|null
* @return array{0: bool, 1: mixed} Whether the payload is valid and its value.
*/
private static function decodeVarCachePayload($raw)
{
if (!str_starts_with($raw, self::VAR_CACHE_PREFIX)) {
return null;
return [false, null];
}

$json = substr($raw, strlen(self::VAR_CACHE_PREFIX));

try {
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
return [true, json_decode($json, true, 512, JSON_THROW_ON_ERROR)];
} catch (\JsonException $e) {
return null;
return [false, null];
}
}

Expand Down Expand Up @@ -342,4 +358,4 @@ private static function getCacheDir()
return $dir;
}

}
}
16 changes: 12 additions & 4 deletions docs/security-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ They are treated as invalid, deleted, and handled as cache miss.

**What this means for you:** cache arrays and scalar values only. Do not cache objects.

## Cache Directory Permissions (Low)
## Cache Directory Permissions (Medium)

The `.simplePhpCache` subdirectory is created with mode `0770`. Recommendations:
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.

- Set `$cacheBaseDir` to a directory outside the web root.
- Ensure only the web server user has write access to that directory.
Recommendations:

- Always set `$cacheBaseDir` in production to an application-specific directory
outside the web root; do not rely on the system temporary directory.
- Ensure the base directory and `.simplePhpCache` are owned and writable only by
the web server user, and do not allow untrusted users to create entries there.
- Never expose the cache directory via a public URL.

## Cache Key / ID Guidance (Low)
Expand Down
38 changes: 38 additions & 0 deletions tests/SimplePhpCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ private function resetStaticState(): void
$r = new ReflectionClass(SimplePhpCache::class);
$r->getProperty('startedCache')->setValue(null, null);
$r->getProperty('cacheContent')->setValue(null, null);
$r->getProperty('hasCacheContent')->setValue(null, false);
}

private function getCacheFilePathForId(string $id): string
Expand Down Expand Up @@ -92,6 +93,31 @@ public function testVarCachingHitOnSecondCall(): void
$this->assertEquals($data, $result);
}

public function testVarCachingNullRoundTrip(): void
{
$id = 'var_null';

SimplePhpCache::initVarCaching($id);
SimplePhpCache::setVarCaching($id, null);
SimplePhpCache::finishVarCaching($id);
$this->resetStaticState();

$miss = SimplePhpCache::initVarCaching($id);
$this->assertFalse($miss);
$this->assertNull(SimplePhpCache::finishVarCaching($id));
}

public function testVarCachingMissDoesNotReturnContentFromPreviousSession(): void
{
SimplePhpCache::initVarCaching('first');
SimplePhpCache::setVarCaching('first', 'previous value');
SimplePhpCache::finishVarCaching('first');

$miss = SimplePhpCache::initVarCaching('second');
$this->assertTrue($miss);
$this->assertNull(SimplePhpCache::finishVarCaching('second'));
}

public function testVarCachingLargePayloadAndMultilineTextRoundTrip(): void
{
$id = 'var_large_multiline';
Expand Down Expand Up @@ -220,6 +246,18 @@ public function testHtmlCachingReturnsCachedContent(): void
$this->assertEquals('stored content', $output);
}

public function testHtmlCachingReturnsEmptyCachedContent(): void
{
$id = 'html_empty';

SimplePhpCache::initHTMLCaching($id);
SimplePhpCache::finishHTMLCaching($id);
$this->resetStaticState();

$this->assertFalse(SimplePhpCache::initHTMLCaching($id));
$this->assertSame('', SimplePhpCache::finishHTMLCaching($id));
}

// --- clear / count ---

public function testClearCacheById(): void
Expand Down
Loading