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