Passed
Pull Request — master (#27)
by Alexander
01:44
created

FileCache::setValue()   B

Complexity

Conditions 8
Paths 13

Size

Total Lines 29
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 11.7643

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 17
c 1
b 0
f 0
dl 0
loc 29
ccs 11
cts 18
cp 0.6111
rs 8.4444
cc 8
nc 13
nop 3
crap 11.7643
1
<?php
2
namespace Yiisoft\Cache;
3
4
use Psr\Log\LoggerInterface;
5
use Yiisoft\Cache\Exception\CacheException;
6
use Yiisoft\Cache\Serializer\SerializerInterface;
7
8
/**
9
 * FileCache implements a cache handler using files.
10
 *
11
 * For each data value being cached, FileCache will store it in a separate file.
12
 * The cache files are placed under {@see FileCache::$cachePath}. FileCache will perform garbage collection
13
 * automatically to remove expired cache files.
14
 *
15
 * Please refer to {@see \Psr\SimpleCache\CacheInterface} for common cache operations that are supported by FileCache.
16
 */
17
final class FileCache extends SimpleCache
18
{
19
    private const TTL_INFINITY = 31536000; // 1 year
20
21
    /**
22
     * @var string the directory to store cache files. You may use [path alias](guide:concept-aliases) here.
23
     */
24
    private $cachePath;
25
    /**
26
     * @var string cache file suffix. Defaults to '.bin'.
27
     */
28
    private $cacheFileSuffix = '.bin';
29
    /**
30
     * @var int the level of sub-directories to store cache files. Defaults to 1.
31
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
32
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
33
     * is not over burdened with a single directory having too many files.
34
     */
35
    private $directoryLevel = 1;
36
37
    /**
38
     * @var int the probability (parts per million) that garbage collection (GC) should be performed
39
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
40
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
41
     */
42
    private $gcProbability = 10;
43
    /**
44
     * @var int the permission to be set for newly created cache files.
45
     * This value will be used by PHP chmod() function. No umask will be applied.
46
     * If not set, the permission will be determined by the current environment.
47
     */
48
    private $fileMode;
49
    /**
50
     * @var int the permission to be set for newly created directories.
51
     * This value will be used by PHP chmod() function. No umask will be applied.
52
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
53
     * but read-only for other users.
54
     */
55
    private $dirMode = 0775;
56
57
    private $logger;
58
59 19
    public function __construct(string $cachePath, LoggerInterface $logger, SerializerInterface $serializer = null)
60
    {
61 19
        $this->logger = $logger;
62 19
        $this->setCachePath($cachePath);
63 19
        parent::__construct($serializer);
64
    }
65
66
    /**
67
     * Sets cache path and ensures it exists.
68
     * @param string $cachePath
69
     */
70 19
    public function setCachePath(string $cachePath): void
71
    {
72 19
        $this->cachePath = $cachePath;
73
74 19
        if (!$this->createDirectory($this->cachePath, $this->dirMode)) {
75
            throw new CacheException('Failed to create cache directory "' . $this->cachePath . '"');
76
        }
77
    }
78
79 4
    protected function hasValue(string $key): bool
80
    {
81 4
        $cacheFile = $this->getCacheFile($key);
82
83 4
        return @filemtime($cacheFile) > time();
84
    }
85
86
    /**
87
     * @param string $cacheFileSuffix cache file suffix. Defaults to '.bin'.
88
     */
89
    public function setCacheFileSuffix(string $cacheFileSuffix): void
90
    {
91
        $this->cacheFileSuffix = $cacheFileSuffix;
92
    }
93
94
    /**
95
     * @param int $gcProbability the probability (parts per million) that garbage collection (GC) should be performed
96
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
97
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
98
     */
99
    public function setGcProbability(int $gcProbability): void
100
    {
101
        $this->gcProbability = $gcProbability;
102
    }
103
104
    /**
105
     * @param int $fileMode the permission to be set for newly created cache files.
106
     * This value will be used by PHP chmod() function. No umask will be applied.
107
     * If not set, the permission will be determined by the current environment.
108
     */
109
    public function setFileMode(int $fileMode): void
110
    {
111
        $this->fileMode = $fileMode;
112
    }
113
114
    /**
115
     * @param int $dirMode the permission to be set for newly created directories.
116
     * This value will be used by PHP chmod() function. No umask will be applied.
117
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
118
     * but read-only for other users.
119
     */
120
    public function setDirMode(int $dirMode): void
121
    {
122
        $this->dirMode = $dirMode;
123
    }
124
125 18
    protected function getValue(string $key, $default = null)
126
    {
127 18
        $cacheFile = $this->getCacheFile($key);
128
129 18
        if (@filemtime($cacheFile) > time()) {
130 16
            $fp = @fopen($cacheFile, 'rb');
131 16
            if ($fp !== false) {
132 16
                @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

132
                /** @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...
133 16
                $cacheValue = @stream_get_contents($fp);
134 16
                @flock($fp, LOCK_UN);
135 16
                @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

135
                /** @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...
136 16
                return $cacheValue;
137
            }
138
        }
139
140 12
        return $default;
141
    }
142
143 18
    protected function setValue(string $key, $value, ?int $ttl): bool
144
    {
145 18
        $this->gc();
146 18
        $cacheFile = $this->getCacheFile($key);
147 18
        if ($this->directoryLevel > 0) {
148 18
            $directoryName = \dirname($cacheFile);
149 18
            if (!$this->createDirectory($directoryName, $this->dirMode)) {
150
                $this->logger->warning('Failed to create cache directory "' . $directoryName . '"');
151
                return false;
152
            }
153
        }
154
        // If ownership differs the touch call will fail, so we try to
155
        // rebuild the file from scratch by deleting it first
156
        // https://github.com/yiisoft/yii2/pull/16120
157 18
        if (\function_exists('posix_geteuid') && is_file($cacheFile) && fileowner($cacheFile) !== posix_geteuid()) {
158
            @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

158
            /** @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...
159
        }
160
161 18
        if (@file_put_contents($cacheFile, $value, LOCK_EX) !== false) {
162 18
            if ($this->fileMode !== null) {
163
                @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

163
                /** @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...
164
            }
165 18
            $ttl = $ttl ?? self::TTL_INFINITY;
166 18
            return @touch($cacheFile, $ttl + time());
167
        }
168
169
        $error = error_get_last();
170
        $this->logger->warning("Failed to write cache data to \"$cacheFile\": " . $error['message']);
171
        return false;
172
    }
173
174 2
    protected function deleteValue(string $key): bool
175
    {
176 2
        $cacheFile = $this->getCacheFile($key);
177 2
        return @unlink($cacheFile);
178
    }
179
180
    /**
181
     * Returns the cache file path given the cache key.
182
     * @param string $key cache key
183
     * @return string the cache file path
184
     */
185 19
    private function getCacheFile(string $key): string
186
    {
187 19
        if ($this->directoryLevel > 0) {
188 19
            $base = $this->cachePath;
189 19
            for ($i = 0; $i < $this->directoryLevel; ++$i) {
190 19
                if (($prefix = substr($key, $i + $i, 2)) !== false) {
191 19
                    $base .= DIRECTORY_SEPARATOR . $prefix;
192
                }
193
            }
194
195 19
            return $base . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
196
        }
197
198
        return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
199
    }
200
201 19
    public function clear(): bool
202
    {
203 19
        $this->removeCacheFiles($this->cachePath, false);
204 19
        return true;
205
    }
206
207
    /**
208
     * Removes expired cache files
209
     * @throws \Exception
210
     */
211 18
    public function gc(): void
212
    {
213 18
        if (\random_int(0, 1000000) < $this->gcProbability) {
214
            $this->removeCacheFiles($this->cachePath, true);
215
        }
216
    }
217
218
    /**
219
     * Recursively removing expired cache files under a directory.
220
     * This method is mainly used by {@see gc()}.
221
     * @param string $path the directory under which expired cache files are removed.
222
     * @param bool $expiredOnly whether to only remove expired cache files. If false, all files
223
     * under `$path` will be removed.
224
     */
225 19
    private function removeCacheFiles(string $path, bool $expiredOnly): void
226
    {
227 19
        if (($handle = opendir($path)) !== false) {
228 19
            while (($file = readdir($handle)) !== false) {
229 19
                if (strncmp($file, '.', 1) === 0) {
230 19
                    continue;
231
                }
232 16
                $fullPath = $path . DIRECTORY_SEPARATOR . $file;
233 16
                if (is_dir($fullPath)) {
234 16
                    $this->removeCacheFiles($fullPath, $expiredOnly);
235 16
                    if (!$expiredOnly && !@rmdir($fullPath)) {
236
                        $error = error_get_last();
237 16
                        throw new CacheException("Unable to remove directory '{$fullPath}': {$error['message']}");
238
                    }
239 15
                } elseif (!$expiredOnly || ($expiredOnly && @filemtime($fullPath) < time())) {
240 15
                    if (!@unlink($fullPath)) {
241
                        $error = error_get_last();
242
                        throw new CacheException("Unable to remove file '{$fullPath}': {$error['message']}");
243
                    }
244
                }
245
            }
246 19
            closedir($handle);
247
        }
248
    }
249
250 19
    private function createDirectory(string $path, int $mode): bool
251
    {
252 19
        return is_dir($path) || (mkdir($path, $mode, true) && is_dir($path));
253
    }
254
255
    /**
256
     * @param int $directoryLevel the level of sub-directories to store cache files. Defaults to 1.
257
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
258
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
259
     * is not over burdened with a single directory having too many files.
260
     *
261
     */
262
    public function setDirectoryLevel(int $directoryLevel): void
263
    {
264
        $this->directoryLevel = $directoryLevel;
265
    }
266
}
267