Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.87% covered (danger)
6.87%
9 / 131
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
PCG_Rollback
6.87% covered (danger)
6.87%
9 / 131
14.29% covered (danger)
14.29%
1 / 7
1680.65
0.00% covered (danger)
0.00%
0 / 1
 to_snapshot
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 to_local_backup
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
210
 to_download
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 reactivate_if_needed
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 build_download_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 install_from_url
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 fs
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Rollback a failed plugin update.
4 *
5 * Two paths, tried in order:
6 *
7 *   1. Local backup. If `PCG_Snapshot::create_backup()` stashed a copy
8 *      of the pre-update files under `<get_temp_dir()>/pcg-backups/<unique>/`,
9 *      restore from there — works for any plugin (paid, private, .org)
10 *      and needs no network round-trip.
11 *   2. WordPress.org versioned ZIP. Fallback for cases where the local
12 *      backup is missing or restoration failed.
13 *
14 * @package automattic/jetpack-mu-wpcom
15 */
16
17/**
18 * Restores the pre-update plugin files from the snapshot.
19 */
20class PCG_Rollback {
21
22    /**
23     * Restore the plugin to its pre-update version.
24     *
25     * @param array{plugin_file:string,slug:string,version:string,was_active:bool,backup_path?:string,timestamp:float} $snapshot Snapshot from PCG_Snapshot::capture().
26     * @return array{status:string,reason?:string,restored_to?:string,via?:string}
27     *         status is one of "restored", "reactivated", "rollback_unavailable", "rollback_failed".
28     */
29    public static function to_snapshot( $snapshot ) {
30        if ( empty( $snapshot['plugin_file'] ) ) {
31            return array(
32                'status' => 'rollback_unavailable',
33                'reason' => 'Snapshot missing plugin_file.',
34            );
35        }
36
37        // Deactivate first so the broken version stops fataling on every request.
38        $plugin_file = (string) $snapshot['plugin_file'];
39        if ( function_exists( 'deactivate_plugins' ) ) {
40            deactivate_plugins( array( $plugin_file ), true );
41        }
42
43        // Try the local backup first — fast, offline, works for any source.
44        $local = self::to_local_backup( $snapshot );
45        if ( 'rollback_unavailable' !== ( $local['status'] ?? '' ) && 'rollback_failed' !== ( $local['status'] ?? '' ) ) {
46            return self::reactivate_if_needed( $local, $snapshot );
47        }
48
49        // Fallback to .org download.
50        $download = self::to_download( $snapshot );
51        if ( 'rollback_unavailable' === ( $download['status'] ?? '' ) || 'rollback_failed' === ( $download['status'] ?? '' ) ) {
52            // Surface the local-backup failure when both paths failed but the local one is more informative.
53            if ( ! empty( $local['reason'] ) ) {
54                $download['reason'] = (string) $local['reason'] . ' / ' . (string) ( $download['reason'] ?? '' );
55            }
56            return $download;
57        }
58        return self::reactivate_if_needed( $download, $snapshot );
59    }
60
61    /**
62     * Restore from the local backup directory captured at snapshot time.
63     *
64     * @param array $snapshot Snapshot.
65     * @return array{status:string,reason?:string,restored_to?:string,via?:string}
66     */
67    public static function to_local_backup( $snapshot ) {
68        $backup_path = is_array( $snapshot ) ? (string) ( $snapshot['backup_path'] ?? '' ) : '';
69        $plugin_file = (string) ( $snapshot['plugin_file'] ?? '' );
70        $asset_name  = PCG_Snapshot::asset_name( $plugin_file );
71
72        if ( '' === $backup_path || '' === $asset_name ) {
73            return array(
74                'status' => 'rollback_unavailable',
75                'reason' => 'No local backup recorded for this update.',
76            );
77        }
78        $fs = self::fs();
79        if ( ! $fs ) {
80            return array(
81                'status' => 'rollback_failed',
82                'reason' => 'WP_Filesystem unavailable.',
83            );
84        }
85        $backup_asset = $backup_path . '/' . $asset_name;
86        if ( ! $fs->exists( $backup_asset ) ) {
87            return array(
88                'status' => 'rollback_unavailable',
89                'reason' => 'Local backup missing on disk.',
90            );
91        }
92
93        $current = WP_PLUGIN_DIR . '/' . $asset_name;
94
95        // Stage the broken plugin aside (instead of deleting it outright)
96        // so a failed restore leaves the slug populated with the broken
97        // version, not empty. The plugin will still be deactivated by the
98        // caller, but its files staying on disk is what lets WP / the user
99        // see and re-attempt the update later.
100        //
101        // The dot-prefix on the trash basename hides it from
102        // `get_plugins()`, which skips entries starting with `.`. Avoids
103        // a phantom plugin row appearing during the brief rollback window.
104        $trash = '';
105        if ( $fs->exists( $current ) ) {
106            $trash = WP_PLUGIN_DIR . '/.pcg-rollback-trash-' . $asset_name . '-' . md5( uniqid( '', true ) );
107            if ( ! $fs->move( $current, $trash, false ) ) {
108                return array(
109                    'status' => 'rollback_failed',
110                    'reason' => 'Could not stage broken plugin files aside.',
111                );
112            }
113        }
114
115        $restore_failed = static function ( $reason ) use ( $fs, $current, $trash ) {
116            if ( '' !== $trash ) {
117                // Throw away any partial restore, then put the broken
118                // plugin back where it was so the slug isn't empty.
119                $fs->delete( $current, true );
120                $fs->move( $trash, $current, true );
121            }
122            return array(
123                'status' => 'rollback_failed',
124                'reason' => $reason,
125            );
126        };
127
128        // move() works for files (and same-fs dir renames). For cross-fs
129        // dir moves WP_Filesystem_Direct::move can't recurse, so fall
130        // back to copy_dir on dirs.
131        $moved = $fs->move( $backup_asset, $current, true );
132        if ( ! $moved ) {
133            if ( $fs->is_dir( $backup_asset ) ) {
134                if ( ! wp_mkdir_p( $current ) || true !== copy_dir( $backup_asset, $current ) ) {
135                    return $restore_failed( 'Could not restore plugin from local backup (cross-fs copy_dir failed).' );
136                }
137            } else {
138                return $restore_failed( 'Could not restore plugin from local backup.' );
139            }
140        }
141
142        // Restore succeeded — drop the trashed broken plugin and the
143        // (now-empty-ish) backup wrapper dir.
144        if ( '' !== $trash ) {
145            $fs->delete( $trash, true );
146        }
147        $fs->delete( $backup_path, true );
148
149        return array(
150            'status'      => 'restored',
151            'restored_to' => (string) ( $snapshot['version'] ?? '' ),
152            'via'         => 'local_backup',
153        );
154    }
155
156    /**
157     * Re-download and reinstall from WordPress.org.
158     *
159     * @param array $snapshot Snapshot.
160     * @return array{status:string,reason?:string,restored_to?:string,via?:string}
161     */
162    public static function to_download( $snapshot ) {
163        $slug    = (string) ( $snapshot['slug'] ?? '' );
164        $version = (string) ( $snapshot['version'] ?? '' );
165
166        $zip_url = self::build_download_url( $slug, $version );
167        if ( '' === $zip_url ) {
168            return array(
169                'status' => 'rollback_unavailable',
170                'reason' => 'No WordPress.org download URL could be built for this plugin.',
171            );
172        }
173
174        $install = self::install_from_url( $zip_url );
175        if ( is_wp_error( $install ) ) {
176            return array(
177                'status' => 'rollback_failed',
178                'reason' => (string) $install->get_error_message(),
179            );
180        }
181
182        return array(
183            'status'      => 'restored',
184            'restored_to' => $version,
185            'via'         => 'wp_org_download',
186        );
187    }
188
189    /**
190     * If the snapshot says the plugin was active, reactivate it and
191     * promote `restored` → `reactivated` on success.
192     *
193     * @param array $result   Successful rollback result.
194     * @param array $snapshot Snapshot.
195     * @return array
196     */
197    protected static function reactivate_if_needed( $result, $snapshot ) {
198        if ( empty( $snapshot['was_active'] ) || ! function_exists( 'activate_plugin' ) ) {
199            return $result;
200        }
201        $activated = activate_plugin( (string) $snapshot['plugin_file'], '', false, true );
202        if ( ! is_wp_error( $activated ) ) {
203            $result['status'] = 'reactivated';
204        }
205        return $result;
206    }
207
208    /**
209     * Build the canonical WordPress.org versioned ZIP URL for a plugin.
210     *
211     * @param string $slug    Plugin slug (directory name).
212     * @param string $version Previous version string (e.g. "7.9.1").
213     * @return string URL, or '' when inputs are invalid.
214     */
215    public static function build_download_url( $slug, $version ) {
216        $slug    = trim( (string) $slug );
217        $version = trim( (string) $version );
218        if ( '' === $slug || '' === $version ) {
219            return '';
220        }
221        if ( ! preg_match( '/^[a-z0-9\-]+$/i', $slug ) ) {
222            return '';
223        }
224        if ( ! preg_match( '/^[0-9][0-9A-Za-z\.\-]*$/', $version ) ) {
225            return '';
226        }
227        return sprintf( 'https://downloads.wordpress.org/plugin/%s.%s.zip', rawurlencode( $slug ), rawurlencode( $version ) );
228    }
229
230    /**
231     * Download $url and re-install it via Plugin_Upgrader with clear_destination.
232     *
233     * @param string $url Versioned plugin ZIP URL.
234     * @return true|WP_Error
235     */
236    protected static function install_from_url( $url ) {
237        if ( ! class_exists( 'Plugin_Upgrader' ) ) {
238            require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
239        }
240        if ( ! class_exists( 'Automatic_Upgrader_Skin' ) ) {
241            require_once ABSPATH . 'wp-admin/includes/class-automatic-upgrader-skin.php';
242        }
243
244        $skin     = new Automatic_Upgrader_Skin();
245        $upgrader = new Plugin_Upgrader( $skin );
246        $result   = $upgrader->install(
247            $url,
248            array(
249                'overwrite_package' => true,
250                'clear_destination' => true,
251            )
252        );
253
254        if ( is_wp_error( $result ) ) {
255            return $result;
256        }
257        if ( true === $result ) {
258            return true;
259        }
260        // install() returned false/null. Surface the skin's accumulated
261        // errors when available; otherwise fall back to a generic one.
262        if ( method_exists( $skin, 'get_errors' ) ) {
263            // @phan-suppress-next-line PhanUndeclaredMethod -- existence checked at runtime.
264            $errors = $skin->get_errors();
265            if ( $errors instanceof WP_Error && $errors->has_errors() ) {
266                return $errors;
267            }
268        }
269        return new WP_Error( 'pcg_rollback_install_failed', 'Plugin_Upgrader::install() returned false.' );
270    }
271
272    /**
273     * Lazy-init WP_Filesystem and return the global instance, or null
274     * when initialization fails. Inside upgrader hooks WP has already
275     * called WP_Filesystem(), so this is effectively a no-op fetch.
276     *
277     * @return WP_Filesystem_Base|null
278     */
279    protected static function fs() {
280        global $wp_filesystem;
281        if ( $wp_filesystem ) {
282            return $wp_filesystem;
283        }
284        if ( ! function_exists( 'WP_Filesystem' ) ) {
285            require_once ABSPATH . 'wp-admin/includes/file.php';
286        }
287        WP_Filesystem();
288        return $wp_filesystem;
289    }
290}