Passed
Pull Request — master (#25)
by Alexander
02:22
created

FileCache::validateKeysOfValues()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
ccs 3
cts 3
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Cache\File;
6
7
use DateInterval;
8
use DateTime;
9
use Psr\SimpleCache\CacheInterface;
10
use Yiisoft\Serializer\PhpSerializer;
11
use Yiisoft\Serializer\SerializerInterface;
12
13
/**
14
 * FileCache implements a cache handler using files.
15
 *
16
 * For each data value being cached, FileCache will store it in a separate file.
17
 * The cache files are placed under {@see FileCache::$cachePath}. FileCache will perform garbage collection
18
 * automatically to remove expired cache files.
19
 *
20
 * Please refer to {@see \Psr\SimpleCache\CacheInterface} for common cache operations that are supported by FileCache.
21
 */
22
final class FileCache implements CacheInterface
23
{
24
    private const TTL_INFINITY = 31536000; // 1 year
25
    private const EXPIRATION_EXPIRED = -1;
26
27
    /**
28
     * @var string the directory to store cache files. You may use [path alias](guide:concept-aliases) here.
29
     */
30
    private $cachePath;
31
    /**
32
     * @var string cache file suffix. Defaults to '.bin'.
33
     */
34
    private $cacheFileSuffix = '.bin';
35
    /**
36
     * @var int the level of sub-directories to store cache files. Defaults to 1.
37
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
38
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
39
     * is not over burdened with a single directory having too many files.
40
     */
41
    private $directoryLevel = 1;
42
43
    /**
44
     * @var int the probability (parts per million) that garbage collection (GC) should be performed
45
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
46
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
47
     */
48
    private $gcProbability = 10;
49
    /**
50
     * @var int the permission to be set for newly created cache files.
51
     * This value will be used by PHP chmod() function. No umask will be applied.
52
     * If not set, the permission will be determined by the current environment.
53
     */
54
    private $fileMode;
55
    /**
56
     * @var int the permission to be set for newly created directories.
57
     * This value will be used by PHP chmod() function. No umask will be applied.
58
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
59
     * but read-only for other users.
60
     */
61
    private $dirMode = 0775;
62
63
    /**
64
     * @var SerializerInterface the serializer to be used for serializing and unserializing of the cached data.
65
     */
66
    private $serializer;
67
68 108
    public function __construct(string $cachePath, ?SerializerInterface $serializer = null)
69
    {
70 108
        $this->cachePath = $cachePath;
71 108
        $this->serializer = $serializer ?? new PhpSerializer();
72 108
        $this->initCacheDirectory();
73 108
    }
74
75 78
    public function get($key, $default = null)
76
    {
77 78
        $this->validateKey($key);
78 77
        if ($this->existsAndNotExpired($key)) {
79 64
            $fp = @fopen($this->getCacheFile($key), 'rb');
80 64
            if ($fp !== false) {
81 64
                @flock($fp, LOCK_SH);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for flock(). 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

81
                /** @scrutinizer ignore-unhandled */ @flock($fp, LOCK_SH);

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...
82 64
                $cacheValue = @stream_get_contents($fp);
83 64
                @flock($fp, LOCK_UN);
84 64
                @fclose($fp);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for fclose(). 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

84
                /** @scrutinizer ignore-unhandled */ @fclose($fp);

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...
85 64
                return $this->serializer->unserialize($cacheValue);
86
            }
87
        }
88
89 27
        return $default;
90
    }
91
92 90
    public function set($key, $value, $ttl = null): bool
93
    {
94 90
        $this->validateKey($key);
95 89
        $this->gc();
96
97 89
        $expiration = $this->ttlToExpiration($ttl);
98 89
        if ($expiration < 0) {
99 1
            return $this->delete($key);
100
        }
101
102 89
        $cacheFile = $this->getCacheFile($key);
103 89
        if ($this->directoryLevel > 0) {
104 88
            $directoryName = \dirname($cacheFile);
105 88
            if (!$this->createDirectory($directoryName, $this->dirMode)) {
106
                return false;
107
            }
108
        }
109
        // If ownership differs the touch call will fail, so we try to
110
        // rebuild the file from scratch by deleting it first
111
        // https://github.com/yiisoft/yii2/pull/16120
112 89
        if (\function_exists('posix_geteuid') && is_file($cacheFile) && fileowner($cacheFile) !== posix_geteuid()) {
113 1
            @unlink($cacheFile);
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

113
            /** @scrutinizer ignore-unhandled */ @unlink($cacheFile);

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...
114
        }
115
116 89
        $value = $this->serializer->serialize($value);
117
118 89
        if (@file_put_contents($cacheFile, $value, LOCK_EX) !== false) {
119 89
            if ($this->fileMode !== null) {
120 1
                @chmod($cacheFile, $this->fileMode);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for chmod(). 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

120
                /** @scrutinizer ignore-unhandled */ @chmod($cacheFile, $this->fileMode);

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...
121
            }
122 89
            return @touch($cacheFile, $expiration);
123
        }
124
125
        return false;
126
    }
127
128 15
    public function delete($key): bool
129
    {
130 15
        $this->validateKey($key);
131 14
        return @unlink($this->getCacheFile($key));
132
    }
133
134 90
    public function clear(): bool
135
    {
136 90
        $this->removeCacheFiles($this->cachePath, false);
137 90
        return true;
138
    }
139
140 9
    public function getMultiple($keys, $default = null): iterable
141
    {
142 9
        $keys = $this->iterableToArray($keys);
143 8
        $this->validateKeys($keys);
144 7
        $results = [];
145 7
        foreach ($keys as $key) {
146 7
            $value = $this->get($key, $default);
147 7
            $results[$key] = $value;
148
        }
149 7
        return $results;
150
    }
151
152 11
    public function setMultiple($values, $ttl = null): bool
153
    {
154 11
        $values = $this->iterableToArray($values);
155 10
        $this->validateKeysOfValues($values);
156 10
        foreach ($values as $key => $value) {
157 10
            $this->set((string)$key, $value, $ttl);
158
        }
159 10
        return true;
160
    }
161
162 3
    public function deleteMultiple($keys): bool
163
    {
164 3
        $keys = $this->iterableToArray($keys);
165 2
        $this->validateKeys($keys);
166 1
        foreach ($keys as $key) {
167 1
            $this->delete($key);
168
        }
169 1
        return true;
170
    }
171
172 14
    public function has($key): bool
173
    {
174 14
        $this->validateKey($key);
175 13
        return $this->existsAndNotExpired($key);
176
    }
177
178
    /**
179
     * Converts TTL to expiration
180
     *
181
     * @param DateInterval|int|null $ttl
182
     *
183
     * @return int
184
     */
185 92
    private function ttlToExpiration($ttl): int
186
    {
187 92
        $ttl = $this->normalizeTtl($ttl);
188
189 92
        if ($ttl === null) {
190 87
            $expiration = self::TTL_INFINITY + time();
191 8
        } elseif ($ttl <= 0) {
192 2
            $expiration = self::EXPIRATION_EXPIRED;
193
        } else {
194 6
            $expiration = $ttl + time();
195
        }
196
197 92
        return $expiration;
198
    }
199
200
    /**
201
     * @noinspection PhpDocMissingThrowsInspection DateTime won't throw exception because constant string is passed as time
202
     *
203
     * Normalizes cache TTL handling strings and {@see DateInterval} objects.
204
     *
205
     * @param DateInterval|int|string|null $ttl raw TTL.
206
     *
207
     * @return int|null TTL value as UNIX timestamp or null meaning infinity
208
     */
209 98
    private function normalizeTtl($ttl): ?int
210
    {
211 98
        if ($ttl instanceof DateInterval) {
212 3
            return (new DateTime('@0'))->add($ttl)->getTimestamp();
213
        }
214
215 96
        if (is_string($ttl)) {
216 1
            return (int)$ttl;
217
        }
218
219 95
        return $ttl;
220
    }
221
222
    /**
223
     * Ensures that cache directory exists.
224
     */
225 108
    private function initCacheDirectory(): void
226
    {
227 108
        if (!$this->createDirectory($this->cachePath, $this->dirMode)) {
228
            throw new CacheException('Failed to create cache directory "' . $this->cachePath . '"');
229
        }
230 108
    }
231
232 108
    private function createDirectory(string $path, int $mode): bool
233
    {
234 108
        return is_dir($path) || (mkdir($path, $mode, true) && is_dir($path));
235
    }
236
237
    /**
238
     * Returns the cache file path given the cache key.
239
     *
240
     * @param string $key cache key
241
     *
242
     * @return string the cache file path
243
     */
244 90
    private function getCacheFile(string $key): string
245
    {
246 90
        if ($this->directoryLevel > 0) {
247 89
            $base = $this->cachePath;
248 89
            for ($i = 0; $i < $this->directoryLevel; ++$i) {
249 89
                if (($prefix = substr($key, $i + $i, 2)) !== false) {
250 89
                    $base .= DIRECTORY_SEPARATOR . $prefix;
251
                }
252
            }
253
254 89
            return $base . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
255
        }
256
257 1
        return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
258
    }
259
260
    /**
261
     * Removes expired cache files
262
     *
263
     * @throws \Exception
264
     */
265 89
    public function gc(): void
266
    {
267 89
        if (\random_int(0, 1000000) < $this->gcProbability) {
268 1
            $this->removeCacheFiles($this->cachePath, true);
269
        }
270 89
    }
271
272
    /**
273
     * Recursively removing expired cache files under a directory.
274
     * This method is mainly used by {@see gc()}.
275
     *
276
     * @param string $path the directory under which expired cache files are removed.
277
     * @param bool $expiredOnly whether to only remove expired cache files. If false, all files
278
     * under `$path` will be removed.
279
     */
280 90
    private function removeCacheFiles(string $path, bool $expiredOnly): void
281
    {
282 90
        if (($handle = opendir($path)) !== false) {
283 90
            while (($file = readdir($handle)) !== false) {
284 90
                if (strncmp($file, '.', 1) === 0) {
285 90
                    continue;
286
                }
287 86
                $fullPath = $path . DIRECTORY_SEPARATOR . $file;
288 86
                if (is_dir($fullPath)) {
289 85
                    $this->removeCacheFiles($fullPath, $expiredOnly);
290 85
                    if (!$expiredOnly && !@rmdir($fullPath)) {
291
                        $error = error_get_last();
292 85
                        throw new CacheException("Unable to remove directory '{$fullPath}': {$error['message']}");
293
                    }
294 72
                } elseif (!$expiredOnly || ($expiredOnly && @filemtime($fullPath) < time())) {
295 72
                    if (!@unlink($fullPath)) {
296
                        $error = error_get_last();
297
                        throw new CacheException("Unable to remove file '{$fullPath}': {$error['message']}");
298
                    }
299
                }
300
            }
301 90
            closedir($handle);
302
        }
303 90
    }
304
305
    /**
306
     * @param string $cacheFileSuffix cache file suffix. Defaults to '.bin'.
307
     */
308 1
    public function setCacheFileSuffix(string $cacheFileSuffix): void
309
    {
310 1
        $this->cacheFileSuffix = $cacheFileSuffix;
311 1
    }
312
313
    /**
314
     * @param int $gcProbability the probability (parts per million) that garbage collection (GC) should be performed
315
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
316
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
317
     */
318 1
    public function setGcProbability(int $gcProbability): void
319
    {
320 1
        $this->gcProbability = $gcProbability;
321 1
    }
322
323
    /**
324
     * @param int $fileMode the permission to be set for newly created cache files.
325
     * This value will be used by PHP chmod() function. No umask will be applied.
326
     * If not set, the permission will be determined by the current environment.
327
     */
328 1
    public function setFileMode(int $fileMode): void
329
    {
330 1
        $this->fileMode = $fileMode;
331 1
    }
332
333
    /**
334
     * @param int $dirMode the permission to be set for newly created directories.
335
     * This value will be used by PHP chmod() function. No umask will be applied.
336
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
337
     * but read-only for other users.
338
     */
339 1
    public function setDirMode(int $dirMode): void
340
    {
341 1
        $this->dirMode = $dirMode;
342 1
    }
343
344
    /**
345
     * @param int $directoryLevel the level of sub-directories to store cache files. Defaults to 1.
346
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
347
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
348
     * is not over burdened with a single directory having too many files.
349
     */
350 1
    public function setDirectoryLevel(int $directoryLevel): void
351
    {
352 1
        $this->directoryLevel = $directoryLevel;
353 1
    }
354
355
    /**
356
     * @param string $key
357
     *
358
     * @return bool
359
     */
360 78
    private function existsAndNotExpired(string $key): bool
361
    {
362 78
        return @filemtime($this->getCacheFile($key)) > time();
363
    }
364
365
    /**
366
     * Converts iterable to array. If provided value is not iterable it throws an InvalidArgumentException
367
     *
368
     * @param $iterable
369
     *
370
     * @return array
371
     */
372 15
    private function iterableToArray($iterable): array
373
    {
374 15
        if (!is_iterable($iterable)) {
375 3
            throw new InvalidArgumentException('Iterable is expected, got ' . gettype($iterable));
376
        }
377
378 12
        return $iterable instanceof \Traversable ? iterator_to_array($iterable) : (array)$iterable;
379
    }
380
381
    /**
382
     * @param $key
383
     */
384 96
    private function validateKey($key): void
385
    {
386 96
        if (!\is_string($key) || strpbrk($key, '{}()/\@:')) {
387 6
            throw new InvalidArgumentException('Invalid key value.');
388
        }
389 90
    }
390
391
    /**
392
     * @param array $keys
393
     */
394 12
    private function validateKeys(array $keys): void
395
    {
396 12
        foreach ($keys as $key) {
397 12
            $this->validateKey($key);
398
        }
399 10
    }
400
401
    /**
402
     * @param array $values
403
     */
404 10
    private function validateKeysOfValues(array $values): void
405
    {
406 10
        $keys = array_map('strval', array_keys($values));
407 10
        $this->validateKeys($keys);
408 10
    }
409
}
410