PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-backup-helper-script-manager / src / class-helper-script-manager-impl.php
jetpack / jetpack_vendor / automattic / jetpack-backup-helper-script-manager / src Last commit date
class-helper-script-manager-impl.php 1 year ago class-helper-script-manager.php 1 year ago class-throw-on-errors.php 8 months ago
class-helper-script-manager-impl.php
621 lines
1 <?php
2 /**
3 * The Jetpack Backup Helper Script Manager class (implementation).
4 *
5 * @package automattic/jetpack-backup
6 */
7
8 // After changing this file, consider increasing the version number ("VXXX") in all the files using this namespace, in
9 // order to ensure that the specific version of this file always get loaded. Otherwise, Jetpack autoloader might decide
10 // to load an older/newer version of the class (if, for example, both the standalone and bundled versions of the plugin
11 // are installed, or in some other cases).
12 namespace Automattic\Jetpack\Backup\V0005;
13
14 use Exception;
15 use WP_Error;
16 use function content_url;
17 use function get_site_url;
18 use function is_wp_error;
19 use function set_url_scheme;
20 use function trailingslashit;
21 use function wp_generate_password;
22 use function wp_http_validate_url;
23 use function wp_schedule_single_event;
24 use function wp_upload_dir;
25 use const ABSPATH;
26 use const WP_CONTENT_DIR;
27
28 /**
29 * Manage installation, deletion and cleanup of Helper Scripts to assist with backing up Jetpack Sites.
30 *
31 * Does *not* use WP_Filesystem, because if there are permissions issues between the webserver's user and the FTP/SSH
32 * user, then we'll just install the helper script and do a backup/restore using FTP/SSH credentials (that we collect
33 * ourselves), without using WP_Filesystem in any way.
34 *
35 * Also, if we can't write that helper script somewhere (due to writes being inaccessible to the webserver's user, or
36 * for other reasons), we want to know about it (in the form of an error response), instead of having that helper
37 * script silently uploaded via FTP/SFTP, so that we could fall back to a backup/restore using credentials.
38 *
39 * Lastly, PHP provides us with better error reporting than WP_Filesystem.
40 */
41 class Helper_Script_Manager_Impl {
42
43 /**
44 * Name of a directory that will be created for storing the helper script.
45 */
46 const TEMP_DIRECTORY = 'jetpack-temp';
47
48 /**
49 * How long until the helper script will "expire" and refuse taking requests, in seconds.
50 */
51 const EXPIRY_TIME = 60 * 60 * 8;
52
53 /**
54 * Maximum size of the helper script, in bytes.
55 */
56 const MAX_FILESIZE = 1024 * 1024;
57
58 /**
59 * Associative array of possible places to install a jetpack-temp directory, along with the URL to access each.
60 *
61 * Keys specify the full path of install locations, and values point to the equivalent URL.
62 *
63 * If null, then install locations will be determined dynamically at the point of an install.
64 *
65 * @var array|null
66 */
67 protected $custom_install_locations;
68
69 /**
70 * Filenames to ignore in scandir()'s return value.
71 *
72 * @var string[]
73 */
74 protected $scandir_ignored_names = array( '.', '..' );
75
76 /**
77 * Header that the helper script is expected to start with.
78 */
79 const HELPER_HEADER = "<?php /* Jetpack Backup Helper Script */\n";
80
81 /**
82 * Lines that will be written to README in the helper directory.
83 */
84 const README_LINES = array(
85 'These files have been put on your server by Jetpack to assist with backups, restores, and scans of your ' .
86 'site content. They are cleaned up automatically when we no longer need them.',
87 'If you no longer have Jetpack connected to your site, you can delete them manually.',
88 'If you have questions or need assistance, please contact Jetpack Support at https://jetpack.com/support/',
89 'If you like to build amazing things with WordPress, you should visit automattic.com/jobs and apply to join ' .
90 'the fun – mention this file when you apply!',
91 );
92
93 /**
94 * Data that will be written to index.php in the helper directory.
95 */
96 const INDEX_FILE = '<?php // Silence is golden';
97
98 /**
99 * Create Helper Script Manager.
100 *
101 * @param array|null $custom_install_locations Associative array of possible places to install a jetpack-temp
102 * directory, along with the URL to access each.
103 */
104 public function __construct( $custom_install_locations = null ) {
105 $this->custom_install_locations = $custom_install_locations;
106 }
107
108 /**
109 * Get either the default install locations, or the ones configured in the constructor.
110 *
111 * Has to be done late, i.e. can't be done in constructor, because in __construct() not all constants / functions
112 * might be available.
113 *
114 * @return array<string, string|WP_Error> Array with keys specifying the full path of install locations, and values
115 * either pointing to the equivalent URL, or being WP_Error if a specific path is not accessible.
116 */
117 public function install_locations() {
118 if ( $this->custom_install_locations !== null ) {
119 return $this->custom_install_locations;
120 }
121
122 $abspath_url = get_site_url();
123
124 $locations = array();
125
126 // Prioritize ABSPATH first, because even though ABSPATH constant's value might be weird sometimes, it's the
127 // path where the PHP scripts will be most likely be able to get executed.
128
129 try {
130 if ( Throw_On_Errors::t_is_dir( ABSPATH ) ) {
131 $abspath_dir = Throw_On_Errors::t_realpath( ABSPATH );
132 $locations[ $abspath_dir ] = $abspath_url;
133 }
134 } catch ( Exception $exception ) {
135 $locations[ ABSPATH ] = new WP_Error(
136 'abspath_missing',
137 'Unable to access WordPress root "' . ABSPATH . '": ' . $exception->getMessage(),
138 array( 'status' => 500 )
139 );
140 }
141
142 try {
143 if ( Throw_On_Errors::t_is_dir( WP_CONTENT_DIR ) ) {
144 $wp_content_dir = Throw_On_Errors::t_realpath( WP_CONTENT_DIR );
145
146 // Using content_url() instead of WP_CONTENT_URL as it tests for whether we're using SSL.
147 $wp_content_url = content_url();
148
149 // I think we mess up the order in which we load things somewhere in a test, so "wp-content" and
150 // "wp-content/uploads/" URLs don't actually have the scheme+host part in them.
151 if ( ! wp_http_validate_url( $wp_content_url ) ) {
152 $wp_content_url = $abspath_url . $wp_content_url;
153 }
154
155 $locations[ $wp_content_dir ] = $wp_content_url;
156 }
157 } catch ( Exception $exception ) {
158 $locations[ WP_CONTENT_DIR ] = new WP_Error(
159 'content_path_missing',
160 'Unable to access content path "' . WP_CONTENT_DIR . '"' . $exception->getMessage(),
161 array( 'status' => 500 )
162 );
163 }
164
165 $upload_dir_info = wp_upload_dir();
166 $wp_uploads_dir = $upload_dir_info['basedir'];
167
168 try {
169 if ( Throw_On_Errors::t_is_dir( $wp_uploads_dir ) ) {
170
171 $wp_uploads_dir = Throw_On_Errors::t_realpath( $wp_uploads_dir );
172 $wp_uploads_url = $upload_dir_info['baseurl'];
173
174 // wp_upload_dir() doesn't check for whether we're using SSL:
175 //
176 // https://core.trac.wordpress.org/ticket/25449
177 //
178 // so set the scheme manually.
179 $wp_uploads_url = set_url_scheme( $wp_uploads_url );
180
181 if ( ! wp_http_validate_url( $wp_uploads_url ) ) {
182 $wp_uploads_url = $abspath_url . $wp_uploads_url;
183 }
184
185 $locations[ $wp_uploads_dir ] = $wp_uploads_url;
186 }
187 } catch ( Exception $exception ) {
188 $locations[ $wp_uploads_dir ] = new WP_Error(
189 'uploads_path_missing',
190 'Unable to access uploads path "' . $wp_uploads_dir . '"' . $exception->getMessage(),
191 array( 'status' => 500 )
192 );
193 }
194
195 return $locations;
196 }
197
198 /**
199 * Installs a Helper Script, and returns its filesystem path and access url.
200 *
201 * @param string $script_body Helper Script file contents.
202 *
203 * @return array|WP_Error Either an array containing the filesystem path ("path"), the URL ("url") of the helper
204 * script, and the WordPress root ("abspath"), or an instance of WP_Error.
205 */
206 public function install_helper_script( $script_body ) {
207 // Check that the script body contains the correct header.
208 $actual_header = static::string_starts_with_substring( $script_body, static::HELPER_HEADER );
209 if ( true !== $actual_header ) {
210 return new WP_Error(
211 'bad_header',
212 'Bad helper script header: 0x' . bin2hex( $actual_header ),
213 array( 'status' => 400 )
214 );
215 }
216
217 // Refuse to install a Helper Script that is too large.
218 $helper_script_size = strlen( $script_body );
219 if ( $helper_script_size > static::MAX_FILESIZE ) {
220 return new WP_Error(
221 'too_big',
222 "Helper script is bigger ($helper_script_size bytes) " .
223 'than the max. size (' . static::MAX_FILESIZE . ' bytes)',
224 array( 'status' => 413 )
225 );
226 }
227
228 // Replace '[wp_path]' in the Helper Script with the WordPress installation location. Allows the Helper Script
229 // to find WordPress.
230 $wp_path_marker = '[wp_path]';
231 try {
232 $normalized_abspath = addslashes( Throw_On_Errors::t_realpath( ABSPATH ) );
233 } catch ( Exception $exception ) {
234 return new WP_Error(
235 'abspath_missing',
236 'Error while resolving ABSPATH "' . ABSPATH . '": ' . $exception->getMessage(),
237 array( 'status' => 500 )
238 );
239 }
240 $script_body = str_replace(
241 $wp_path_marker,
242 $normalized_abspath,
243 $script_body,
244 $wp_path_marker_replacement_count
245 );
246 if ( 0 === $wp_path_marker_replacement_count ) {
247 return new WP_Error(
248 'no_wp_path_marker',
249 "Helper script does not have the '$wp_path_marker' marker",
250 array( 'status' => 400 )
251 );
252 }
253
254 $failure_paths_and_reasons = array();
255
256 foreach ( $this->install_locations() as $directory => $url ) {
257
258 if ( is_wp_error( $url ) ) {
259 $failure_paths_and_reasons[] = "directory '$directory': " . $url->get_error_message();
260 continue;
261 }
262
263 try {
264 $installed = $this->install_to_location_or_throw( $script_body, $directory, $url );
265
266 // Always schedule a cleanup run shortly after EXPIRY_TIME.
267 wp_schedule_single_event(
268 time() + static::EXPIRY_TIME + 60,
269 'jetpack_backup_cleanup_helper_scripts'
270 );
271
272 return array(
273 'path' => $installed['path'],
274 'url' => $installed['url'],
275 'abspath' => Throw_On_Errors::t_realpath( ABSPATH ),
276 );
277
278 } catch ( Exception $exception ) {
279 $failure_paths_and_reasons[] = "directory '$directory' (URL '$url'): " . $exception->getMessage();
280 }
281 }
282
283 return new WP_Error(
284 'all_locations_failed',
285 'Unable to write the helper script to any install locations; ' .
286 'tried: ' . implode( ';', $failure_paths_and_reasons ),
287 array( 'status' => 500 )
288 );
289 }
290
291 /**
292 * Install helper script to a directory, or throw an exception.
293 *
294 * @param string $script_body Helper script's body.
295 * @param string $directory Candidate directory to create "jetpack-temp" in and write the helper script.
296 * @param string $url Base URL that the files in a directory are expected to be available at.
297 *
298 * @return string[] Array with "path" (location to the installed helper script) and "url"
299 * (URL of the installed helper script) keys.
300 * @throws Exception On I/O errors.
301 */
302 protected function install_to_location_or_throw( $script_body, $directory, $url ) {
303 if ( ! Throw_On_Errors::t_is_writable( $directory ) ) {
304 throw new Exception( "Directory '$directory' is not writable" );
305 }
306
307 $temp_dir = trailingslashit( $directory ) . static::TEMP_DIRECTORY;
308
309 if ( ! Throw_On_Errors::t_is_dir( $temp_dir ) ) {
310 Throw_On_Errors::t_mkdir( $temp_dir );
311 }
312
313 $readme_path = trailingslashit( $temp_dir ) . 'README';
314 Throw_On_Errors::t_file_put_contents( $readme_path, implode( "\n\n", static::README_LINES ) );
315
316 $index_path = trailingslashit( $temp_dir ) . 'index.php';
317 Throw_On_Errors::t_file_put_contents( $index_path, static::INDEX_FILE );
318
319 $file_key = wp_generate_password( 10, false );
320 $file_name = 'jp-helper-' . $file_key . '.php';
321 $file_path = trailingslashit( $temp_dir ) . $file_name;
322
323 // Very unlikely, but check nonetheless.
324 if ( Throw_On_Errors::t_file_exists( $file_path ) ) {
325 throw new Exception( "Helper script at '$file_path' already exists" );
326 }
327
328 Throw_On_Errors::t_file_put_contents( $file_path, $script_body );
329
330 return array(
331 'path' => $file_path,
332 'url' => trailingslashit( $url ) . trailingslashit( static::TEMP_DIRECTORY ) . $file_name,
333 );
334 }
335
336 /**
337 * Ensure that the helper script is gone (by deleting it, if needed).
338 *
339 * @param string $path Path to the helper script to delete.
340 *
341 * @return true|WP_Error True if the file helper script is gone (either it got deleted, or it was never there), or
342 * WP_Error instance on deletion failures.
343 */
344 public function delete_helper_script( $path ) {
345 try {
346 $this->delete_helper_script_or_throw( $path );
347 } catch ( Exception $exception ) {
348 return new WP_Error(
349 'deletion_failure',
350 "Unable to delete helper script at '$path': " . $exception->getMessage(),
351 array( 'status' => 500 )
352 );
353 }
354
355 return true;
356 }
357
358 /**
359 * Ensure that the helper script is gone (by deleting it, if needed), throw an exception on errors.
360 *
361 * @param string $path Path to the helper script to delete.
362 *
363 * @return void
364 * @throws Exception On deletion failures.
365 */
366 protected function delete_helper_script_or_throw( $path ) {
367
368 if ( ! Throw_On_Errors::t_file_exists( $path ) ) {
369 return;
370 }
371
372 if ( ! Throw_On_Errors::t_is_readable( $path ) ) {
373 throw new Exception( "File '$path' is not readable" );
374 }
375
376 if ( ! Throw_On_Errors::t_is_writable( $path ) ) {
377 throw new Exception( "File '$path' is not writable" );
378 }
379
380 $helper_script_size = Throw_On_Errors::t_filesize( $path );
381
382 // Check this file looks like a JPR helper script.
383 $helper_header_size = strlen( static::HELPER_HEADER );
384 if ( $helper_script_size < $helper_header_size ) {
385 throw new Exception(
386 "Helper script is smaller ($helper_script_size bytes) " .
387 "than the expected header ($helper_header_size bytes)"
388 );
389 }
390 if ( $helper_script_size > static::MAX_FILESIZE ) {
391 throw new Exception(
392 "Helper script is bigger ($helper_script_size bytes) " .
393 'than the max. size (' . static::MAX_FILESIZE . ' bytes)'
394 );
395 }
396
397 $actual_header = static::verify_file_header( $path, static::HELPER_HEADER );
398 if ( true !== $actual_header ) {
399 throw new Exception( 'Bad helper script header: 0x' . bin2hex( $actual_header ) );
400 }
401
402 Throw_On_Errors::t_unlink( $path );
403
404 $this->delete_helper_directory_if_empty( dirname( $path ) );
405 }
406
407 /**
408 * Search for Helper Scripts that are suspiciously old, and clean them out.
409 *
410 * @return true|WP_Error True if all expired helper scripts got cleaned up successfully, or an instance of
411 * WP_Error if one or more expired helper scripts didn't manage to get cleaned up.
412 */
413 public function cleanup_expired_helper_scripts() {
414 try {
415 $this->cleanup_helper_scripts( time() - static::EXPIRY_TIME );
416 } catch ( Exception $exception ) {
417 return new WP_Error(
418 'cleanup_failed',
419 'Unable to clean up expired helper scripts: ' . $exception->getMessage(),
420 array( 'status' => 500 )
421 );
422 }
423
424 return true;
425 }
426
427 /**
428 * Search for and delete all Helper Scripts. Used during uninstallation.
429 *
430 * @return true|WP_Error True if all helper scripts got deleted successfully, or an instance of WP_Error if one or
431 * more helper scripts didn't manage to get deleted.
432 */
433 public function delete_all_helper_scripts() {
434 try {
435 $this->cleanup_helper_scripts();
436 } catch ( Exception $exception ) {
437 return new WP_Error(
438 'cleanup_failed',
439 'Unable to clean up all helper scripts: ' . $exception->getMessage(),
440 array( 'status' => 500 )
441 );
442 }
443
444 return true;
445 }
446
447 /**
448 * Search for and delete Helper Scripts. If an $expiry_time is specified, only delete Helper Scripts
449 * with a mtime older than $expiry_time. Otherwise, delete them all.
450 *
451 * @param int|null $expiry_time If specified, only delete scripts older than this UNIX timestamp.
452 *
453 * @return void
454 * @throws Exception If one or more helper scripts doesn't manage to get cleaned up.
455 */
456 protected function cleanup_helper_scripts( $expiry_time = null ) {
457
458 $error_messages = array();
459
460 foreach ( $this->install_locations() as $directory => $url ) {
461
462 if ( is_wp_error( $url ) ) {
463 $error_messages[] = $url->get_error_message();
464 continue;
465 }
466
467 $temp_dir = trailingslashit( trailingslashit( $directory ) . static::TEMP_DIRECTORY );
468
469 if ( Throw_On_Errors::t_is_dir( $temp_dir ) ) {
470
471 // Find expired helper scripts and delete them.
472 $temp_dir_contents = Throw_On_Errors::t_scandir( $temp_dir );
473
474 foreach ( $temp_dir_contents as $name ) {
475
476 if ( in_array( $name, $this->scandir_ignored_names, true ) ) {
477 continue;
478 }
479
480 $full_path = $temp_dir . $name;
481
482 $last_modified = Throw_On_Errors::t_filemtime( $full_path );
483
484 if ( preg_match( '/^jp-helper-.*\.php$/', $name ) ) {
485 if ( null === $expiry_time || $last_modified < $expiry_time ) {
486 try {
487 $this->delete_helper_script_or_throw( $full_path );
488 } catch ( Exception $exception ) {
489 $error_messages[] = $exception->getMessage();
490 }
491 }
492 }
493 }
494
495 // Delete the directory if it's empty now.
496 $this->delete_helper_directory_if_empty( $temp_dir );
497 }
498 }
499
500 if ( count( $error_messages ) > 0 ) {
501 throw new Exception(
502 'Unable to clean up one or more helper scripts: ' . implode( ';', $error_messages )
503 );
504 }
505 }
506
507 /**
508 * Delete a helper script directory if it's empty.
509 *
510 * @param string $dir Path to the helper script directory.
511 *
512 * @return bool True if the directory is missing, or was empty and got deleted; false if directory still contains
513 * something and wasn't deleted.
514 * @throws Exception On I/O errors.
515 */
516 protected function delete_helper_directory_if_empty( $dir ) {
517
518 if ( ! Throw_On_Errors::t_is_dir( $dir ) ) {
519 return true;
520 }
521
522 // Check that the only remaining files are a README and index.php generated by this system.
523 $allowed_files_and_headers = array(
524 'README' => static::README_LINES[0],
525 'index.php' => static::INDEX_FILE,
526 );
527
528 $dir_contents = Throw_On_Errors::t_scandir( $dir );
529
530 if ( count( $dir_contents ) > count( $allowed_files_and_headers ) + count( $this->scandir_ignored_names ) ) {
531 return false;
532 }
533
534 foreach ( $dir_contents as $name ) {
535
536 if ( in_array( $name, $this->scandir_ignored_names, true ) ) {
537 continue;
538 }
539
540 $full_path = trailingslashit( $dir ) . $name;
541 if ( ! isset( $allowed_files_and_headers[ $name ] ) ) {
542 return false;
543 }
544
545 // Verify the file starts with the expected contents.
546 $actual_header = static::verify_file_header( $full_path, $allowed_files_and_headers[ $name ] );
547 if ( true !== $actual_header ) {
548 throw new Exception( "Bad header for file '$full_path': 0x" . bin2hex( $actual_header ) );
549 }
550
551 Throw_On_Errors::t_unlink( $full_path );
552 }
553
554 // If the directory is now empty, delete it.
555 $dir_contents_after_cleanup = Throw_On_Errors::t_scandir( $dir );
556
557 if ( count( $dir_contents_after_cleanup ) <= count( $this->scandir_ignored_names ) ) {
558 Throw_On_Errors::t_rmdir( $dir );
559 }
560
561 return true;
562 }
563
564 /**
565 * Test if string starts with a substring, and if it doesn't, return the actual prefix.
566 *
567 * @param string $string String to search in.
568 * @param string $expected_prefix Expected prefix.
569 *
570 * @return bool|string True if string starts with a substring, or the actual prefix that was found instead of the
571 * expected prefix.
572 */
573 protected static function string_starts_with_substring( $string, $expected_prefix ) {
574 $actual_prefix = substr( $string, 0, strlen( $expected_prefix ) );
575 if ( $actual_prefix !== $expected_prefix ) {
576 return $actual_prefix;
577 }
578
579 return true;
580 }
581
582 /**
583 * Verify that a file exists, is readable, and has the expected header.
584 *
585 * @param string $path File to verify.
586 * @param string $expected_header Header that the file should have.
587 *
588 * @return bool|string True if header matches, or an actual header if it doesn't match.
589 * @throws Exception If the file doesn't exist, isn't readable, or is of the wrong size.
590 */
591 protected static function verify_file_header( $path, $expected_header ) {
592 if ( ! Throw_On_Errors::t_file_exists( $path ) ) {
593 throw new Exception( "File '$path' does not exist" );
594 }
595
596 if ( ! Throw_On_Errors::t_is_readable( $path ) ) {
597 throw new Exception( "File '$path' is not readable" );
598 }
599
600 $file_size = Throw_On_Errors::t_filesize( $path );
601
602 // Check this file looks like a JPR helper script.
603 $expected_header_size = strlen( $expected_header );
604 if ( $file_size < $expected_header_size ) {
605 throw new Exception(
606 "File is smaller ($file_size bytes) " .
607 "than the expected header ($expected_header_size bytes)"
608 );
609 }
610 if ( $file_size > static::MAX_FILESIZE ) {
611 throw new Exception(
612 "File is bigger ($file_size bytes) " .
613 'than the max. size (' . static::MAX_FILESIZE . ' bytes)'
614 );
615 }
616
617 $file_contents = Throw_On_Errors::t_file_get_contents( $path );
618 return static::string_starts_with_substring( $file_contents, $expected_header );
619 }
620 }
621