PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-backup-helper-script-manager/src/class-helper-script-manager-impl.php +620 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,620 @@
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 +}