Issues (1)

src/FileCache.php (1 issue)

Labels
Severity
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Cache\File;
6
7
use DateInterval;
8
use DateTime;
9
use Psr\SimpleCache\CacheInterface;
10
use Traversable;
11
12
use function array_keys;
13
use function array_map;
14
use function closedir;
15
use function dirname;
16
use function error_get_last;
17
use function filemtime;
18
use function fileowner;
19
use function fopen;
20
use function function_exists;
21
use function is_dir;
22
use function is_file;
23
use function iterator_to_array;
24
use function opendir;
25
use function posix_geteuid;
26
use function random_int;
27
use function readdir;
28
use function rmdir;
29
use function serialize;
30
use function strpbrk;
31
use function substr;
32
use function unlink;
33
use function unserialize;
34
35
use const LOCK_EX;
36
use const LOCK_SH;
37
use const LOCK_UN;
38
39
/**
40
 * `FileCache` implements a cache handler using files.
41
 *
42
 * For each data value being cached, `FileCache` will store it in a separate file. The cache files are placed
43
 * under {@see FileCache::$cachePath}. `FileCache` will perform garbage collection automatically to remove expired
44
 * cache files.
45
 *
46
 * Please refer to {@see CacheInterface} for common cache operations that are supported by `FileCache`.
47
 */
