Passed
Branch master (0b4094)
by Alexander
01:32
created

FileCache::removeCacheFiles()   B

Complexity

Conditions 11
Paths 8

Size

Total Lines 22
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 11.7975

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 15
c 1
b 0
f 0
nc 8
nop 2
dl 0
loc 22
ccs 13
cts 16
cp 0.8125
crap 11.7975
rs 7.3166

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
namespace Yiisoft\Cache;
4
5
use DateInterval;
6
use DateTime;
7
use Psr\SimpleCache\CacheInterface;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Yiisoft\Cache\CacheInterface. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
8
use Yiisoft\Cache\Exception\CacheException;
9
use Yiisoft\Cache\Serializer\PhpSerializer;
10
use Yiisoft\Cache\Serializer\SerializerInterface;
11
12
/**
13
 * FileCache implements a cache handler using files.
14
 *
15
 * For each data value being cached, FileCache will store it in a separate file.
16
 * The cache files are placed under {@see FileCache::$cachePath}. FileCache will perform garbage collection
17
 * automatically to remove expired cache files.
18
 *
19
 * Please refer to {@see \Psr\SimpleCache\CacheInterface} for common cache operations that are supported by FileCache.
20
 */
21
final class FileCache implements CacheInterface
22
{
23
    private const TTL_INFINITY = 31536000; // 1 year
24
    private const EXPIRATION_EXPIRED = -1;
25
    private const TTL_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 179
    public function __construct(string $cachePath, ?SerializerInterface $serializer = null)
69
    {
70 179
        $this->cachePath = $cachePath;
71 179
        $this->serializer = $serializer ?? new PhpSerializer();
72 179
        $this->initCacheDirectory();
73
    }
74
75 137
    public function get($key, $default = null)
76
    {
77 137
        if ($this->existsAndNotExpired($key)) {
78 115
            $fp = @fopen($this->getCacheFile($key), 'rb');
79 115
            if ($fp !== false) {
80 115
                @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

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

83
                /** @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...
84 115
                $cacheValue = $this->serializer->unserialize($cacheValue);
85 115
                return $cacheValue;
86
            }
87
        }
88
89 56
        return $default;
90
    }
91
92 157
    public function set($key, $value, $ttl = null): bool
93
    {
94 157
        $this->gc();
95
96 157
        $expiration = $this->ttlToExpiration($ttl);
97 157
        if ($expiration < 0) {
98 2
            return $this->delete($key);
99
        }
100
101 157
        $cacheFile = $this->getCacheFile($key);
102 157
        if ($this->directoryLevel > 0) {
103 157
            $directoryName = \dirname($cacheFile);
104 157
            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 157
        if (\function_exists('posix_geteuid') && is_file($cacheFile) && fileowner($cacheFile) !== posix_geteuid()) {
112 2
            @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 157
        $value = $this->serializer->serialize($value);
116
117 157
        if (@file_put_contents($cacheFile, $value, LOCK_EX) !== false) {
118 157
            if ($this->fileMode !== null) {
119
                @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 157
            return @touch($cacheFile, $expiration);
122
        }
123
124
        return false;
125
    }
126
127 24
    public function delete($key): bool
128
    {
129 24
        return @unlink($this->getCacheFile($key));
130
    }
131
132 160
    public function clear(): bool
133
    {
134 160
        $this->removeCacheFiles($this->cachePath, false);
135 160
        return true;
136
    }
137
138 20
    public function getMultiple($keys, $default = null): iterable
139
    {
140 20
        $results = [];
141 20
        foreach ($keys as $key) {
142 20
            $value = $this->get($key, $default);
143 20
            $results[$key] = $value;
144
        }
145 20
        return $results;
146
    }
147
148 26
    public function setMultiple($values, $ttl = null): bool
149
    {
150 26
        foreach ($values as $key => $value) {
151 26
            $this->set($key, $value, $ttl);
152
        }
153 26
        return true;
154
    }
155
156 2
    public function deleteMultiple($keys): bool
157
    {
158 2
        foreach ($keys as $key) {
159 2
            $this->delete($key);
160
        }
161 2
        return true;
162
    }
163
164 29
    public function has($key): bool
165
    {
166 29
        return $this->existsAndNotExpired($key);
167
    }
168
169
    /**
170
     * Converts TTL to expiration
171
     * @param int|DateInterval|null $ttl
172
     * @return int
173
     */
174 163
    private function ttlToExpiration($ttl): int
175
    {
176 163
        $ttl = $this->normalizeTtl($ttl);
177
178 163
        if ($ttl === null) {
179 153
            $expiration = static::TTL_INFINITY + time();
180 14
        } elseif ($ttl <= 0) {
181 4
            $expiration = static::EXPIRATION_EXPIRED;
182
        } else {
183 10
            $expiration = $ttl + time();
184
        }
185
186 163
        return $expiration;
187
    }
188
189
    /**
190
     * @noinspection PhpDocMissingThrowsInspection DateTime won't throw exception because constant string is passed as time
191
     *
192
     * Normalizes cache TTL handling `null` value and {@see DateInterval} objects.
193
     * @param int|DateInterval|null $ttl raw TTL.
194
     * @return int|null TTL value as UNIX timestamp or null meaning infinity
195
     */
196 175
    private function normalizeTtl($ttl): ?int
197
    {
198 175
        if ($ttl instanceof DateInterval) {
199 5
            return (new DateTime('@0'))->add($ttl)->getTimestamp();
200
        }
201
202 171
        return $ttl;
203
    }
204
205
    /**
206
     * Ensures that cache directory exists.
207
     */
208 179
    private function initCacheDirectory(): void
209
    {
210 179
        if (!$this->createDirectory($this->cachePath, $this->dirMode)) {
211
            throw new CacheException('Failed to create cache directory "' . $this->cachePath . '"');
212
        }
213
    }
214
215 179
    private function createDirectory(string $path, int $mode): bool
216
    {
217 179
        return is_dir($path) || (mkdir($path, $mode, true) && is_dir($path));
218
    }
219
220
    /**
221
     * Returns the cache file path given the cache key.
222
     * @param string $key cache key
223
     * @return string the cache file path
224
     */
225 159
    private function getCacheFile(string $key): string
226
    {
227 159
        if ($this->directoryLevel > 0) {
228 159
            $base = $this->cachePath;
229 159
            for ($i = 0; $i < $this->directoryLevel; ++$i) {
230 159
                if (($prefix = substr($key, $i + $i, 2)) !== false) {
231 159
                    $base .= DIRECTORY_SEPARATOR . $prefix;
232
                }
233
            }
234
235 159
            return $base . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
236
        }
237
238
        return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
239
    }
240
241
    /**
242
     * Removes expired cache files
243
     * @throws \Exception
244
     */
245 157
    public function gc(): void
246
    {
247 157
        if (\random_int(0, 1000000) < $this->gcProbability) {
248
            $this->removeCacheFiles($this->cachePath, true);
249
        }
250
    }
251
252
    /**
253
     * Recursively removing expired cache files under a directory.
254
     * This method is mainly used by {@see gc()}.
255
     * @param string $path the directory under which expired cache files are removed.
256
     * @param bool $expiredOnly whether to only remove expired cache files. If false, all files
257
     * under `$path` will be removed.
258
     */
259 160
    private function removeCacheFiles(string $path, bool $expiredOnly): void
260
    {
261 160
        if (($handle = opendir($path)) !== false) {
262 160
            while (($file = readdir($handle)) !== false) {
263 160
                if (strncmp($file, '.', 1) === 0) {
264 160
                    continue;
265
                }
266 153
                $fullPath = $path . DIRECTORY_SEPARATOR . $file;
267 153
                if (is_dir($fullPath)) {
268 153
                    $this->removeCacheFiles($fullPath, $expiredOnly);
269 153
                    if (!$expiredOnly && !@rmdir($fullPath)) {
270
                        $error = error_get_last();
271 153
                        throw new CacheException("Unable to remove directory '{$fullPath}': {$error['message']}");
272
                    }
273 130
                } elseif (!$expiredOnly || ($expiredOnly && @filemtime($fullPath) < time())) {
274 130
                    if (!@unlink($fullPath)) {
275
                        $error = error_get_last();
276
                        throw new CacheException("Unable to remove file '{$fullPath}': {$error['message']}");
277
                    }
278
                }
279
            }
280 160
            closedir($handle);
281
        }
282
    }
283
284
    /**
285
     * @param string $cacheFileSuffix cache file suffix. Defaults to '.bin'.
286
     */
287
    public function setCacheFileSuffix(string $cacheFileSuffix): void
288
    {
289
        $this->cacheFileSuffix = $cacheFileSuffix;
290
    }
291
292
    /**
293
     * @param int $gcProbability the probability (parts per million) that garbage collection (GC) should be performed
294
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
295
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
296
     */
297
    public function setGcProbability(int $gcProbability): void
298
    {
299
        $this->gcProbability = $gcProbability;
300
    }
301
302
    /**
303
     * @param int $fileMode the permission to be set for newly created cache files.
304
     * This value will be used by PHP chmod() function. No umask will be applied.
305
     * If not set, the permission will be determined by the current environment.
306
     */
307
    public function setFileMode(int $fileMode): void
308
    {
309
        $this->fileMode = $fileMode;
310
    }
311
312
    /**
313
     * @param int $dirMode the permission to be set for newly created directories.
314
     * This value will be used by PHP chmod() function. No umask will be applied.
315
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
316
     * but read-only for other users.
317
     */
318
    public function setDirMode(int $dirMode): void
319
    {
320
        $this->dirMode = $dirMode;
321
    }
322
323
    /**
324
     * @param int $directoryLevel the level of sub-directories to store cache files. Defaults to 1.
325
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
326
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
327
     * is not over burdened with a single directory having too many files.
328
     */
329
    public function setDirectoryLevel(int $directoryLevel): void
330
    {
331
        $this->directoryLevel = $directoryLevel;
332
    }
333
334
    /**
335
     * @param string $key
336
     * @return bool
337
     */
338 139
    private function existsAndNotExpired(string $key): bool
339
    {
340 139
        return @filemtime($this->getCacheFile($key)) > time();
341
    }
342
}
343