Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.65% covered (warning)
77.65%
132 / 170
53.33% covered (warning)
53.33%
8 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
StagingBootstrapCheck
77.65% covered (warning)
77.65%
132 / 170
53.33% covered (warning)
53.33%
8 / 15
95.57
0.00% covered (danger)
0.00%
0 / 1
 check
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
10
 parse_failure
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 extract_components
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 relevant_lines
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 component_type
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 create_function_message
41.18% covered (danger)
41.18%
7 / 17
0.00% covered (danger)
0.00%
0 / 1
7.26
 redeclare_message
27.27% covered (danger)
27.27%
6 / 22
0.00% covered (danger)
0.00%
0 / 1
25.85
 generic_message
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
4.41
 output_has_fatal
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_tool_missing
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 exec_disabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 build_command
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 evict_object_cache
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 log_output
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 log_directory
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
1<?php
2
3namespace NewfoldLabs\WP\Module\Staging;
4
5/**
6 * Probes a WordPress install with WP-CLI before staging operations that boot plugins and themes.
7 *
8 * This is separate from StagingHealthCheck, which repairs staging_config. A single fatal in an
9 * active plugin or theme kills the few WP-CLI commands that do not pass --skip-plugins, and the
10 * shell script then has nothing to report except a generic failure.
11 */
12class StagingBootstrapCheck {
13
14    /**
15     * REST / WP_Error code for a failed bootstrap.
16     *
17     * @var string
18     */
19    const ERROR_CODE = 'bootstrap_check';
20
21    /**
22     * Hard cap for the probe. A healthy bootstrap finishes well inside this.
23     *
24     * @var int
25     */
26    const TIMEOUT_SECONDS = 10;
27
28    /**
29     * GNU timeout exit status when the probe is killed.
30     *
31     * @var int
32     */
33    const TIMEOUT_EXIT = 124;
34
35    /**
36     * Shell exit status when wp or timeout is not installed.
37     *
38     * @var int
39     */
40    const MISSING_EXIT = 127;
41
42    /**
43     * Max characters of CLI output written to the staging log.
44     *
45     * @var int
46     */
47    const LOG_OUTPUT_LIMIT = 4000;
48
49    /**
50     * Staging log filename under the production nfd-private directory.
51     *
52     * @var string
53     */
54    const LOG_FILE = 'nfd-staging.log';
55
56    /**
57     * PHP passed to `wp --exec`. Kept in step with NFD_WP_EXEC in lib/.staging so a broken
58     * object-cache drop-in is not reported as a plugin fatal.
59     *
60     * @var string
61     */
62    const WP_CLI_EXEC = "defined('WP_REDIS_DISABLED') || define('WP_REDIS_DISABLED', true); if ( class_exists('WP_CLI') && method_exists('WP_CLI', 'add_wp_hook') ) { WP_CLI::add_wp_hook('enable_loading_object_cache_dropin', function () { return function_exists('wp_cache_init'); }); }";
63
64    /**
65     * Prefix of the line FATAL_REPORTER writes to stderr.
66     *
67     * @var string
68     */
69    const FATAL_MARKER = 'NFD_BOOTSTRAP_FATAL:';
70
71    /**
72     * Probe-only PHP passed as a second `wp --exec`.
73     *
74     * With display_errors off, which is common for PHP CLI on shared hosting, WP-CLI prints only
75     * "There has been a critical error on this website." and no file path. error_get_last() still
76     * has the fatal at shutdown, and this runs before WordPress registers its own handler.
77     *
78     * WP_DISABLE_FATAL_ERROR_HANDLER keeps that handler from mailing a recovery link or writing
79     * options. The probe skips the object-cache drop-in, so those writes would land in the database
80     * and leave the live cache stale. The next switch or clone then dies with a generic error
81     * even after the broken file has been restored.
82     *
83     * @var string
84     */
85    const FATAL_REPORTER = <<<'PHP'
86defined('WP_DISABLE_FATAL_ERROR_HANDLER') || define('WP_DISABLE_FATAL_ERROR_HANDLER', true); register_shutdown_function( function () { $e = error_get_last(); if ( is_array( $e ) && in_array( $e['type'], array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR ), true ) ) { fwrite( STDERR, 'NFD_BOOTSTRAP_FATAL: ' . strtok( (string) $e['message'], "\n" ) . ' in ' . $e['file'] . ':' . $e['line'] . "\n" ); } } );
87PHP;
88
89    /**
90     * PHP passed to the cache-eviction `wp --exec`, before WordPress loads.
91     *
92     * @var string
93     */
94    const CACHE_EVICTION_BOOT = "defined('WP_DISABLE_FATAL_ERROR_HANDLER') || define('WP_DISABLE_FATAL_ERROR_HANDLER', true);";
95
96    /**
97     * Option keys to drop from the probed install's object cache after the bootstrap.
98     *
99     * Passed to `wp eval`, so it runs once the drop-in is loaded. alloptions is the one that
100     * matters: autoloaded options are served from it. The others are named so a key stored on its
101     * own is dropped too.
102     *
103     * @var string
104     */
105    const CACHE_EVICTION = <<<'PHP'
106foreach ( array( 'alloptions', 'notoptions', 'staging_auth_token', 'staging_config', 'staging_environment', 'nfd_coming_soon', 'cron', 'recovery_mode_email_last_sent' ) as $key ) { wp_cache_delete( $key, 'options' ); }
107PHP;
108
109    /**
110     * Bootstrap WordPress at $path by running `wp eval`.
111     *
112     * Missing wp/timeout, a disabled exec(), a directory that is not there, a probe that exceeds
113     * the timeout, or a non-zero exit that is not a PHP fatal all fail open: the staging script
114     * already reports those. Only a PHP fatal is returned to the user.
115     *
116     * @param string $path             Production or staging directory.
117     * @param bool   $load_extensions  Whether to load regular plugins and themes. Must-use plugins,
118     *                                 drop-ins, and wp-config.php load either way.
119     * @return true|\WP_Error
120     */
121    public function check( $path, $load_extensions = true ) {
122        $path = is_string( $path ) ? $path : '';
123        if ( '' === $path || ! is_dir( $path ) ) {
124            return true;
125        }
126
127        if ( $this->exec_disabled() ) {
128            return true;
129        }
130
131        $command = $this->build_command( $path, $load_extensions );
132        $output  = array();
133        $status  = 0;
134
135        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
136        exec( $command, $output, $status );
137
138        $text   = implode( "\n", $output );
139        $status = (int) $status;
140
141        /*
142         * The probe skipped the object-cache drop-in, and a full or partial boot may have written
143         * options straight to the database. Drop the cached copies on this install before returning,
144         * or the next command that loads the drop-in (switch's `wp newfold sso`) reads the stale copy.
145         * A missing wp binary never booted WordPress, so there is nothing to drop.
146         */
147        if ( ! $this->is_tool_missing( $status, $text ) ) {
148            $this->evict_object_cache( $path );
149        }
150
151        // Exit 0 means eval finished. A BOM or extra echoed text must not look like a fatal.
152        if ( 0 === $status ) {
153            return true;
154        }
155
156        if ( $this->is_tool_missing( $status, $text ) || self::TIMEOUT_EXIT === $status || ! $this->output_has_fatal( $text ) ) {
157            $this->log_output( $path, 'WARN', $text, $status );
158            return true;
159        }
160
161        $this->log_output( $path, 'ERROR', $text, $status );
162
163        return new \WP_Error( self::ERROR_CODE, $this->parse_failure( $text ) );
164    }
165
166    /**
167     * Turn WP-CLI fatal output into a message that names the plugin or theme when possible.
168     *
169     * Only the fatal line is read. Stack frames usually start in WordPress core and would name
170     * the wrong extension.
171     *
172     * @param string $output Combined stdout and stderr from the probe.
173     * @return string
174     */
175    public function parse_failure( $output ) {
176        $output     = (string) $output;
177        $components = $this->extract_components( $output );
178        $fatal_text = implode( "\n", $this->relevant_lines( $output ) );
179
180        if ( false !== stripos( $fatal_text, 'create_function' ) ) {
181            return $this->create_function_message( $components );
182        }
183
184        if ( false !== stripos( $fatal_text, 'Cannot redeclare' ) ) {
185            return $this->redeclare_message( $components );
186        }
187
188        return $this->generic_message( $components );
189    }
190
191    /**
192     * Plugin and theme slugs mentioned on the fatal line, in order.
193     *
194     * @param string $output CLI output.
195     * @return array<int, array{type: string, slug: string}>
196     */
197    protected function extract_components( $output ) {
198        $found = array();
199        $seen  = array();
200
201        foreach ( $this->relevant_lines( $output ) as $line ) {
202            if ( ! preg_match_all( '#/(mu-plugins|plugins|themes)/([A-Za-z0-9_-]+)(?:/|\.php\b)#', $line, $matches, PREG_SET_ORDER ) ) {
203                continue;
204            }
205
206            foreach ( $matches as $match ) {
207                $key = $match[1] . ':' . $match[2];
208                if ( isset( $seen[ $key ] ) ) {
209                    continue;
210                }
211
212                $seen[ $key ] = true;
213                $found[]      = array(
214                    'type' => $this->component_type( $match[1] ),
215                    'slug' => $match[2],
216                );
217            }
218        }
219
220        return $found;
221    }
222
223    /**
224     * Lines that describe the fatal. The shutdown marker wins over PHP's own fatal text.
225     *
226     * A create_function() deprecation is not a fatal line. On PHP 7.x that notice can sit next
227     * to an unrelated fatal and would otherwise name the wrong extension.
228     *
229     * @param string $output CLI output.
230     * @return string[]
231     */
232    protected function relevant_lines( $output ) {
233        $lines    = preg_split( '/\R/', (string) $output );
234        $lines    = is_array( $lines ) ? $lines : array();
235        $reported = array();
236        $relevant = array();
237
238        foreach ( $lines as $line ) {
239            if ( false !== strpos( $line, self::FATAL_MARKER ) ) {
240                $reported[] = $line;
241            } elseif ( preg_match( '/Fatal error|Parse error|Cannot redeclare|^\s*thrown in\b/i', $line ) ) {
242                $relevant[] = $line;
243            }
244        }
245
246        if ( ! empty( $reported ) ) {
247            return $reported;
248        }
249
250        return $relevant;
251    }
252
253    /**
254     * Map a wp-content directory to the component type used in messages.
255     *
256     * @param string $directory mu-plugins, plugins, or themes.
257     * @return string mu-plugin, plugin, or theme.
258     */
259    protected function component_type( $directory ) {
260        if ( 'themes' === $directory ) {
261            return 'theme';
262        }
263
264        if ( 'mu-plugins' === $directory ) {
265            return 'mu-plugin';
266        }
267
268        return 'plugin';
269    }
270
271    /**
272     * Message for a create_function() fatal.
273     *
274     * @param array<int, array{type: string, slug: string}> $components Components on the fatal line.
275     * @return string
276     */
277    protected function create_function_message( array $components ) {
278        if ( empty( $components ) ) {
279            return __( 'A plugin or theme is not compatible with PHP 8 because it uses create_function(), which was removed. Deactivate third-party plugins and switch to a default theme such as Twenty Twenty-Five, then try again.', 'wp-module-staging' );
280        }
281
282        $component = $components[0];
283
284        if ( 'theme' === $component['type'] ) {
285            return sprintf(
286                /* translators: %s: theme directory name. */
287                __( 'The theme "%s" is not compatible with PHP 8 because it uses create_function(), which was removed. Deactivate it, then try again.', 'wp-module-staging' ),
288                $component['slug']
289            );
290        }
291
292        if ( 'mu-plugin' === $component['type'] ) {
293            return sprintf(
294                /* translators: %s: must-use plugin directory or file name. */
295                __( 'The must-use plugin "%s" is not compatible with PHP 8 because it uses create_function(), which was removed. Remove it from mu-plugins, then try again.', 'wp-module-staging' ),
296                $component['slug']
297            );
298        }
299
300        return sprintf(
301            /* translators: %s: plugin directory name. */
302            __( 'The plugin "%s" is not compatible with PHP 8 because it uses create_function(), which was removed. Deactivate it, then try again.', 'wp-module-staging' ),
303            $component['slug']
304        );
305    }
306
307    /**
308     * Message for a Cannot redeclare fatal.
309     *
310     * @param array<int, array{type: string, slug: string}> $components Components on the fatal line.
311     * @return string
312     */
313    protected function redeclare_message( array $components ) {
314        if ( count( $components ) >= 2 ) {
315            return sprintf(
316                /* translators: 1: plugin or theme that declared the symbol first, 2: plugin or theme that declared it again. */
317                __( 'The plugin or theme "%1$s" conflicts with "%2$s" (Cannot redeclare). Deactivate or remove one of them, then try again.', 'wp-module-staging' ),
318                $components[0]['slug'],
319                $components[1]['slug']
320            );
321        }
322
323        if ( 1 === count( $components ) && 'theme' === $components[0]['type'] ) {
324            return sprintf(
325                /* translators: %s: theme directory name. */
326                __( 'The theme "%s" has a code conflict (Cannot redeclare). Deactivate it, then try again.', 'wp-module-staging' ),
327                $components[0]['slug']
328            );
329        }
330
331        if ( 1 === count( $components ) && 'mu-plugin' === $components[0]['type'] ) {
332            return sprintf(
333                /* translators: %s: must-use plugin directory or file name. */
334                __( 'The must-use plugin "%s" has a code conflict (Cannot redeclare). Remove it from mu-plugins, then try again.', 'wp-module-staging' ),
335                $components[0]['slug']
336            );
337        }
338
339        if ( 1 === count( $components ) ) {
340            return sprintf(
341                /* translators: %s: plugin directory name. */
342                __( 'The plugin "%s" has a code conflict (Cannot redeclare). Deactivate it, then try again.', 'wp-module-staging' ),
343                $components[0]['slug']
344            );
345        }
346
347        return __( 'A plugin has a code conflict (Cannot redeclare). Deactivate third-party plugins and switch to a default theme such as Twenty Twenty-Five, then try again.', 'wp-module-staging' );
348    }
349
350    /**
351     * Message for any other fatal.
352     *
353     * @param array<int, array{type: string, slug: string}> $components Components on the fatal line.
354     * @return string
355     */
356    protected function generic_message( array $components ) {
357        if ( empty( $components ) ) {
358            return __( 'WordPress could not start because of a fatal error in a plugin or theme. Deactivate third-party plugins and switch to a default theme such as Twenty Twenty-Five, then try again.', 'wp-module-staging' );
359        }
360
361        $component = $components[0];
362
363        if ( 'theme' === $component['type'] ) {
364            return sprintf(
365                /* translators: %s: theme directory name. */
366                __( 'The theme "%s" caused a fatal error while loading WordPress. Deactivate it, or deactivate third-party plugins and switch to a default theme such as Twenty Twenty-Five, then try again.', 'wp-module-staging' ),
367                $component['slug']
368            );
369        }
370
371        if ( 'mu-plugin' === $component['type'] ) {
372            return sprintf(
373                /* translators: %s: must-use plugin directory or file name. */
374                __( 'The must-use plugin "%s" caused a fatal error while loading WordPress. Remove it from mu-plugins, then try again.', 'wp-module-staging' ),
375                $component['slug']
376            );
377        }
378
379        return sprintf(
380            /* translators: %s: plugin directory name. */
381            __( 'The plugin "%s" caused a fatal error while loading WordPress. Deactivate it, or deactivate third-party plugins and switch to a default theme such as Twenty Twenty-Five, then try again.', 'wp-module-staging' ),
382            $component['slug']
383        );
384    }
385
386    /**
387     * Whether the output contains a PHP fatal, as opposed to a WP-CLI or database error.
388     *
389     * @param string $text CLI output.
390     * @return bool
391     */
392    protected function output_has_fatal( $text ) {
393        return (bool) preg_match(
394            '/NFD_BOOTSTRAP_FATAL:|Fatal error|Parse error|There has been a critical error/i',
395            (string) $text
396        );
397    }
398
399    /**
400     * Whether the probe could not be launched, as opposed to WordPress failing to boot.
401     *
402     * @param int    $status Process exit status.
403     * @param string $text   CLI output.
404     * @return bool
405     */
406    protected function is_tool_missing( $status, $text ) {
407        if ( self::MISSING_EXIT === $status ) {
408            return true;
409        }
410
411        return false !== stripos( $text, 'command not found' );
412    }
413
414    /**
415     * Whether exec() is listed in disable_functions.
416     *
417     * @return bool
418     */
419    protected function exec_disabled() {
420        $disabled = array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) );
421
422        return in_array( 'exec', $disabled, true );
423    }
424
425    /**
426     * Shell command for one bootstrap. The path is escaped and is never taken from request input.
427     *
428     * /usr/local/bin is where wp lives on the hosts this module targets. runCommand() adds it
429     * only after this probe, and lib/.staging exports it inside the script, so the probe sets it.
430     *
431     * @param string $path            Directory to pass to WP-CLI --path.
432     * @param bool   $load_extensions Whether to load regular plugins and themes.
433     * @return string
434     */
435    protected function build_command( $path, $load_extensions = true ) {
436        $skip = $load_extensions ? '' : ' --skip-plugins --skip-themes';
437
438        return sprintf(
439            'PATH="$PATH:/usr/local/bin" timeout %d wp --exec=%s --exec=%s eval %s --path=%s%s --quiet 2>&1',
440            self::TIMEOUT_SECONDS,
441            escapeshellarg( self::WP_CLI_EXEC ),
442            escapeshellarg( self::FATAL_REPORTER ),
443            escapeshellarg( 'echo "ok";' ),
444            escapeshellarg( untrailingslashit( $path ) ),
445            $skip
446        );
447    }
448
449    /**
450     * Drop cached options for the install that was just probed.
451     *
452     * Runs with plugins skipped and the object-cache drop-in loaded, which is the opposite of the
453     * probe. wp_cache_delete() then reaches the same Redis keys the next web request or SSO command
454     * will read. A broken drop-in must not turn into a user-facing error, so the result is ignored.
455     *
456     * @param string $path Directory that was probed.
457     * @return void
458     */
459    protected function evict_object_cache( $path ) {
460        $command = sprintf(
461            'PATH="$PATH:/usr/local/bin" timeout %d wp --exec=%s eval %s --path=%s --skip-plugins --skip-themes --quiet 2>/dev/null',
462            self::TIMEOUT_SECONDS,
463            escapeshellarg( self::CACHE_EVICTION_BOOT ),
464            escapeshellarg( self::CACHE_EVICTION ),
465            escapeshellarg( untrailingslashit( $path ) )
466        );
467
468        $output = array();
469        $status = 0;
470
471        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
472        exec( $command, $output, $status );
473    }
474
475    /**
476     * Append a single truncated line to the production staging log.
477     *
478     * @param string $path    Directory that was probed.
479     * @param string $level   WARN or ERROR.
480     * @param string $message Raw CLI output.
481     * @param int    $status  Process exit status.
482     * @return void
483     */
484    protected function log_output( $path, $level, $message, $status ) {
485        $log_dir  = $this->log_directory( $path ) . 'nfd-private';
486        $log_file = $log_dir . '/' . self::LOG_FILE;
487
488        if ( ! is_dir( $log_dir ) ) {
489            wp_mkdir_p( $log_dir );
490        }
491
492        $htaccess = $log_dir . '/.htaccess';
493        if ( ! file_exists( $htaccess ) ) {
494            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
495            file_put_contents( $htaccess, "Order allow,deny\nDeny from all\n" );
496        }
497
498        $flat = preg_replace( '/\s+/', ' ', (string) $message );
499        $flat = is_string( $flat ) ? trim( $flat ) : '';
500        if ( strlen( $flat ) > self::LOG_OUTPUT_LIMIT ) {
501            $flat = substr( $flat, 0, self::LOG_OUTPUT_LIMIT ) . '...';
502        }
503
504        $line = sprintf(
505            "%s [%s] [%s] status=%d %s\n",
506            gmdate( 'Y-m-d H:i:s' ),
507            $level,
508            'bootstrap_check',
509            (int) $status,
510            $flat
511        );
512
513        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
514        file_put_contents( $log_file, $line, FILE_APPEND | LOCK_EX );
515    }
516
517    /**
518     * Production directory that owns nfd-private, derived from the probed path.
519     *
520     * @param string $path Directory that was probed.
521     * @return string
522     */
523    protected function log_directory( $path ) {
524        if ( StagingPath::is_staging_abspath( $path ) ) {
525            $parsed = StagingPath::parse_staging_from_abspath( $path );
526            if ( is_array( $parsed ) && ! empty( $parsed['production_dir'] ) ) {
527                return $parsed['production_dir'];
528            }
529        }
530
531        return StagingPath::normalize_trailing_slash( $path );
532    }
533}