Lines 90.47% 76 / 84
Methods 62.50% 10 / 16
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 pathFor 100.00% 5 / 5 100.00% 1 / 1 1
 ensureDirectory 100.00% 3 / 3 100.00% 1 / 1 2
 get 82.35% 14 / 17 0.00% 0 / 1 6.20
 set 100.00% 8 / 8 100.00% 1 / 1 3
 delete 100.00% 5 / 5 100.00% 1 / 1 2
 clear 66.66% 2 / 3 0.00% 0 / 1 2.15
 has 92.30% 12 / 13 0.00% 0 / 1 5.01
 [Lucent\Cache\Cache] setDefaultTtl 100.00% 1 / 1 100.00% 1 / 1 1
 [Lucent\Cache\Cache] getDefaultTtl 100.00% 1 / 1 100.00% 1 / 1 1
 [Lucent\Cache\Cache] resolveTtl 100.00% 1 / 1 100.00% 1 / 1 1
 [Lucent\Cache\Cache] validateKey 100.00% 2 / 2 100.00% 1 / 1 3
 [Lucent\Cache\Cache] expiryFromTtl 85.71% 6 / 7 0.00% 0 / 1 4.05
 [Lucent\Cache\Cache] getMultiple 100.00% 5 / 5 100.00% 1 / 1 2
 [Lucent\Cache\Cache] setMultiple 83.33% 5 / 6 0.00% 0 / 1 3.04
 [Lucent\Cache\Cache] deleteMultiple 83.33% 5 / 6 0.00% 0 / 1 3.04
30class FileDriver extends Cache
31{
32    /**
33     * Cache directory path (absolute).
34     */
35    private string $directory;
36
37    /**
38     * @param string $directory Cache directory, relative to the project root or absolute
39     */
40    public function __construct(string $directory = 'storage/cache')
41    {
42        $this->directory = FileSystem::normalizePath(FileSystem::absolutePath($directory));
43    }
44
45    /**
46     * Resolve the absolute path for a cache key.
47     *
48     * The key is hashed with SHA-256 so the filename is a fixed 64 hex
49     * characters regardless of key length, keeping it well under the
50     * filesystem's 255-byte filename component limit. The first four hex
51     * characters form two levels of 2-character subdirectories, so files
52     * are spread across up to 65,536 directories.
53     *
54     * @param string $key The cache key
55     * @return string Absolute file path
56     */
57    private function pathFor(string $key): string
58    {
59        $hash = hash('sha256', $key);
60
61        return $this->directory
62            . DIRECTORY_SEPARATOR . substr($hash, 0, 2)
63            . DIRECTORY_SEPARATOR . substr($hash, 2, 2)
64            . DIRECTORY_SEPARATOR . $hash . '.cache';
65    }
66
67    /**
68     * Ensure the cache directory and shard subdirectories exist.
69     *
70     * @param string $path The full path to the cache file
71     * @return void
72     */
73    private function ensureDirectory(string $path): void
74    {
75        $directory = dirname($path);
76
77        if (!is_dir($directory)) {
78            mkdir($directory, 0755, true);
79        }
80    }
81
82    /**
83     * @inheritDoc
84     */
85    public function get(string $key, mixed $default = null): mixed
86    {
87        $this->validateKey($key);
88
89        $file = new File($this->pathFor($key), null, true);
90
91        if (!$file->exists()) {
92            return $default;
93        }
94
95        $contents = $file->getContents();
96        $separator = strpos($contents, '|');
97
98        if ($separator === false) {
99            return $default;
100        }
101
102        $expires = (int) substr($contents, 0, $separator);
103
104        if ($expires !== 0 && $expires <= time()) {
105            $file->delete();
106            return $default;
107        }
108
109        // Restrict object instantiation to prevent PHP object injection
110        // (POP-chain RCE) from a tampered or attacker-influenced cache file.
111        // `allowed_classes => false` returns __PHP_Incomplete_Class for any
112        // object in the payload instead of instantiating it, which is safe
113        // for the scalar/array values this cache stores.
114        $value = unserialize(substr($contents, $separator + 1), ['allowed_classes' => false]);
115
116        // A value that unserialized to an incomplete class is untrusted â€”
117        // treat it as a cache miss rather than returning a broken object.
118        if ($value instanceof \__PHP_Incomplete_Class) {
119            $file->delete();
120            return $default;
121        }
122
123        return $value;
124    }
125
126    /**
127     * @inheritDoc
128     */
129    public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool
130    {
131        $this->validateKey($key);
132
133        $expires = $this->expiryFromTtl($this->resolveTtl($ttl));
134
135        if ($expires !== null && $expires <= time()) {
136            return $this->delete($key);
137        }
138
139        $this->ensureDirectory($path = $this->pathFor($key));
140
141        $file = new File($path, null, true);
142
143        $payload = ($expires ?? 0) . '|' . serialize($value);
144
145        return $file->write($payload);
146    }
147
148    /**
149     * @inheritDoc
150     */
151    public function delete(string $key): bool
152    {
153        $this->validateKey($key);
154
155        $file = new File($this->pathFor($key), null, true);
156
157        if (!$file->exists()) {
158            return true;
159        }
160
161        return $file->delete();
162    }
163
164    /**
165     * @inheritDoc
166     */
167    public function clear(): bool
168    {
169        if (!is_dir($this->directory)) {
170            return true;
171        }
172
173        // Delete the entire cache tree, including the root directory and any
174        // shard subdirectories. The directory is recreated lazily on the next
175        // write via {@see ensureDirectory()}.
176        return (new Folder($this->directory, true))->delete();
177    }
178
179    /**
180     * @inheritDoc
181     */
182    public function has(string $key): bool
183    {
184        $this->validateKey($key);
185
186        $file = new File($this->pathFor($key), null, true);
187
188        if (!$file->exists()) {
189            return false;
190        }
191
192        $contents = $file->getContents();
193        $separator = strpos($contents, '|');
194
195        if ($separator === false) {
196            return false;
197        }
198
199        $expires = (int) substr($contents, 0, $separator);
200
201        if ($expires !== 0 && $expires <= time()) {
202            $file->delete();
203            return false;
204        }
205
206        return true;
207    }
208}

