Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.22% covered (warning)
77.22%
61 / 79
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Filesystem_Utils
77.22% covered (warning)
77.22%
61 / 79
35.71% covered (danger)
35.71%
5 / 14
44.11
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
 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    private static function validate_path( $path ) {
80        $path = realpath( $path );
81        if ( ! $path ) {
82            // translators: %s is the directory that does not exist.
83            return new Boost_Cache_Error( 'directory-missing', 'Directory does not exist: ' . $path ); // realpath returns false if a file does not exist.
84        }
85
86        // make sure that $dir is a directory inside WP_CONTENT . '/boost-cache/';
87        if ( self::is_boost_cache_directory( $path ) === false ) {
88            // translators: %s is the directory that is invalid.
89            return new Boost_Cache_Error( 'invalid-directory', 'Invalid directory %s' . $path );
90        }
91
92        if ( ! is_dir( $path ) ) {
93            return new Boost_Cache_Error( 'not-a-directory', 'Not a directory' );
94        }
95
96        return true;
97    }
98
99    /**
100     * Returns true if the given directory is inside the boost-cache directory.
101     *
102     * @param string $dir - The directory to check.
103     * @return bool
104     */
105    public static function is_boost_cache_directory( $dir ) {
106        $dir = Boost_Cache_Utils::sanitize_file_path( $dir );
107        return strpos( $dir, WP_CONTENT_DIR . '/boost-cache' ) !== false;
108    }
109
110    /**
111     * Given a request_uri and its parameters, return the filename to use for this cached data. Does not include the file path.
112     *
113     * @param array $parameters  - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally.
114     */
115    public static function get_request_filename( $parameters ) {
116
117        /**
118         * Filters the components used to generate the cache key.
119         *
120         * @param array $parameters The array of components, url, cookies, get parameters, etc.
121         *
122         * @since   1.0.0
123         * @deprecated 3.8.0
124         */
125        $key_components = apply_filters_deprecated( 'boost_cache_key_components', array( $parameters ), '3.8.0', 'jetpack_boost_cache_parameters' );
126
127        return md5(
128            json_encode( // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
129                $key_components,
130                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.
131            )
132        ) . '.html';
133    }
134
135    /**
136     * Check if a file is a rebuild file.
137     *
138     * @param string $file - The file to check.
139     * @return bool - True if the file is a rebuild file, false otherwise.
140     */
141    public static function is_rebuild_file( $file ) {
142        return substr( $file, -strlen( self::REBUILD_FILE_EXTENSION ) ) === self::REBUILD_FILE_EXTENSION;
143    }
144
145    /**
146     * Creates the directory if it doesn't exist.
147     *
148     * @param string $path - The path to the directory to create.
149     */
150    public static function create_directory( $path ) {
151        if ( ! is_dir( $path ) ) {
152            // phpcs:ignore WordPress.WP.AlternativeFunctions.dir_mkdir_dirname, WordPress.WP.AlternativeFunctions.file_system_operations_mkdir, WordPress.PHP.NoSilencedErrors.Discouraged
153            $dir_created = @mkdir( $path, 0755, true );
154
155            if ( $dir_created ) {
156                self::create_empty_index_files( $path );
157            }
158
159            return $dir_created;
160        }
161
162        return true;
163    }
164
165    /**
166     * Create an empty index.html file in the given directory.
167     * This is done to prevent directory listing.
168     */
169    private static function create_empty_index_files( $path ) {
170        if ( self::is_boost_cache_directory( $path ) ) {
171            self::write_to_file( $path . '/index.html', '' );
172
173            // Create an empty index.html file in the parent directory as well.
174            self::create_empty_index_files( dirname( $path ) );
175        }
176    }
177
178    /**
179     * Rebuild a file. Make a copy of the file with a different extension instead of deleting it.
180     *
181     * @param string $file_path - The file to rebuild.
182     * @return bool - True if the file was rebuilt, false otherwise.
183     */
184    public static function rebuild_file( $file_path ) {
185        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
186        if ( is_writable( $file_path ) ) {
187            // only rename the file if it is not already a rebuild file.
188            if ( ! self::is_rebuild_file( $file_path ) ) {
189                // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged
190                @rename( $file_path, $file_path . self::REBUILD_FILE_EXTENSION );
191                @touch( $file_path . self::REBUILD_FILE_EXTENSION ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged
192                return true;
193            }
194        }
195
196        return false;
197    }
198
199    /**
200     * Restore a file that was rebuilt so the cache file can be used for other visitors.
201     *
202     * @param string $file_path - The rebuilt file
203     * @return bool - True if the file was restored, false otherwise.
204     */
205    public static function restore_file( $file_path ) {
206        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
207        if ( is_writable( $file_path ) ) {
208            // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged
209            return @rename( $file_path, str_replace( self::REBUILD_FILE_EXTENSION, '', $file_path ) );
210        }
211
212        return false;
213    }
214
215    /**
216     * Delete a file.
217     *
218     * @param string $file_path - The file to delete.
219     * @return bool - True if the file was deleted, false otherwise.
220     */
221    public static function delete_file( $file_path ) {
222        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
223        $deletable = is_writable( $file_path );
224
225        if ( $deletable ) {
226            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
227            return @unlink( $file_path );
228        }
229
230        return false;
231    }
232
233    /**
234     * Delete an empty cache directory.
235     *
236     * @param string $dir - The directory to delete.
237     * @return int - 1 if the directory was deleted, 0 otherwise.
238     *
239     * This function will delete the index.html file and the directory itself.
240     */
241    public static function delete_empty_dir( $dir ) {
242        if ( self::is_dir_empty( $dir ) ) {
243            @unlink( $dir . '/index.html' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
244            @rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged
245            return 1;
246        }
247        return 0;
248    }
249
250    /**
251     * Check if a directory is empty.
252     *
253     * @param string $dir - The directory to check.
254     */
255    public static function is_dir_empty( $dir ) {
256        if ( ! is_readable( $dir ) ) {
257            return new Boost_Cache_Error( 'directory_not_readable', 'Directory is not readable' );
258        }
259
260        $files = array_diff( scandir( $dir ), array( '.', '..', 'index.html' ) );
261        return empty( $files );
262    }
263
264    /**
265     * Writes data to a file.
266     * This creates a temporary file first, then renames the file to the final filename.
267     * This is done to prevent the file from being read while it is being written to.
268     *
269     * @param string $filename - The filename to write to.
270     * @param string $data - The data to write to the file.
271     * @return bool|Boost_Cache_Error - true on sucess or Boost_Cache_Error on failure.
272     */
273    public static function write_to_file( $filename, $data ) {
274        $tmp_filename = $filename . uniqid( uniqid(), true ) . '.tmp';
275        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged
276        if ( false === @file_put_contents( $tmp_filename, $data ) ) {
277            return new Boost_Cache_Error( 'could-not-write', 'Could not write to tmp file: ' . $tmp_filename );
278        }
279
280        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
281        if ( ! rename( $tmp_filename, $filename ) ) {
282            return new Boost_Cache_Error( 'could-not-rename', 'Could not rename tmp file to final file: ' . $filename );
283        }
284        return true;
285    }
286}