Passed
Push — master ( 599ef6...524477 )
by Alexander
01:29
created

FileCache::setDirMode()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
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 87
    public function __construct(string $cachePath, ?SerializerInterface $serializer = null)
67
    {
68 87
        $this->cachePath = $cachePath;
69 87
        $this->serializer = $serializer ?? new PhpSerializer();
70 87
        $this->initCacheDirectory();
71 87
    }
72
73 67
    public function get($key, $default = null)
74
    {
75 67
        if ($this->existsAndNotExpired($key)) {
76 56
            $fp = @fopen($this->getCacheFile($key), 'rb');
77 56
            if ($fp !== false) {
78 56
                @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

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

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

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

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