Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
81.65% |
89 / 109 |
|
33.33% |
5 / 15 |
CRAP | |
0.00% |
0 / 1 |
| Filesystem_Utils | |
81.65% |
89 / 109 |
|
33.33% |
5 / 15 |
57.51 | |
0.00% |
0 / 1 |
| iterate_directory | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| iterate_files | |
36.36% |
4 / 11 |
|
0.00% |
0 / 1 |
5.32 | |||
| delete_directory | |
93.33% |
28 / 30 |
|
0.00% |
0 / 1 |
13.05 | |||
| validate_path | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
| is_boost_cache_directory | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| get_request_filename | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| is_rebuild_file | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| create_directory | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| create_empty_index_files | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| rebuild_file | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| restore_file | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| delete_file | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| delete_empty_dir | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| is_dir_empty | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| write_to_file | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress; |
| 4 | |
| 5 | use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Path_Action; |
| 6 | use SplFileInfo; |
| 7 | |
| 8 | class Filesystem_Utils { |
| 9 | |
| 10 | const DELETE_ALL = 'delete-all'; // delete all files and directories in a given directory, recursively. |
| 11 | const DELETE_FILE = 'delete-single'; // delete a single file or recursively delete a single directory in a given directory. |
| 12 | const DELETE_FILES = 'delete-files'; // delete all files in a given directory. |
| 13 | const REBUILD_ALL = 'rebuild-all'; // rebuild all files and directories in a given directory, recursively. |
| 14 | const REBUILD_FILE = 'rebuild-single'; // rebuild a single file or recursively rebuild a single directory in a given directory. |
| 15 | const REBUILD_FILES = 'rebuild-files'; // rebuild all files in a given directory. |
| 16 | const REBUILD = 'rebuild'; // rebuild mode for managing expired files |
| 17 | const DELETE = 'delete'; // delete mode for managing expired files |
| 18 | const REBUILD_FILE_EXTENSION = '.rebuild.html'; // The extension used for rebuilt files. |
| 19 | |
| 20 | /** |
| 21 | * Iterate over a directory and apply an action to each file. |
| 22 | * |
| 23 | * This applies the action to all files and subdirectories in the given directory. |
| 24 | * |
| 25 | * @param string $path - The directory to iterate over. |
| 26 | * @param Path_Action $action - The action to apply to each file. |
| 27 | * @return int|Boost_Cache_Error - The number of files processed, or Boost_Cache_Error on failure. |
| 28 | */ |
| 29 | public static function iterate_directory( $path, Path_Action $action ) { |
| 30 | clearstatcache(); |
| 31 | $validation_error = self::validate_path( $path ); |
| 32 | if ( $validation_error instanceof Boost_Cache_Error ) { |
| 33 | return $validation_error; |
| 34 | } |
| 35 | |
| 36 | $iterator = new \RecursiveIteratorIterator( |
| 37 | new \RecursiveDirectoryIterator( $path, \RecursiveDirectoryIterator::SKIP_DOTS ), |
| 38 | \RecursiveIteratorIterator::CHILD_FIRST |
| 39 | ); |
| 40 | |
| 41 | $count = 0; |
| 42 | foreach ( $iterator as $file ) { |
| 43 | $count += $action->apply_to_path( new SplFileInfo( $file ) ); |
| 44 | } |
| 45 | |
| 46 | $count += $action->apply_to_path( new SplFileInfo( $path ) ); |
| 47 | |
| 48 | return $count; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Iterate over a directory and apply an action to each file. |
| 53 | * |
| 54 | * This applies the action to all files in the directory, except index.html. And doesn't go into subdirectories. |
| 55 | * |
| 56 | * @param string $path - The directory to iterate over. |
| 57 | * @param Path_Action $action - The action to apply to each file. |
| 58 | * @return int|Boost_Cache_Error - The number of files processed, or Boost_Cache_Error on failure. |
| 59 | */ |
| 60 | public static function iterate_files( $path, Path_Action $action ) { |
| 61 | clearstatcache(); |
| 62 | $validation_error = self::validate_path( $path ); |
| 63 | if ( $validation_error instanceof Boost_Cache_Error ) { |
| 64 | return $validation_error; |
| 65 | } |
| 66 | |
| 67 | $path = Boost_Cache_Utils::trailingslashit( $path ); |
| 68 | // Files to delete are all files in the given directory, except index.html. index.html is used to prevent directory listing. |
| 69 | $files = array_diff( scandir( $path ), array( '.', '..', 'index.html' ) ); |
| 70 | $count = 0; |
| 71 | foreach ( $files as $file ) { |
| 72 | $fileinfo = new SplFileInfo( $path . $file ); |
| 73 | $count += (int) $action->apply_to_path( $fileinfo ); |
| 74 | } |
| 75 | |
| 76 | return $count; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Recursively delete a directory and everything in it, including cache files, |
| 81 | * index.html placeholder files, subdirectories and the directory itself. |
| 82 | * |
| 83 | * Unlike iterate_directory() with a Simple_Delete action, this does not keep |
| 84 | * index.html placeholder files, does not log each deletion, and removes each |
| 85 | * entry as the iterator visits it instead of building a file list in memory, |
| 86 | * so it stays time- and memory-efficient even for very large caches. Used to |
| 87 | * completely remove the boost-cache directory when the plugin is uninstalled. |
| 88 | * |
| 89 | * @param string $path - The directory to delete. |
| 90 | * @return bool|Boost_Cache_Error - True on success (or if the directory is already gone), Boost_Cache_Error on failure. |
| 91 | */ |
| 92 | public static function delete_directory( $path ) { |
| 93 | clearstatcache(); |
| 94 | |
| 95 | // Strip a trailing slash so the is_link() guard below sees the link itself. |
| 96 | // is_link( 'foo/' ) is false on POSIX, which would let a trailing-slash path |
| 97 | // slip past the symlink-root check; rtrim() closes that for this public, |
| 98 | // destructive primitive even though the current caller passes no slash. |
| 99 | $path = rtrim( $path, '/' ); |
| 100 | |
| 101 | // Refuse to follow a symlinked cache root. realpath() resolves a symlink |
| 102 | // to its target, so a boost-cache symlink pointing outside wp-content would |
| 103 | // resolve identically to $cache_root below and pass the containment check, |
| 104 | // causing the target tree to be deleted. Boost never creates boost-cache as |
| 105 | // a symlink, so a symlinked root is unexpected and we refuse it outright. |
| 106 | // This is checked on the literal $path, not the resolved target, and only |
| 107 | // guards the root itself; symlinks encountered inside the tree are unlinked |
| 108 | // (never followed) by the deletion loop below. |
| 109 | if ( is_link( $path ) ) { |
| 110 | return new Boost_Cache_Error( 'invalid-directory', 'Refusing to delete a symlinked directory: ' . $path ); |
| 111 | } |
| 112 | |
| 113 | $resolved = realpath( $path ); |
| 114 | if ( false === $resolved ) { |
| 115 | // Nothing to delete if the directory is already gone. |
| 116 | return true; |
| 117 | } |
| 118 | |
| 119 | // Strict containment check. is_boost_cache_directory() only does a substring |
| 120 | // match, which would also accept sibling paths like boost-cache-old; since |
| 121 | // this helper deletes whole trees during uninstall, only the cache root |
| 122 | // itself or paths inside it are accepted, compared on resolved paths. |
| 123 | $cache_root = realpath( WP_CONTENT_DIR . '/boost-cache' ); |
| 124 | if ( false === $cache_root || ( $resolved !== $cache_root && strpos( $resolved, $cache_root . '/' ) !== 0 ) ) { |
| 125 | return new Boost_Cache_Error( 'invalid-directory', 'Invalid directory ' . $path ); |
| 126 | } |
| 127 | |
| 128 | if ( ! is_dir( $resolved ) ) { |
| 129 | return new Boost_Cache_Error( 'not-a-directory', 'Not a directory' ); |
| 130 | } |
| 131 | |
| 132 | // Deleting a large cache can take a while; try not to time out half-way through. |
| 133 | if ( function_exists( 'set_time_limit' ) ) { |
| 134 | @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 135 | } |
| 136 | |
| 137 | try { |
| 138 | // CATCH_GET_CHILD keeps the walk best-effort: an unreadable subdirectory |
| 139 | // is skipped instead of throwing and aborting the whole cleanup, so the |
| 140 | // rest of the tree is still deleted. Anything left behind is reported by |
| 141 | // the final is_dir() re-check below. |
| 142 | $iterator = new \RecursiveIteratorIterator( |
| 143 | new \RecursiveDirectoryIterator( $resolved, \RecursiveDirectoryIterator::SKIP_DOTS ), |
| 144 | \RecursiveIteratorIterator::CHILD_FIRST, |
| 145 | \RecursiveIteratorIterator::CATCH_GET_CHILD |
| 146 | ); |
| 147 | |
| 148 | // Errors for individual entries are suppressed so a single failure doesn't abort the cleanup. |
| 149 | foreach ( $iterator as $file ) { |
| 150 | if ( $file->isDir() && ! $file->isLink() ) { |
| 151 | @rmdir( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged |
| 152 | } else { |
| 153 | @unlink( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged |
| 154 | } |
| 155 | } |
| 156 | } catch ( \Throwable $e ) { |
| 157 | // The iterator itself can throw (e.g. an unreadable subdirectory). |
| 158 | // Uninstall cleanup must fail with a controlled error, not an |
| 159 | // uncaught exception. |
| 160 | return new Boost_Cache_Error( 'could-not-delete-directory', 'Could not completely delete directory: ' . $e->getMessage() ); |
| 161 | } |
| 162 | |
| 163 | @rmdir( $resolved ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged |
| 164 | |
| 165 | // Re-check against the filesystem, not a stale stat cache, so a successful |
| 166 | // removal is not misreported as a failure. |
| 167 | clearstatcache(); |
| 168 | if ( is_dir( $resolved ) ) { |
| 169 | return new Boost_Cache_Error( 'could-not-delete-directory', 'Could not completely delete directory: ' . $path ); |
| 170 | } |
| 171 | |
| 172 | return true; |
| 173 | } |
| 174 | |
| 175 | private static function validate_path( $path ) { |
| 176 | $path = realpath( $path ); |
| 177 | if ( ! $path ) { |
| 178 | // translators: %s is the directory that does not exist. |
| 179 | return new Boost_Cache_Error( 'directory-missing', 'Directory does not exist: ' . $path ); // realpath returns false if a file does not exist. |
| 180 | } |
| 181 | |
| 182 | // make sure that $dir is a directory inside WP_CONTENT . '/boost-cache/'; |
| 183 | if ( self::is_boost_cache_directory( $path ) === false ) { |
| 184 | // translators: %s is the directory that is invalid. |
| 185 | return new Boost_Cache_Error( 'invalid-directory', 'Invalid directory %s' . $path ); |
| 186 | } |
| 187 | |
| 188 | if ( ! is_dir( $path ) ) { |
| 189 | return new Boost_Cache_Error( 'not-a-directory', 'Not a directory' ); |
| 190 | } |
| 191 | |
| 192 | return true; |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * Returns true if the given directory is inside the boost-cache directory. |
| 197 | * |
| 198 | * @param string $dir - The directory to check. |
| 199 | * @return bool |
| 200 | */ |
| 201 | public static function is_boost_cache_directory( $dir ) { |
| 202 | $dir = Boost_Cache_Utils::sanitize_file_path( $dir ); |
| 203 | return strpos( $dir, WP_CONTENT_DIR . '/boost-cache' ) !== false; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Given a request_uri and its parameters, return the filename to use for this cached data. Does not include the file path. |
| 208 | * |
| 209 | * @param array $parameters - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally. |
| 210 | */ |
| 211 | public static function get_request_filename( $parameters ) { |
| 212 | |
| 213 | /** |
| 214 | * Filters the components used to generate the cache key. |
| 215 | * |
| 216 | * @param array $parameters The array of components, url, cookies, get parameters, etc. |
| 217 | * |
| 218 | * @since 1.0.0 |
| 219 | * @deprecated 3.8.0 |
| 220 | */ |
| 221 | $key_components = apply_filters_deprecated( 'boost_cache_key_components', array( $parameters ), '3.8.0', 'jetpack_boost_cache_parameters' ); |
| 222 | |
| 223 | return md5( |
| 224 | json_encode( // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode |
| 225 | $key_components, |
| 226 | 0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because this needs to match whatever is calculating the hash on the other end. |
| 227 | ) |
| 228 | ) . '.html'; |
| 229 | } |
| 230 | |
| 231 | /** |
| 232 | * Check if a file is a rebuild file. |
| 233 | * |
| 234 | * @param string $file - The file to check. |
| 235 | * @return bool - True if the file is a rebuild file, false otherwise. |
| 236 | */ |
| 237 | public static function is_rebuild_file( $file ) { |
| 238 | return substr( $file, -strlen( self::REBUILD_FILE_EXTENSION ) ) === self::REBUILD_FILE_EXTENSION; |
| 239 | } |
| 240 | |
| 241 | /** |
| 242 | * Creates the directory if it doesn't exist. |
| 243 | * |
| 244 | * @param string $path - The path to the directory to create. |
| 245 | */ |
| 246 | public static function create_directory( $path ) { |
| 247 | if ( ! is_dir( $path ) ) { |
| 248 | // phpcs:ignore WordPress.WP.AlternativeFunctions.dir_mkdir_dirname, WordPress.WP.AlternativeFunctions.file_system_operations_mkdir, WordPress.PHP.NoSilencedErrors.Discouraged |
| 249 | $dir_created = @mkdir( $path, 0755, true ); |
| 250 | |
| 251 | if ( $dir_created ) { |
| 252 | self::create_empty_index_files( $path ); |
| 253 | } |
| 254 | |
| 255 | return $dir_created; |
| 256 | } |
| 257 | |
| 258 | return true; |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * Create an empty index.html file in the given directory. |
| 263 | * This is done to prevent directory listing. |
| 264 | */ |
| 265 | private static function create_empty_index_files( $path ) { |
| 266 | if ( self::is_boost_cache_directory( $path ) ) { |
| 267 | self::write_to_file( $path . '/index.html', '' ); |
| 268 | |
| 269 | // Create an empty index.html file in the parent directory as well. |
| 270 | self::create_empty_index_files( dirname( $path ) ); |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | /** |
| 275 | * Rebuild a file. Make a copy of the file with a different extension instead of deleting it. |
| 276 | * |
| 277 | * @param string $file_path - The file to rebuild. |
| 278 | * @return bool - True if the file was rebuilt, false otherwise. |
| 279 | */ |
| 280 | public static function rebuild_file( $file_path ) { |
| 281 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable |
| 282 | if ( is_writable( $file_path ) ) { |
| 283 | // only rename the file if it is not already a rebuild file. |
| 284 | if ( ! self::is_rebuild_file( $file_path ) ) { |
| 285 | // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged |
| 286 | @rename( $file_path, $file_path . self::REBUILD_FILE_EXTENSION ); |
| 287 | @touch( $file_path . self::REBUILD_FILE_EXTENSION ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged |
| 288 | return true; |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | return false; |
| 293 | } |
| 294 | |
| 295 | /** |
| 296 | * Restore a file that was rebuilt so the cache file can be used for other visitors. |
| 297 | * |
| 298 | * @param string $file_path - The rebuilt file |
| 299 | * @return bool - True if the file was restored, false otherwise. |
| 300 | */ |
| 301 | public static function restore_file( $file_path ) { |
| 302 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable |
| 303 | if ( is_writable( $file_path ) ) { |
| 304 | // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged |
| 305 | return @rename( $file_path, str_replace( self::REBUILD_FILE_EXTENSION, '', $file_path ) ); |
| 306 | } |
| 307 | |
| 308 | return false; |
| 309 | } |
| 310 | |
| 311 | /** |
| 312 | * Delete a file. |
| 313 | * |
| 314 | * @param string $file_path - The file to delete. |
| 315 | * @return bool - True if the file was deleted, false otherwise. |
| 316 | */ |
| 317 | public static function delete_file( $file_path ) { |
| 318 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable |
| 319 | $deletable = is_writable( $file_path ); |
| 320 | |
| 321 | if ( $deletable ) { |
| 322 | // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged |
| 323 | return @unlink( $file_path ); |
| 324 | } |
| 325 | |
| 326 | return false; |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * Delete an empty cache directory. |
| 331 | * |
| 332 | * @param string $dir - The directory to delete. |
| 333 | * @return int - 1 if the directory was deleted, 0 otherwise. |
| 334 | * |
| 335 | * This function will delete the index.html file and the directory itself. |
| 336 | */ |
| 337 | public static function delete_empty_dir( $dir ) { |
| 338 | if ( self::is_dir_empty( $dir ) ) { |
| 339 | @unlink( $dir . '/index.html' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged |
| 340 | @rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged |
| 341 | return 1; |
| 342 | } |
| 343 | return 0; |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * Check if a directory is empty. |
| 348 | * |
| 349 | * @param string $dir - The directory to check. |
| 350 | */ |
| 351 | public static function is_dir_empty( $dir ) { |
| 352 | if ( ! is_readable( $dir ) ) { |
| 353 | return new Boost_Cache_Error( 'directory_not_readable', 'Directory is not readable' ); |
| 354 | } |
| 355 | |
| 356 | $files = array_diff( scandir( $dir ), array( '.', '..', 'index.html' ) ); |
| 357 | return empty( $files ); |
| 358 | } |
| 359 | |
| 360 | /** |
| 361 | * Writes data to a file. |
| 362 | * This creates a temporary file first, then renames the file to the final filename. |
| 363 | * This is done to prevent the file from being read while it is being written to. |
| 364 | * |
| 365 | * @param string $filename - The filename to write to. |
| 366 | * @param string $data - The data to write to the file. |
| 367 | * @return bool|Boost_Cache_Error - true on sucess or Boost_Cache_Error on failure. |
| 368 | */ |
| 369 | public static function write_to_file( $filename, $data ) { |
| 370 | $tmp_filename = $filename . uniqid( uniqid(), true ) . '.tmp'; |
| 371 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged |
| 372 | if ( false === @file_put_contents( $tmp_filename, $data ) ) { |
| 373 | return new Boost_Cache_Error( 'could-not-write', 'Could not write to tmp file: ' . $tmp_filename ); |
| 374 | } |
| 375 | |
| 376 | // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename |
| 377 | if ( ! rename( $tmp_filename, $filename ) ) { |
| 378 | return new Boost_Cache_Error( 'could-not-rename', 'Could not rename tmp file to final file: ' . $filename ); |
| 379 | } |
| 380 | return true; |
| 381 | } |
| 382 | } |