1<?php
2
3namespace Doctrine\Common\Cache;
4
5use FilesystemIterator;
6use InvalidArgumentException;
7use Iterator;
8use RecursiveDirectoryIterator;
9use RecursiveIteratorIterator;
10use const DIRECTORY_SEPARATOR;
11use const PATHINFO_DIRNAME;
12use function bin2hex;
13use function chmod;
14use function defined;
15use function disk_free_space;
16use function file_exists;
17use function file_put_contents;
18use function gettype;
19use function hash;
20use function is_dir;
21use function is_int;
22use function is_writable;
23use function mkdir;
24use function pathinfo;
25use function realpath;
26use function rename;
27use function rmdir;
28use function sprintf;
29use function strlen;
30use function strrpos;
31use function substr;
32use function tempnam;
33use function unlink;
34
35/**
36 * Base file cache driver.
37 */
38abstract class FileCache extends CacheProvider
39{
40    /**
41     * The cache directory.
42     *
43     * @var string
44     */
45    protected $directory;
46
47    /**
48     * The cache file extension.
49     *
50     * @var string
51     */
52    private $extension;
53
54    /** @var int */
55    private $umask;
56
57    /** @var int */
58    private $directoryStringLength;
59
60    /** @var int */
61    private $extensionStringLength;
62
63    /** @var bool */
64    private $isRunningOnWindows;
65
66    /**
67     * @param string $directory The cache directory.
68     * @param string $extension The cache file extension.
69     *
70     * @throws InvalidArgumentException
71     */
72    public function __construct($directory, $extension = '', $umask = 0002)
73    {
74        // YES, this needs to be *before* createPathIfNeeded()
75        if (! is_int($umask)) {
76            throw new InvalidArgumentException(sprintf(
77                'The umask parameter is required to be integer, was: %s',
78                gettype($umask)
79            ));
80        }
81        $this->umask = $umask;
82
83        if (! $this->createPathIfNeeded($directory)) {
84            throw new InvalidArgumentException(sprintf(
85                'The directory "%s" does not exist and could not be created.',
86                $directory
87            ));
88        }
89
90        if (! is_writable($directory)) {
91            throw new InvalidArgumentException(sprintf(
92                'The directory "%s" is not writable.',
93                $directory
94            ));
95        }
96
97        // YES, this needs to be *after* createPathIfNeeded()
98        $this->directory = realpath($directory);
99        $this->extension = (string) $extension;
100
101        $this->directoryStringLength = strlen($this->directory);
102        $this->extensionStringLength = strlen($this->extension);
103        $this->isRunningOnWindows    = defined('PHP_WINDOWS_VERSION_BUILD');
104    }
105
106    /**
107     * Gets the cache directory.
108     *
109     * @return string
110     */
111    public function getDirectory()
112    {
113        return $this->directory;
114    }
115
116    /**
117     * Gets the cache file extension.
118     *
119     * @return string
120     */
121    public function getExtension()
122    {
123        return $this->extension;
124    }
125
126    /**
127     * @param string $id
128     *
129     * @return string
130     */
131    protected function getFilename($id)
132    {
133        $hash = hash('sha256', $id);
134
135        // This ensures that the filename is unique and that there are no invalid chars in it.
136        if ($id === ''
137            || ((strlen($id) * 2 + $this->extensionStringLength) > 255)
138            || ($this->isRunningOnWindows && ($this->directoryStringLength + 4 + strlen($id) * 2 + $this->extensionStringLength) > 258)
139        ) {
140            // Most filesystems have a limit of 255 chars for each path component. On Windows the the whole path is limited
141            // to 260 chars (including terminating null char). Using long UNC ("\\?\" prefix) does not work with the PHP API.
142            // And there is a bug in PHP (https://bugs.php.net/bug.php?id=70943) with path lengths of 259.
143            // So if the id in hex representation would surpass the limit, we use the hash instead. The prefix prevents
144            // collisions between the hash and bin2hex.
145            $filename = '_' . $hash;
146        } else {
147            $filename = bin2hex($id);
148        }
149
150        return $this->directory
151            . DIRECTORY_SEPARATOR
152            . substr($hash, 0, 2)
153            . DIRECTORY_SEPARATOR
154            . $filename
155            . $this->extension;
156    }
157
158    /**
159     * {@inheritdoc}
160     */
161    protected function doDelete($id)
162    {
163        $filename = $this->getFilename($id);
164
165        return @unlink($filename) || ! file_exists($filename);
166    }
167
168    /**
169     * {@inheritdoc}
170     */
171    protected function doFlush()
172    {
173        foreach ($this->getIterator() as $name => $file) {
174            if ($file->isDir()) {
175                // Remove the intermediate directories which have been created to balance the tree. It only takes effect
176                // if the directory is empty. If several caches share the same directory but with different file extensions,
177                // the other ones are not removed.
178                @rmdir($name);
179            } elseif ($this->isFilenameEndingWithExtension($name)) {
180                // If an extension is set, only remove files which end with the given extension.
181                // If no extension is set, we have no other choice than removing everything.
182                @unlink($name);
183            }
184        }
185
186        return true;
187    }
188
189    /**
190     * {@inheritdoc}
191     */
192    protected function doGetStats()
193    {
194        $usage = 0;
195        foreach ($this->getIterator() as $name => $file) {
196            if ($file->isDir() || ! $this->isFilenameEndingWithExtension($name)) {
197                continue;
198            }
199
200            $usage += $file->getSize();
201        }
202
203        $free = disk_free_space($this->directory);
204
205        return [
206            Cache::STATS_HITS               => null,
207            Cache::STATS_MISSES             => null,
208            Cache::STATS_UPTIME             => null,
209            Cache::STATS_MEMORY_USAGE       => $usage,
210            Cache::STATS_MEMORY_AVAILABLE   => $free,
211        ];
212    }
213
214    /**
215     * Create path if needed.
216     *
217     * @return bool TRUE on success or if path already exists, FALSE if path cannot be created.
218     */
219    private function createPathIfNeeded(string $path) : bool
220    {
221        if (! is_dir($path)) {
222            if (@mkdir($path, 0777 & (~$this->umask), true) === false && ! is_dir($path)) {
223                return false;
224            }
225        }
226
227        return true;
228    }
229
230    /**
231     * Writes a string content to file in an atomic way.
232     *
233     * @param string $filename Path to the file where to write the data.
234     * @param string $content  The content to write
235     *
236     * @return bool TRUE on success, FALSE if path cannot be created, if path is not writable or an any other error.
237     */
238    protected function writeFile(string $filename, string $content) : bool
239    {
240        $filepath = pathinfo($filename, PATHINFO_DIRNAME);
241
242        if (! $this->createPathIfNeeded($filepath)) {
243            return false;
244        }
245
246        if (! is_writable($filepath)) {
247            return false;
248        }
249
250        $tmpFile = tempnam($filepath, 'swap');
251        @chmod($tmpFile, 0666 & (~$this->umask));
252
253        if (file_put_contents($tmpFile, $content) !== false) {
254            @chmod($tmpFile, 0666 & (~$this->umask));
255            if (@rename($tmpFile, $filename)) {
256                return true;
257            }
258
259            @unlink($tmpFile);
260        }
261
262        return false;
263    }
264
265    private function getIterator() : Iterator
266    {
267        return new RecursiveIteratorIterator(
268            new RecursiveDirectoryIterator($this->directory, FilesystemIterator::SKIP_DOTS),
269            RecursiveIteratorIterator::CHILD_FIRST
270        );
271    }
272
273    /**
274     * @param string $name The filename
275     */
276    private function isFilenameEndingWithExtension(string $name) : bool
277    {
278        return $this->extension === ''
279            || strrpos($name, $this->extension) === (strlen($name) - $this->extensionStringLength);
280    }
281}
282