Inherited from Lucent\Cache\Cache

45    public function setDefaultTtl(?int $ttl): void
46    {
47        $this->defaultTtl = $ttl;
48    }
55    public function getDefaultTtl(): ?int
56    {
57        return $this->defaultTtl;
58    }
69    protected function resolveTtl(null|int|DateInterval $ttl): null|int|DateInterval
70    {
71        return $ttl ?? $this->defaultTtl;
72    }
89    protected function validateKey(mixed $key): void
90    {
91        if (!is_string($key) || preg_match('/^[A-Za-z0-9_.]{1,512}$/', $key) !== 1) {
92            throw new InvalidArgumentException($key);
93        }
94    }
102    protected function expiryFromTtl(null|int|DateInterval $ttl): ?int
103    {
104        if ($ttl === null) {
105            return null;
106        }
107
108        if ($ttl instanceof DateInterval) {
109            $ttl = (new \DateTimeImmutable())->add($ttl)->getTimestamp() - time();
110        }
111
112        if ($ttl <= 0) {
113            // Already expired: return a timestamp in the past so the item is
114            // treated as stale and removed rather than stored.
115            return time() - 1;
116        }
117
118        return time() + $ttl;
119    }
124    public function getMultiple(iterable $keys, mixed $default = null): iterable
125    {
126        $result = [];
127
128        foreach ($keys as $key) {
129            $this->validateKey($key);
130            $result[$key] = $this->get($key, $default);
131        }
132
133        return $result;
134    }
139    public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool
140    {
141        $success = true;
142
143        foreach ($values as $key => $value) {
144            $this->validateKey($key);
145            if (!$this->set($key, $value, $ttl)) {
146                $success = false;
147            }
148        }
149
150        return $success;
151    }
156    public function deleteMultiple(iterable $keys): bool
157    {
158        $success = true;
159
160        foreach ($keys as $key) {
161            $this->validateKey($key);
162            if (!$this->delete($key)) {
163                $success = false;
164            }
165        }
166
167        return $success;
168    }