Passed
Pull Request — master (#52)
by
unknown
02:17
created

FileCache::isLastErrorNotSafe()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 9.488

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 9
c 1
b 1
f 0
dl 0
loc 18
ccs 3
cts 10
cp 0.3
rs 9.9666
cc 4
nc 4
nop 1
crap 9.488
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Cache\File;
6
7
use DateInterval;
8
use DateTime;
9
use Exception;
10
use Psr\SimpleCache\CacheInterface;
11
use Traversable;
12
13
use function array_keys;
14
use function array_map;
15
use function closedir;
16
use function dirname;
17
use function error_get_last;
18
use function filemtime;
19
use function fileowner;
20
use function fopen;
21
use function function_exists;
22
use function is_dir;
23
use function is_file;
24
use function iterator_to_array;
25
use function opendir;
26
use function posix_geteuid;
27
use function random_int;
28
use function readdir;
29
use function rmdir;
30
use function serialize;
31
use function strpbrk;
32
use function substr;
33
use function unlink;
34
use function unserialize;
35
36
use const LOCK_EX;
37
use const LOCK_SH;
38
use const LOCK_UN;
39
40
/**
41
 * FileCache implements a cache handler using files.
42
 *
43
 * For each data value being cached, FileCache will store it in a separate file.
44
 * The cache files are placed under {@see FileCache::$cachePath}.
45
 * FileCache will perform garbage collection automatically to remove expired cache files.
46
 *
47
 * Please refer to {@see \Psr\SimpleCache\CacheInterface} for common cache operations that are supported by FileCache.
48
 */
49
final class FileCache implements CacheInterface
50
{
51
    private const TTL_INFINITY = 31_536_000; // 1 year
0 ignored issues
show
Bug introduced by
The constant Yiisoft\Cache\File\31_536_000 was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
52
    private const EXPIRATION_EXPIRED = -1;
53
54
    /**
55
     * @var string The directory to store cache files.
56
     */
57
    private string $cachePath;
58
59
    /**
60
     * @var string The cache file suffix. Defaults to '.bin'.
61
     */
62
    private string $fileSuffix = '.bin';
63
64
    /**
65
     * @var int|null The permission to be set for newly created cache files.
66
     * This value will be used by PHP chmod() function. No umask will be applied.
67
     * If not set, the permission will be determined by the current environment.
68
     */
69
    private ?int $fileMode = null;
70
71
    /**
72
     * @var int The permission to be set for newly created directories.
73
     * This value will be used by PHP chmod() function. No umask will be applied.
74
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
75
     * but read-only for other users.
76
     */
77
    private int $directoryMode = 0775;
78
79
    /**
80
     * @var int The level of sub-directories to store cache files. Defaults to 1.
81
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
82
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
83
     * is not over burdened with a single directory having too many files.
84
     */
85
    private int $directoryLevel = 1;
86
87
    /**
88
     * @var int The probability (parts per million) that garbage collection (GC) should be performed
89
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
90
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
91
     */
92
    private int $gcProbability = 10;
93
94
    /**
95
     * @param string $cachePath The directory to store cache files.
96
     *
97
     * @see FileCache::$cachePath
98
     *
99
     * @throws CacheException If failed to create cache directory.
100
     */
101 116
    public function __construct(string $cachePath)
102
    {
103 116
        if (!$this->createDirectoryIfNotExists($cachePath)) {
104 1
            throw new CacheException("Failed to create cache directory \"{$cachePath}\".");
105
        }
106
107 116
        $this->cachePath = $cachePath;
108
    }
109
110 80
    public function get(string $key, mixed $default = null): mixed
111
    {
112 80
        $this->validateKey($key);
113 78
        $file = $this->getCacheFile($key);
114
115 78
        if (!$this->existsAndNotExpired($file) || ($filePointer = @fopen($file, 'rb')) === false) {
116 28
            return $default;
117
        }
118
119 64
        flock($filePointer, LOCK_SH);
120 64
        $value = stream_get_contents($filePointer);
121 64
        flock($filePointer, LOCK_UN);
122 64
        fclose($filePointer);
123
124 64
        return unserialize($value);
125
    }
126
127 92
    public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool
128
    {
129 92
        $this->validateKey($key);
130 90
        $this->gc();
131 90
        $expiration = $this->ttlToExpiration($ttl);
132
133 90
        if ($expiration <= self::EXPIRATION_EXPIRED) {
134 1
            return $this->delete($key);
135
        }
136
137 90
        $file = $this->getCacheFile($key);
138 90
        $cacheDirectory = dirname($file);
139
140 90
        if (!is_dir($this->cachePath) || ($this->directoryLevel > 0 && !$this->createDirectoryIfNotExists($cacheDirectory))) {
141 1
            throw new CacheException("Failed to create cache directory \"{$cacheDirectory}\".");
142
        }
143
144
        // If ownership differs the touch call will fail, so we try to
145
        // rebuild the file from scratch by deleting it first
146
        // https://github.com/yiisoft/yii2/pull/16120
147 89
        if (function_exists('posix_geteuid') && is_file($file) && fileowner($file) !== posix_geteuid()) {
148
            @unlink($file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

148
            /** @scrutinizer ignore-unhandled */ @unlink($file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
149
        }
150
151 89
        if (file_put_contents($file, serialize($value), LOCK_EX) === false) {
152
            return false;
153
        }
154
155 89
        if ($this->fileMode !== null) {
156 1
            $result = @chmod($file, $this->fileMode);
157 1
            if ($this->isLastErrorNotSafe($result)) {
158
                return false;
159
            }
160
        }
161
162 89
        $result = @touch($file, $expiration);
163 89
        if ($this->isLastErrorNotSafe($result)) {
164
            return false;
165
        }
166
167 89
        return true;
168
    }
169
170 17
    public function delete(string $key): bool
171
    {
172 17
        $this->validateKey($key);
173 15
        $file = $this->getCacheFile($key);
174
175 15
        if (!is_file($file)) {
176 1
            return true;
177
        }
178
179 14
        $result = @unlink($file);
180 14
        if ($this->isLastErrorNotSafe($result)) {
181
            return false;
182
        }
183
184 14
        return true;
185
    }
186
187 12
    public function clear(): bool
188
    {
189 12
        $this->removeCacheFiles($this->cachePath, false);
190 12
        return true;
191
    }
192
193 9
    public function getMultiple(iterable $keys, mixed $default = null): iterable
194
    {
195 9
        $keys = $this->iterableToArray($keys);
196 9
        $this->validateKeys($keys);
197 7
        $results = [];
198
199 7
        foreach ($keys as $key) {
200 7
            $results[$key] = $this->get($key, $default);
201
        }
202
203 7
        return $results;
204
    }
205
206 10
    public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool
207
    {
208 10
        $values = $this->iterableToArray($values);
209 10
        $this->validateKeys(array_map('\strval', array_keys($values)));
210
211 10
        foreach ($values as $key => $value) {
212 10
            $this->set((string) $key, $value, $ttl);
213
        }
214
215 10
        return true;
216
    }
217
218 3
    public function deleteMultiple(iterable $keys): bool
219
    {
220 3
        $keys = $this->iterableToArray($keys);
221 3
        $this->validateKeys($keys);
222
223 1
        foreach ($keys as $key) {
224 1
            $this->delete($key);
225
        }
226
227 1
        return true;
228
    }
229
230 15
    public function has(string $key): bool
231
    {
232 15
        $this->validateKey($key);
233 13
        return $this->existsAndNotExpired($this->getCacheFile($key));
234
    }
235
236
    /**
237
     * @param string $fileSuffix The cache file suffix. Defaults to '.bin'.
238
     */
239 1
    public function withFileSuffix(string $fileSuffix): self
240
    {
241 1
        $new = clone $this;
242 1
        $new->fileSuffix = $fileSuffix;
243 1
        return $new;
244
    }
245
246
    /**
247
     * @param int $fileMode The permission to be set for newly created cache files.
248
     * This value will be used by PHP chmod() function. No umask will be applied.
249
     * If not set, the permission will be determined by the current environment.
250
     */
251 1
    public function withFileMode(int $fileMode): self
252
    {
253 1
        $new = clone $this;
254 1
        $new->fileMode = $fileMode;
255 1
        return $new;
256
    }
257
258
    /**
259
     * @param int $directoryMode The permission to be set for newly created directories.
260
     * This value will be used by PHP chmod() function. No umask will be applied.
261
     * Defaults to 0775, meaning the directory is read-writable by owner and group, but read-only for other users.
262
     */
263 1
    public function withDirectoryMode(int $directoryMode): self
264
    {
265 1
        $new = clone $this;
266 1
        $new->directoryMode = $directoryMode;
267 1
        return $new;
268
    }
269
270
    /**
271
     * @param int $directoryLevel The level of sub-directories to store cache files. Defaults to 1.
272
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
273
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
274
     * is not over burdened with a single directory having too many files.
275
     */
276 1
    public function withDirectoryLevel(int $directoryLevel): self
277
    {
278 1
        $new = clone $this;
279 1
        $new->directoryLevel = $directoryLevel;
280 1
        return $new;
281
    }
282
283
    /**
284
     * @param int $gcProbability The probability (parts per million) that garbage collection (GC) should
285
     * be performed when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
286
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
287
     */
288 1
    public function withGcProbability(int $gcProbability): self
289
    {
290 1
        $new = clone $this;
291 1
        $new->gcProbability = $gcProbability;
292 1
        return $new;
293
    }
294
295
    /**
296
     * Converts TTL to expiration.
297
     */
298 95
    private function ttlToExpiration(null|int|string|DateInterval $ttl = null): int
299
    {
300 95
        $ttl = $this->normalizeTtl($ttl);
301
302 95
        if ($ttl === null) {
303 88
            return self::TTL_INFINITY + time();
304
        }
305
306 10
        if ($ttl <= 0) {
307 4
            return self::EXPIRATION_EXPIRED;
308
        }
309
310 6
        return $ttl + time();
311
    }
312
313
    /**
314
     * Normalizes cache TTL handling strings and {@see DateInterval} objects.
315
     *
316
     * @param DateInterval|int|string|null $ttl The raw TTL.
317
     *
318
     * @return int|null TTL value as UNIX timestamp or null meaning infinity
319
     */
320 101
    private function normalizeTtl(null|int|string|DateInterval $ttl = null): ?int
321
    {
322 101
        if ($ttl === null) {
323 89
            return null;
324
        }
325
326 15
        if ($ttl instanceof DateInterval) {
327 3
            return (new DateTime('@0'))
328 3
                ->add($ttl)
329 3
                ->getTimestamp();
330
        }
331
332 12
        return (int) $ttl;
333
    }
334
335
    /**
336
     * Ensures that the directory is created.
337
     *
338
     * @param string $path The path to the directory.
339
     *
340
     * @return bool Whether the directory was created.
341
     */
342 116
    private function createDirectoryIfNotExists(string $path): bool
343
    {
344 116
        if (is_dir($path)) {
345 29
            return true;
346
        }
347
348 116
        $result = !is_file($path) && mkdir(directory: $path, recursive: true) && is_dir($path);
349
350 116
        if ($result) {
351 116
            chmod($path, $this->directoryMode);
352
        }
353
354 116
        return $result;
355
    }
356
357
    /**
358
     * Returns the cache file path given the cache key.
359
     *
360
     * @param string $key The cache key.
361
     *
362
     * @return string The cache file path.
363
     */
364 92
    private function getCacheFile(string $key): string
365
    {
366 92
        if ($this->directoryLevel < 1) {
367 1
            return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->fileSuffix;
368
        }
369
370 91
        $base = $this->cachePath;
371
372 91
        for ($i = 0; $i < $this->directoryLevel; ++$i) {
373 91
            if (($prefix = substr($key, $i + $i, 2)) !== '') {
374 91
                $base .= DIRECTORY_SEPARATOR . $prefix;
375
            }
376
        }
377
378 91
        return $base . DIRECTORY_SEPARATOR . $key . $this->fileSuffix;
379
    }
380
381
    /**
382
     * Recursively removing expired cache files under a directory. This method is mainly used by {@see gc()}.
383
     *
384
     * @param string $path The directory under which expired cache files are removed.
385
     * @param bool $expiredOnly Whether to only remove expired cache files.
386
     * If false, all files under `$path` will be removed.
387
     */
388 13
    private function removeCacheFiles(string $path, bool $expiredOnly): void
389
    {
390 13
        if (($handle = @opendir($path)) === false) {
391
            return;
392
        }
393
394 13
        while (($file = readdir($handle)) !== false) {
395 13
            if (str_starts_with($file, '.')) {
396 13
                continue;
397
            }
398
399 13
            $fullPath = $path . DIRECTORY_SEPARATOR . $file;
400
401 13
            if (is_dir($fullPath)) {
402 13
                $this->removeCacheFiles($fullPath, $expiredOnly);
403
404 13
                if (!$expiredOnly && !@rmdir($fullPath)) {
405
                    $errorMessage = error_get_last()['message'] ?? '';
406 13
                    throw new CacheException("Unable to remove directory '{$fullPath}': {$errorMessage}");
407
                }
408 13
            } elseif ((!$expiredOnly || @filemtime($fullPath) < time()) && !@unlink($fullPath)) {
409
                $errorMessage = error_get_last()['message'] ?? '';
410
                throw new CacheException("Unable to remove file '{$fullPath}': {$errorMessage}");
411
            }
412
        }
413
414 13
        closedir($handle);
415
    }
416
417
    /**
418
     * Removes expired cache files.
419
     *
420
     * @throws Exception
421
     */
422 90
    private function gc(): void
423
    {
424 90
        if (random_int(0, 1_000_000) < $this->gcProbability) {
0 ignored issues
show
Bug introduced by
The constant Yiisoft\Cache\File\1_000_000 was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
425 1
            $this->removeCacheFiles($this->cachePath, true);
426
        }
427
    }
428
429 104
    private function validateKey(string $key): void
430
    {
431 104
        if ($key === '' || strpbrk($key, '{}()/\@:')) {
432 12
            throw new InvalidArgumentException('Invalid key value.');
433
        }
434
    }
435
436 14
    private function validateKeys(array $keys): void
437
    {
438 14
        foreach ($keys as $key) {
439 14
            $this->validateKey($key);
440
        }
441
    }
442
443 79
    private function existsAndNotExpired(string $file): bool
444
    {
445 79
        return is_file($file) && @filemtime($file) > time();
446
    }
447
448
    /**
449
     * Converts iterable to array. If provided value is not iterable it throws an InvalidArgumentException.
450
     */
451 14
    private function iterableToArray(iterable $iterable): array
452
    {
453
        /** @psalm-suppress RedundantCast */
454 14
        return $iterable instanceof Traversable ? iterator_to_array($iterable) : (array) $iterable;
455
    }
456
457
    /**
458
     * Check if error was because of file was already deleted by another process on high load
459
     */
460 89
    private function isLastErrorNotSafe(mixed $result): bool
461
    {
462 89
        if ($result !== false) {
463 89
            return false;
464
        }
465
466
        $lastError = error_get_last();
467
468
        if ($lastError === null) {
469
            return false;
470
        }
471
472
        if (str_ends_with($lastError['message'] ?? '', 'No such file or directory')) {
473
            error_clear_last();
474
            return false;
475
        }
476
477
        return true;
478
    }
479
}
480