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

79
                /** @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...
80 64
                $cacheValue = @stream_get_contents($fp);
81 64
                @flock($fp, LOCK_UN);
82 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

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

112
            /** @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...
113
        }
114
115 89
        $value = $this->serializer->serialize($value);
116
117 89
        if (@file_put_contents($cacheFile, $value, LOCK_EX) !== false) {
118 89
            if ($this->fileMode !== null) {
119 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

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