Passed
Push — master ( 6c0eda...93bf2f )
by Sergei
03:56 queued 01:36
created

FileCache::delete()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 20
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4.5923

Importance

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