Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.35% covered (warning)
77.35%
181 / 234
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Helper_Script_Manager_Impl
77.35% covered (warning)
77.35%
181 / 234
41.67% covered (danger)
41.67%
5 / 12
106.66
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 install_locations
53.66% covered (warning)
53.66%
22 / 41
0.00% covered (danger)
0.00%
0 / 1
19.95
 install_helper_script
86.21% covered (warning)
86.21%
50 / 58
0.00% covered (danger)
0.00%
0 / 1
8.17
 install_to_location_or_throw
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
 delete_helper_script
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 delete_helper_script_or_throw
52.17% covered (warning)
52.17%
12 / 23
0.00% covered (danger)
0.00%
0 / 1
12.36
 cleanup_expired_helper_scripts
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 delete_all_helper_scripts
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 cleanup_helper_scripts
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
11.08
 delete_helper_directory_if_empty
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
8.04
 string_starts_with_substring
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 verify_file_header
44.44% covered (danger)
44.44%
8 / 18
0.00% covered (danger)
0.00%
0 / 1
9.29
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).
12namespace Automattic\Jetpack\Backup\V0005;
13
14use Exception;
15use WP_Error;
16use function content_url;
17use function get_site_url;
18use function is_wp_error;
19use function set_url_scheme;
20use function trailingslashit;
21use function wp_generate_password;
22use function wp_http_validate_url;
23use function wp_schedule_single_event;
24use function wp_upload_dir;
25use const ABSPATH;
26use 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 */
41class 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}