1<?php
2/*
3 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
4 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
5 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
6 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
7 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
8 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
9 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
10 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
11 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
12 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
13 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
14 *
15 * This software consists of voluntary contributions made by many individuals
16 * and is licensed under the MIT license. For more information, see
17 * <http://www.doctrine-project.org>.
18 */
19
20namespace Doctrine\Common\Cache;
21
22/**
23 * Base file cache driver.
24 *
25 * @since  2.3
26 * @author Fabio B. Silva <fabio.bat.silva@gmail.com>
27 * @author Tobias Schultze <http://tobion.de>
28 */
29abstract class FileCache extends CacheProvider
30{
31    /**
32     * The cache directory.
33     *
34     * @var string
35     */
36    protected $directory;
37
38    /**
39     * The cache file extension.
40     *
41     * @var string
42     */
43    private $extension;
44
45    /**
46     * @var int
47     */
48    private $umask;
49
50    /**
51     * @var int
52     */
53    private $directoryStringLength;
54
55    /**
56     * @var int
57     */
58    private $extensionStringLength;
59
60    /**
61     * @var bool
62     */
63    private $isRunningOnWindows;
64
65    /**
66     * Constructor.
67     *
68     * @param string $directory The cache directory.
69     * @param string $extension The cache file extension.
70     *
71     * @throws \InvalidArgumentException
72     */
73    public function __construct($directory, $extension = '', $umask = 0002)
74    {
75        // YES, this needs to be *before* createPathIfNeeded()
76        if ( ! is_int($umask)) {
77            throw new \InvalidArgumentException(sprintf(
78                'The umask parameter is required to be integer, was: %s',
79                gettype($umask)
80            ));
81        }
82        $this->umask = $umask;
83
84        if ( ! $this->createPathIfNeeded($directory)) {
85            throw new \InvalidArgumentException(sprintf(
86                'The directory "%s" does not exist and could not be created.',
87                $directory
88            ));
89        }
90
91        if ( ! is_writable($directory)) {
92            throw new \InvalidArgumentException(sprintf(
93                'The directory "%s" is not writable.',
94                $directory
95            ));
96        }
97
98        // YES, this needs to be *after* createPathIfNeeded()
99        $this->directory = realpath($directory);
100        $this->extension = (string) $extension;
101
102        $this->directoryStringLength = strlen($this->directory);
103        $this->extensionStringLength = strlen($this->extension);
104        $this->isRunningOnWindows    = defined('PHP_WINDOWS_VERSION_BUILD');
105    }
106
107    /**
108     * Gets the cache directory.
109     *
110     * @return string
111     */
112    public function getDirectory()
113    {
114        return $this->directory;
115    }
116
117    /**
118     * Gets the cache file extension.
119     *
120     * @return string
121     */
122    public function getExtension()
123    {
124        return $this->extension;
125    }
126
127    /**
128     * @param string $id
129     *
130     * @return string
131     */
132    protected function getFilename($id)
133    {
134        $hash = hash('sha256', $id);
135
136        // This ensures that the filename is unique and that there are no invalid chars in it.
137        if (
138            '' === $id
139            || ((strlen($id) * 2 + $this->extensionStringLength) > 255)
140            || ($this->isRunningOnWindows && ($this->directoryStringLength + 4 + strlen($id) * 2 + $this->extensionStringLength) > 258)
141        ) {
142            // Most filesystems have a limit of 255 chars for each path component. On Windows the the whole path is limited
143            // to 260 chars (including terminating null char). Using long UNC ("\\?\" prefix) does not work with the PHP API.
144            // And there is a bug in PHP (https://bugs.php.net/bug.php?id=70943) with path lengths of 259.
145            // So if the id in hex representation would surpass the limit, we use the hash instead. The prefix prevents
146            // collisions between the hash and bin2hex.
147            $filename = '_' . $hash;
148        } else {
149            $filename = bin2hex($id);
150        }
151
152        return $this->directory
153            . DIRECTORY_SEPARATOR
154            . substr($hash, 0, 2)
155            . DIRECTORY_SEPARATOR
156            . $filename
157            . $this->extension;
158    }
159
160    /**
161     * {@inheritdoc}
162     */
163    protected function doDelete($id)
164    {
165        $filename = $this->getFilename($id);
166
167        return @unlink($filename) || ! file_exists($filename);
168    }
169
170    /**
171     * {@inheritdoc}
172     */
173    protected function doFlush()
174    {
175        foreach ($this->getIterator() as $name => $file) {
176            if ($file->isDir()) {
177                // Remove the intermediate directories which have been created to balance the tree. It only takes effect
178                // if the directory is empty. If several caches share the same directory but with different file extensions,
179                // the other ones are not removed.
180                @rmdir($name);
181            } elseif ($this->isFilenameEndingWithExtension($name)) {
182                // If an extension is set, only remove files which end with the given extension.
183                // If no extension is set, we have no other choice than removing everything.
184                @unlink($name);
185            }
186        }
187
188        return true;
189    }
190
191    /**
192     * {@inheritdoc}
193     */
194    protected function doGetStats()
195    {
196        $usage = 0;
197        foreach ($this->getIterator() as $name => $file) {
198            if (! $file->isDir() && $this->isFilenameEndingWithExtension($name)) {
199                $usage += $file->getSize();
200            }
201        }
202
203        $free = disk_free_space($this->directory);
204
205        return array(
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     * @param string $path
218     * @return bool TRUE on success or if path already exists, FALSE if path cannot be created.
219     */
220    private function createPathIfNeeded($path)
221    {
222        if ( ! is_dir($path)) {
223            if (false === @mkdir($path, 0777 & (~$this->umask), true) && !is_dir($path)) {
224                return false;
225            }
226        }
227
228        return true;
229    }
230
231    /**
232     * Writes a string content to file in an atomic way.
233     *
234     * @param string $filename Path to the file where to write the data.
235     * @param string $content  The content to write
236     *
237     * @return bool TRUE on success, FALSE if path cannot be created, if path is not writable or an any other error.
238     */
239    protected function writeFile($filename, $content)
240    {
241        $filepath = pathinfo($filename, PATHINFO_DIRNAME);
242
243        if ( ! $this->createPathIfNeeded($filepath)) {
244            return false;
245        }
246
247        if ( ! is_writable($filepath)) {
248            return false;
249        }
250
251        $tmpFile = tempnam($filepath, 'swap');
252        @chmod($tmpFile, 0666 & (~$this->umask));
253
254        if (file_put_contents($tmpFile, $content) !== false) {
255            if (@rename($tmpFile, $filename)) {
256                return true;
257            }
258
259            @unlink($tmpFile);
260        }
261
262        return false;
263    }
264
265    /**
266     * @return \Iterator
267     */
268    private function getIterator()
269    {
270        return new \RecursiveIteratorIterator(
271            new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS),
272            \RecursiveIteratorIterator::CHILD_FIRST
273        );
274    }
275
276    /**
277     * @param string $name The filename
278     *
279     * @return bool
280     */
281    private function isFilenameEndingWithExtension($name)
282    {
283        return '' === $this->extension
284            || strrpos($name, $this->extension) === (strlen($name) - $this->extensionStringLength);
285    }
286}
287