Passed
Push — master ( 11f2f3...3b9e3a )
by Alexander
02:17
created

FileCache::createDirectoryIfNotExists()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 3

Importance

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

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