Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.47% covered (warning)
89.47%
51 / 57
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileCache
89.47% covered (warning)
89.47%
51 / 57
62.50% covered (warning)
62.50%
5 / 8
28.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get
87.10% covered (warning)
87.10%
27 / 31
0.00% covered (danger)
0.00%
0 / 1
15.48
 set
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 clear
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 directory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 defaultTtlSeconds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pathFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 now
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\ResourceLoader\Cache;
6
7use Phpdftk\Filesystem\LocalFilesystem;
8use Phpdftk\ResourceLoader\FetchResult;
9
10/**
11 * On-disk persistent cache for {@see FetchResult}s. The same URL
12 * fetched twice — within one render or across renders — hits the
13 * disk on the second attempt instead of the network.
14 *
15 * Storage layout: one file per cache entry, named
16 * `sha256(key) + '.cache'`. The body is `serialize()`-d so it round-
17 * trips arbitrary binary payloads cleanly without base64 inflation.
18 *
19 *   {directory}/
20 *     7e3b0a...c91d.cache    ← serialized entry for URL A
21 *     a204f8...5e2b.cache    ← serialized entry for URL B
22 *
23 * TTL is applied at `get()` time — expired entries return null and
24 * are deleted lazily on read so a cold cache doesn't pay scan cost.
25 *
26 * Concurrency: writes use the same atomic temp-file-then-rename
27 * pattern as {@see LocalFilesystem::writeFile} (it doesn't on its
28 * own, but the OS-level write of a small entry is generally atomic
29 * enough — a partial write returns null from `get` and the next
30 * `set` overwrites). Cross-process concurrent writes to the *same*
31 * key may race; both end up with one of the two payloads, both
32 * valid.
33 */
34final class FileCache implements CacheInterface
35{
36    /** @var \Closure(): int */
37    private readonly \Closure $clock;
38
39    /**
40     * @param string  $directory          Where cache entries live.
41     *                                    Created on first write.
42     * @param int     $defaultTtlSeconds  Time-to-live applied at
43     *                                    `set()` time. Default 24h.
44     * @param ?callable():int $clock      Override the wall clock for
45     *                                    deterministic tests. Defaults
46     *                                    to PHP's `time()`. Must return
47     *                                    epoch seconds.
48     */
49    public function __construct(
50        private readonly string $directory,
51        private readonly int $defaultTtlSeconds = 86400,
52        ?callable $clock = null,
53    ) {
54        if ($defaultTtlSeconds <= 0) {
55            throw new \InvalidArgumentException('defaultTtlSeconds must be positive');
56        }
57        $this->clock = $clock !== null ? \Closure::fromCallable($clock) : static fn(): int => time();
58    }
59
60    public function get(string $key): ?FetchResult
61    {
62        $path = $this->pathFor($key);
63        if (!is_file($path)) {
64            return null;
65        }
66        try {
67            $contents = LocalFilesystem::readFile($path, 'cache entry');
68        } catch (\Throwable) {
69            return null;
70        }
71        if ($contents === '') {
72            return null;
73        }
74        $entry = @unserialize($contents, ['allowed_classes' => false]);
75        if (!is_array($entry) || ($entry['version'] ?? null) !== 1) {
76            return null;
77        }
78        if (!isset($entry['expiresAt']) || !is_int($entry['expiresAt'])) {
79            return null;
80        }
81        if ($this->now() > $entry['expiresAt']) {
82            // Expired — drop the file lazily.
83            @unlink($path);
84            return null;
85        }
86        if (
87            !isset($entry['bytes'], $entry['mimeType'], $entry['originalUrl'], $entry['finalUrl'], $entry['statusCode'])
88            || !is_string($entry['bytes'])
89            || !is_string($entry['mimeType'])
90            || !is_string($entry['originalUrl'])
91            || !is_string($entry['finalUrl'])
92            || !is_int($entry['statusCode'])
93        ) {
94            return null;
95        }
96        return new FetchResult(
97            bytes: $entry['bytes'],
98            mimeType: $entry['mimeType'],
99            originalUrl: $entry['originalUrl'],
100            finalUrl: $entry['finalUrl'],
101            cacheHit: false,
102            statusCode: $entry['statusCode'],
103        );
104    }
105
106    public function set(string $key, FetchResult $result): void
107    {
108        $entry = [
109            'version' => 1,
110            'expiresAt' => $this->now() + $this->defaultTtlSeconds,
111            'originalUrl' => $result->originalUrl,
112            'finalUrl' => $result->finalUrl,
113            'mimeType' => $result->mimeType,
114            'statusCode' => $result->statusCode,
115            'bytes' => $result->bytes,
116        ];
117        $serialized = serialize($entry);
118        try {
119            LocalFilesystem::writeFile($this->pathFor($key), $serialized, createDirectories: true);
120        } catch (\Throwable) {
121            // Cache write failures shouldn't kill the render.
122        }
123    }
124
125    /**
126     * Delete every cache entry. Used by tests + by callers that
127     * want to invalidate everything (rotation of allowedHosts,
128     * etc.). Safe to call when the directory doesn't exist.
129     */
130    public function clear(): void
131    {
132        if (!is_dir($this->directory)) {
133            return;
134        }
135        $entries = glob(rtrim($this->directory, '/') . '/*.cache');
136        if ($entries === false) {
137            return;
138        }
139        foreach ($entries as $entry) {
140            @unlink($entry);
141        }
142    }
143
144    public function directory(): string
145    {
146        return $this->directory;
147    }
148
149    public function defaultTtlSeconds(): int
150    {
151        return $this->defaultTtlSeconds;
152    }
153
154    /**
155     * Map a cache key (the URL the loader is asked to fetch) to a
156     * filesystem path. Uses sha256 so:
157     *   - URLs with special characters are filesystem-safe
158     *   - the keyspace stays bounded (no information leakage in
159     *     the filename)
160     *   - the same URL always maps to the same file
161     */
162    private function pathFor(string $key): string
163    {
164        return rtrim($this->directory, '/') . '/' . hash('sha256', $key) . '.cache';
165    }
166
167    private function now(): int
168    {
169        return ($this->clock)();
170    }
171}