Passed
Pull Request — master (#30)
by Evgeniy
01:51
created

FileCache::set()   B

Complexity

Conditions 10
Paths 8

Size

Total Lines 35
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 10.1626

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 17
c 1
b 0
f 0
dl 0
loc 35
ccs 15
cts 17
cp 0.8824
rs 7.6666
cc 10
nc 8
nop 3
crap 10.1626

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 gettype;
23
use function is_dir;
24
use function is_file;
25
use function is_iterable;
26
use function is_string;
27
use function iterator_to_array;
28
use function opendir;
29
use function posix_geteuid;
30
use function random_int;
31
use function readdir;
32
use function rmdir;
33
use function serialize;
34
use function strncmp;
35
use function strpbrk;
36
use function substr;
37
use function unlink;
38
use function unserialize;
39
40
use const LOCK_EX;
41
use const LOCK_SH;
42
use const LOCK_UN;
43
44
/**
45
 * FileCache implements a cache handler using files.
46
 *
47
 * For each data value being cached, FileCache will store it in a separate file.
48
 * The cache files are placed under {@see FileCache::$cachePath}.
49
 * FileCache will perform garbage collection automatically to remove expired cache files.
50
 *
51
 * Please refer to {@see \Psr\SimpleCache\CacheInterface} for common cache operations that are supported by FileCache.
52
 */
53
final class FileCache implements CacheInterface
54
{
55
    private const TTL_INFINITY = 31536000; // 1 year
56
    private const EXPIRATION_EXPIRED = -1;
57
58
    /**
59
     * @var string The directory to store cache files.
60
     */
61
    private string $cachePath;
62
63
    /**
64
     * @var string The cache file suffix. Defaults to '.bin'.
65
     */
66
    private string $fileSuffix = '.bin';
67
68
    /**
69
     * @var int|null The permission to be set for newly created cache files.
70
     * This value will be used by PHP chmod() function. No umask will be applied.
71
     * If not set, the permission will be determined by the current environment.
72
     */
73
    private ?int $fileMode = null;
74
75
    /**
76
     * @var int The permission to be set for newly created directories.
77
     * This value will be used by PHP chmod() function. No umask will be applied.
78
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
79
     * but read-only for other users.
80
     */
81
    private int $directoryMode = 0775;
82
83
    /**
84
     * @var int The level of sub-directories to store cache files. Defaults to 1.
85
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
86
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
87
     * is not over burdened with a single directory having too many files.
88
     */
89
    private int $directoryLevel = 1;
90
91
    /**
92
     * @var int The probability (parts per million) that garbage collection (GC) should be performed
93
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
94
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
95
     */
96
    private int $gcProbability = 10;
97
98
    /**
99
     * @param string $cachePath The directory to store cache files.
100
     *
101
     * @see FileCache::$cachePath
102
     *
103
     * @throws CacheException If failed to create cache directory.
104
     */
105 174
    public function __construct(string $cachePath)
106
    {
107 174
        if (!$this->createDirectoryIfNotExists($cachePath)) {
108 1
            throw new CacheException("Failed to create cache directory \"{$cachePath}\".");
109
        }
110
111 174
        $this->cachePath = $cachePath;
112 174
    }
113
114 86
    public function get($key, $default = null)
115
    {
116 86
        $this->validateKey($key);
117 78
        $file = $this->getCacheFile($key);
118
119 78
        if (!$this->existsAndNotExpired($file) || ($filePointer = @fopen($file, 'rb')) === false) {
120 28
            return $default;
121
        }
122
123 64
        flock($filePointer, LOCK_SH);
124 64
        $value = stream_get_contents($filePointer);
125 64
        flock($filePointer, LOCK_UN);
126 64
        fclose($filePointer);
127
128 64
        return unserialize($value);
129
    }
130
131 98
    public function set($key, $value, $ttl = null): bool
132
    {
133 98
        $this->validateKey($key);
134 90
        $this->gc();
135 90
        $expiration = $this->ttlToExpiration($ttl);
136
137 90
        if ($expiration < 0) {
138 1
            return $this->delete($key);
139
        }
140
141 90
        $file = $this->getCacheFile($key);
142
143
        if (
144 90
            !is_dir($this->cachePath)
145 90
            || ($this->directoryLevel > 0 && !$this->createDirectoryIfNotExists(dirname($file)))
146
        ) {
147 1
            return false;
148
        }
149
150
        // If ownership differs the touch call will fail, so we try to
151
        // rebuild the file from scratch by deleting it first
152
        // https://github.com/yiisoft/yii2/pull/16120
153 89
        if (function_exists('posix_geteuid') && is_file($file) && fileowner($file) !== posix_geteuid()) {
154
            @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

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