Passed
Push — master ( a62ae3...3743ce )
by Alexander
12:38
created

FileCache::set()   B

Complexity

Conditions 11
Paths 10

Size

Total Lines 38
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 11.4083

Importance

Changes 0
Metric Value
eloc 19
c 0
b 0
f 0
dl 0
loc 38
ccs 17
cts 20
cp 0.85
rs 7.3166
cc 11
nc 10
nop 3
crap 11.4083

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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