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