FilesystemCache.php

PHP

Path: src/Cache/FilesystemCache.php

<?php

namespace mini\Cache;

use Psr\SimpleCache\CacheInterface;

/**
 * Filesystem-backed PSR-16 SimpleCache implementation
 *
 * Stores cache data in serialized files with hashed filenames.
 * Uses sys_get_temp_dir() for storage location.
 */
class FilesystemCache implements CacheInterface
{
    private string $cacheDir;
    private string $prefix;

    public function __construct(?string $cacheDir = null, string $prefix = 'mini_cache_')
    {
        $this->cacheDir = $cacheDir ?? sys_get_temp_dir() . '/mini-cache';
        $this->prefix = $prefix;
        $this->ensureCacheDirectory();
    }

    /**
     * Ensure cache directory exists
     */
    private function ensureCacheDirectory(): void
    {
        if (!is_dir($this->cacheDir)) {
            @mkdir($this->cacheDir, 0755, true);
        }
    }

    /**
     * Get cache file path for key
     */
    private function getCacheFilePath(string $key): string
    {
        $hash = hash('sha256', $this->prefix . $key);
        return $this->cacheDir . '/' . $hash;
    }

    /**
     * Validate cache key
     */
    private function validateKey(string $key): void
    {
        if (empty($key)) {
            throw new \InvalidArgumentException('Cache key cannot be empty');
        }

        // PSR-16 specifies these characters are not allowed: {}()/\@
        if (preg_match('/[{}()\/@\\\]/', $key)) {
            throw new \InvalidArgumentException('Cache key contains invalid characters: ' . $key);
        }
    }

    /**
     * Calculate expiration timestamp from TTL
     */
    private function calculateExpiration(null|int|\DateInterval $ttl): ?int
    {
        if ($ttl === null) {
            return null; // No expiration
        }

        if ($ttl instanceof \DateInterval) {
            $now = new \DateTime();
            $expires = $now->add($ttl);
            return $expires->getTimestamp();
        }

        if ($ttl <= 0) {
            return time() - 1; // Already expired
        }

        return time() + $ttl;
    }

    /**
     * Read cache entry from file
     */
    private function readCacheFile(string $path): ?array
    {
        if (!file_exists($path)) {
            return null;
        }

        $contents = @file_get_contents($path);
        if ($contents === false) {
            return null;
        }

        $data = @unserialize($contents);
        if (!is_array($data) || !array_key_exists('value', $data) || !array_key_exists('expires_at', $data)) {
            return null;
        }

        return $data;
    }

    /**
     * Write cache entry to file
     */
    private function writeCacheFile(string $path, mixed $value, ?int $expiresAt): bool
    {
        $data = [
            'value' => $value,
            'expires_at' => $expiresAt,
        ];

        $serialized = serialize($data);
        $result = @file_put_contents($path, $serialized, LOCK_EX);
        return $result !== false;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        $this->validateKey($key);
        $path = $this->getCacheFilePath($key);
        $data = $this->readCacheFile($path);

        if ($data === null) {
            return $default;
        }

        // Check if expired
        if ($data['expires_at'] !== null && $data['expires_at'] < time()) {
            @unlink($path);
            return $default;
        }

        return $data['value'];
    }

    public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
    {
        $this->validateKey($key);
        $path = $this->getCacheFilePath($key);
        $expiresAt = $this->calculateExpiration($ttl);

        return $this->writeCacheFile($path, $value, $expiresAt);
    }

    public function delete(string $key): bool
    {
        $this->validateKey($key);
        $path = $this->getCacheFilePath($key);

        if (!file_exists($path)) {
            return true; // Already deleted
        }

        return @unlink($path);
    }

    public function clear(): bool
    {
        if (!is_dir($this->cacheDir)) {
            return true;
        }

        $files = glob($this->cacheDir . '/*');
        if ($files === false) {
            return false;
        }

        $success = true;
        foreach ($files as $file) {
            if (is_file($file) && !@unlink($file)) {
                $success = false;
            }
        }

        return $success;
    }

    public function getMultiple(iterable $keys, mixed $default = null): iterable
    {
        $result = [];
        foreach ($keys as $key) {
            $result[$key] = $this->get($key, $default);
        }
        return $result;
    }

    public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool
    {
        $success = true;
        foreach ($values as $key => $value) {
            if (!$this->set($key, $value, $ttl)) {
                $success = false;
            }
        }
        return $success;
    }

    public function deleteMultiple(iterable $keys): bool
    {
        $success = true;
        foreach ($keys as $key) {
            if (!$this->delete($key)) {
                $success = false;
            }
        }
        return $success;
    }

    public function has(string $key): bool
    {
        $this->validateKey($key);
        $path = $this->getCacheFilePath($key);
        $data = $this->readCacheFile($path);

        if ($data === null) {
            return false;
        }

        // Check if expired
        if ($data['expires_at'] !== null && $data['expires_at'] < time()) {
            @unlink($path);
            return false;
        }

        return true;
    }

    /**
     * Manually trigger garbage collection (remove expired entries)
     */
    public function cleanup(): int
    {
        if (!is_dir($this->cacheDir)) {
            return 0;
        }

        $files = glob($this->cacheDir . '/*');
        if ($files === false) {
            return 0;
        }

        $removed = 0;
        $now = time();

        foreach ($files as $file) {
            if (!is_file($file)) {
                continue;
            }

            $data = $this->readCacheFile($file);
            if ($data === null) {
                continue;
            }

            if ($data['expires_at'] !== null && $data['expires_at'] < $now) {
                if (@unlink($file)) {
                    $removed++;
                }
            }
        }

        return $removed;
    }
}