Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.65% covered (warning)
81.65%
89 / 109
33.33% covered (danger)
33.33%
5 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Filesystem_Utils
81.65% covered (warning)
81.65%
89 / 109
33.33% covered (danger)
33.33%
5 / 15
57.51
0.00% covered (danger)
0.00%
0 / 1
 iterate_directory
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 iterate_files
36.36% covered (danger)
36.36%
4 / 11
0.00% covered (danger)
0.00%
0 / 1
5.32
 delete_directory
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
13.05
 validate_path
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 is_boost_cache_directory
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_request_filename
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 is_rebuild_file
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create_directory
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 create_empty_index_files
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 rebuild_file
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 restore_file
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 delete_file
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 delete_empty_dir
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 is_dir_empty
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 write_to_file
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2
3namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
4
5use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Path_Action;
6use SplFileInfo;
7
8class 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}