48
final class FileCache implements CacheInterface
49
{
50
    private const TTL_INFINITY = 31_536_000; // 1 year
0 ignored issues
show
A parse error occurred: Syntax error, unexpected T_STRING, expecting ',' or ';' on line 50 at column 35
Loading history...
51
    private const EXPIRATION_EXPIRED = -1;
52
53
    /**
54
     * @var string The cache file suffix. Defaults to '.bin'.
55
     */
56
    private string $fileSuffix = '.bin';
57
58
    /**
59
     * @var int|null The permission to be set for newly created cache files.
60
     * This value will be used by PHP chmod() function. No umask will be applied.
61
     * If not set, the permission will be determined by the current environment.
62
     */
63
    private ?int $fileMode = null;
64
65
    /**
66
     * @var int The level of sub-directories to store cache files. Defaults to 1.
67
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
68
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
69
     * is not over burdened with a single directory having too many files.
70
     */
71
    private int $directoryLevel = 1;
72
73
    /**
74
     * @var int The probability (parts per million) that garbage collection (GC) should be performed
75
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
76
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
77
     */
78
    private int $gcProbability = 10;
79
80
    /**
81
     * @param string $cachePath The directory to store cache files.
82
     * @param int $directoryMode The permission to be set for newly created directories. This value will be used
83
     * by PHP `chmod()` function. No umask will be applied. Defaults to 0775, meaning the directory is read-writable
84
     * by owner and group, but read-only for other users.
85
     *
86
     * @see FileCache::$cachePath
87
     *
88
     * @throws CacheException If failed to create cache directory.
89
     */
90 124
    public function __construct(
91
        private string $cachePath,
92
        private int $directoryMode = 0775,
93
    ) {
94 124
        if (!$this->createDirectoryIfNotExists($cachePath)) {
95 1
            throw new CacheException("Failed to create cache directory \"$cachePath\".");
96
        }
97
    }
98
99 82
    public function get(string $key, mixed $default = null): mixed
100
    {
101 82
        $this->validateKey($key);
102 80
        $file = $this->getCacheFile($key);
103
104 80
        if (!$this->existsAndNotExpired($file) || ($filePointer = @fopen($file, 'rb')) === false) {
105 29
            return $default;
106
        }
107
108 66
        flock($filePointer, LOCK_SH);
109 66
        $value = stream_get_contents($filePointer);
110 66
        flock($filePointer, LOCK_UN);
111 66
        fclose($filePointer);
112
113 66
        return unserialize($value);
114
    }
115
116 99
    public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool
117
    {
118 99
        $this->validateKey($key);
119 97
        $this->gc();
120 97
        $expiration = $this->ttlToExpiration($ttl);
121
122 97
        if ($expiration <= self::EXPIRATION_EXPIRED) {
123 1
            return $this->delete($key);
124
        }
125
126 97
        $file = $this->getCacheFile($key);
127 97
        $cacheDirectory = dirname($file);
128
129 97
        if (!is_dir($this->cachePath)
130 97
            || $this->directoryLevel > 0 && !$this->createDirectoryIfNotExists($cacheDirectory)
131
        ) {
132 1
            throw new CacheException("Failed to create cache directory \"$cacheDirectory\".");
133
        }
134
135
        // If ownership differs, the touch call will fail, so we try to
136
        // rebuild the file from scratch by deleting it first
137
        // https://github.com/yiisoft/yii2/pull/16120
138 96
        if (function_exists('posix_geteuid') && is_file($file) && fileowner($file) !== posix_geteuid()) {
139
            @unlink($file);
140
        }
141
142 96
        if (file_put_contents($file, serialize($value), LOCK_EX) === false) {
143
            return false;
144
        }
145
146 96
        if ($this->fileMode !== null) {
147 1
            $result = @chmod($file, $this->fileMode);
148 1
            if (!$this->isLastErrorSafe($result)) {
149
                return false;
150
            }
151
        }
152
153 96
        $result = false;
154
155 96
        if (@touch($file, $expiration)) {
156 96
            clearstatcache();
157 96
            $result = true;
158
        }
159
160 96
        return $this->isLastErrorSafe($result);
161
    }
162
163 17
    public function delete(string $key): bool
164
    {
165 17
        $this->validateKey($key);
166 15
        $file = $this->getCacheFile($key);
167
168 15
        if (!is_file($file)) {
169 1
            return true;
170
        }
171
172 14
        $result = @unlink($file);
173
174 14
        return $this->isLastErrorSafe($result);
175
    }
176
177 12
    public function clear(): bool
178
    {
179 12
        $this->removeCacheFiles($this->cachePath, false);
180 12
        return true;
181
    }
182
183 9
    public function getMultiple(iterable $keys, mixed $default = null): iterable
184
    {
185 9
        $keys = $this->iterableToArray($keys);
186 9
        $this->validateKeys($keys);
187 7
        $results = [];
188
189 7
        foreach ($keys as $key) {
190 7
            $results[$key] = $this->get($key, $default);
191
        }
192
193 7
        return $results;
194
    }
195
196 10
    public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool
197
    {
198 10
        $values = $this->iterableToArray($values);
199 10
        $this->validateKeys(array_map('\strval', array_keys($values)));
200
201 10
        foreach ($values as $key => $value) {
202 10
            $this->set((string) $key, $value, $ttl);
203
        }
204
205 10
        return true;
206
    }
207
208 3
    public function deleteMultiple(iterable $keys): bool
209
    {
210 3
        $keys = $this->iterableToArray($keys);
211 3
        $this->validateKeys($keys);
212
213 1
        foreach ($keys as $key) {
214 1
            $this->delete($key);
215
        }
216
217 1
        return true;
218
    }
219
220 15
    public function has(string $key): bool
221
    {
222 15
        $this->validateKey($key);
223 13
        return $this->existsAndNotExpired($this->getCacheFile($key));
224
    }
225
226
    /**
227
     * @param string $fileSuffix The cache file suffix. Defaults to '.bin'.
228
     */
229 1
    public function withFileSuffix(string $fileSuffix): self
230
    {
231 1
        $new = clone $this;
232 1
        $new->fileSuffix = $fileSuffix;
233 1
        return $new;
234
    }
235
236
    /**
237
     * @param int $fileMode The permission to be set for newly created cache files. This value will be used
238
     * by PHP `chmod()` function. No umask will be applied. If not set, the permission will be determined
239
     * by the current environment.
240
     */
241 1
    public function withFileMode(int $fileMode): self
242
    {
243 1
        $new = clone $this;
244 1
        $new->fileMode = $fileMode;
245 1
        return $new;
246
    }
247
248
    /**
249
     * @param int $directoryMode The permission to be set for newly created directories. This value will be used
250
     * by PHP `chmod()` function. No umask will be applied. Defaults to 0775, meaning the directory is read-writable
251
     * by owner and group, but read-only for other users.
252
     *
253
     * @deprecated Use `$directoryMode` in the constructor instead
254
     */
255 1
    public function withDirectoryMode(int $directoryMode): self
256
    {
257 1
        $new = clone $this;
258 1
        $new->directoryMode = $directoryMode;
259 1
        return $new;
260
    }
261
262
    /**
263
     * @param int $directoryLevel The level of sub-directories to store cache files. Defaults to 1.
264
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
265
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
266
     * is not over burdened with a single directory having too many files.
267
     */
268 6
    public function withDirectoryLevel(int $directoryLevel): self
269
    {
270 6
        $new = clone $this;
271 6
        $new->directoryLevel = $directoryLevel;
272 6
        return $new;
273
    }
274
275
    /**
276
     * @param int $gcProbability The probability (parts per million) that garbage collection (GC) should
277
     * be performed when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
278
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
279
     */
280 1
    public function withGcProbability(int $gcProbability): self
281
    {
282 1
        $new = clone $this;
283 1
        $new->gcProbability = $gcProbability;
284 1
        return $new;
285
    }
286
287
    /**
288
     * Converts TTL to expiration.
289
     */
290 102
    private function ttlToExpiration(null|int|string|DateInterval $ttl = null): int
291
    {
292 102
        $ttl = $this->normalizeTtl($ttl);
293
294 102
        if ($ttl === null) {
295 94
            return self::TTL_INFINITY + time();
296
        }
297
298 11
        if ($ttl <= 0) {
299 4
            return self::EXPIRATION_EXPIRED;
300
        }
301
302 7
        return $ttl + time();
303
    }
304
305
    /**
306
     * Normalizes cache TTL handling strings and {@see DateInterval} objects.
307
     *
308
     * @param DateInterval|int|string|null $ttl The raw TTL.
309
     *
310
     * @return int|null TTL value as UNIX timestamp or null meaning infinity
311
     */
312 108
    private function normalizeTtl(null|int|string|DateInterval $ttl = null): ?int
313
    {
314 108
        if ($ttl === null) {
315 95
            return null;
316
        }
317
318 16
        if ($ttl instanceof DateInterval) {
319 3
            return (new DateTime('@0'))
320 3
                ->add($ttl)
321 3
                ->getTimestamp();
322
        }
323
324 13
        return (int) $ttl;
325
    }
326
327
    /**
328
     * Ensures that the directory is created.
329
     *
330
     * @param string $path The path to the directory.
331
     *
332
     * @return bool Whether the directory was created.
333
     */
334 124
    private function createDirectoryIfNotExists(string $path): bool
335
    {
336 124
        if (is_dir($path)) {
337 30
            return true;
338
        }
339
340 124
        $result = !is_file($path) && mkdir(directory: $path, recursive: true) && is_dir($path);
341
342 124
        if ($result) {
343 124
            chmod($path, $this->directoryMode);
344
        }
345
346 124
        return $result;
347
    }
348
349
    /**
350
     * Returns the cache file path given the cache key.
351
     *
352
     * @param string $key The cache key.
353
     *
354
     * @return string The cache file path.
355
     */
356 99
    private function getCacheFile(string $key): string
357
    {
358 99
        if ($this->directoryLevel < 1) {
359 1
            return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->fileSuffix;
360
        }
361
362 98
        $base = $this->cachePath;
363
364 98
        for ($i = 0; $i < $this->directoryLevel; ++$i) {
365 98
            if (($prefix = substr($key, $i + $i, 2)) !== '') {
366 98
                $base .= DIRECTORY_SEPARATOR . $prefix;
367
            }
368
        }
369
370 98
        return $base . DIRECTORY_SEPARATOR . $key . $this->fileSuffix;
371
    }
372
373
    /**
374
     * Recursively removing expired cache files under a directory. This method is mainly used by {@see gc()}.
375
     *
376
     * @param string $path The directory under which expired cache files are removed.
377
     * @param bool $expiredOnly Whether to only remove expired cache files.
378
     * If false, all files under `$path` will be removed.
379
     */
380 13
    private function removeCacheFiles(string $path, bool $expiredOnly): void
381
    {
382 13
        if (($handle = @opendir($path)) === false) {
383
            return;
384
        }
385
386 13
        while (($file = readdir($handle)) !== false) {
387 13
            if (str_starts_with($file, '.')) {
388 13
                continue;
389
            }
390
391 13
            $fullPath = $path . DIRECTORY_SEPARATOR . $file;
392
393 13
            if (is_dir($fullPath)) {
394 13
                $this->removeCacheFiles($fullPath, $expiredOnly);
395
396 13
                if (!$expiredOnly && !@rmdir($fullPath)) {
397
                    $errorMessage = error_get_last()['message'] ?? '';
398 13
                    throw new CacheException("Unable to remove directory '{$fullPath}': {$errorMessage}");
399
                }
400 13
            } elseif ((!$expiredOnly || @filemtime($fullPath) < time()) && !@unlink($fullPath)) {
401
                $errorMessage = error_get_last()['message'] ?? '';
402
                throw new CacheException("Unable to remove file '{$fullPath}': {$errorMessage}");
403
            }
404
        }
405
406 13
        closedir($handle);
407
    }
408
409
    /**
410
     * Removes expired cache files.
411
     */
412 97
    private function gc(): void
413
    {
414 97
        if (random_int(0, 1_000_000) < $this->gcProbability) {
415 1
            $this->removeCacheFiles($this->cachePath, true);
416
        }
417
    }
418
419 111
    private function validateKey(string $key): void
420
    {
421 111
        if ($key === '' || strpbrk($key, '{}()/\@:')) {
422 12
            throw new InvalidArgumentException('Invalid key value.');
423
        }
424
    }
425
426
    /**
427
     * @param string[] $keys
428
     */
429 14
    private function validateKeys(array $keys): void
430
    {
431 14
        foreach ($keys as $key) {
432 14
            $this->validateKey($key);
433
        }
434
    }
435
436 81
    private function existsAndNotExpired(string $file): bool
437
    {
438 81
        return is_file($file) && @filemtime($file) > time();
439
    }
440
441
    /**
442
     * Converts iterable to array.
443
     *
444
     * @psalm-template TKey
445
     * @psalm-template TValue
446
     * @psalm-param iterable<TKey, TValue> $iterable
447
     * @psalm-return array<TKey, TValue>
448
     */
449 14
    private function iterableToArray(iterable $iterable): array
450
    {
451 14
        return $iterable instanceof Traversable ? iterator_to_array($iterable) : $iterable;
452
    }
453
454
    /**
455
     * Check if error was because of file was already deleted by another process on high load.
456
     */
457 96
    private function isLastErrorSafe(bool $result): bool
458
    {
459 96
        if ($result !== false) {
460 96
            return true;
461
        }
462
463
        $lastError = error_get_last();
464
465
        if ($lastError === null) {
466
            return true;
467
        }
468
469
        if (str_ends_with($lastError['message'] ?? '', 'No such file or directory')) {
470
            error_clear_last();
471
            return true;
472
        }
473
474
        return false;
475
    }
476
}
477