Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
27.08% covered (danger)
27.08%
26 / 96
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PCG_Snapshot
27.08% covered (danger)
27.08%
26 / 96
20.00% covered (danger)
20.00%
2 / 10
759.83
0.00% covered (danger)
0.00%
0 / 1
 capture
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 consume
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 create_backup
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
132
 cleanup_backup
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 sweep_stale_backups
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 backup_root
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 asset_name
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 slug_from_file
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 transient_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fs
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
1<?php
2/**
3 * Pre-update snapshot for plugin updates.
4 *
5 * Captures enough state before an update to decide whether the post-update
6 * probe needs to run (was_active), what to roll back to if it fails
7 * (version), and a local copy of the existing files so the rollback can
8 * happen without re-downloading anything (backup_path).
9 *
10 * @package automattic/jetpack-mu-wpcom
11 */
12
13/**
14 * Transient-backed snapshot of a plugin's state immediately before an update.
15 */
16class PCG_Snapshot {
17
18    const BACKUP_DIRNAME = 'pcg-backups';
19
20    /**
21     * How long the snapshot transient and on-disk backup live before
22     * cleanup considers them stale. A slow upgrader (large package, slow
23     * disk, queued cron run) can take well over the WP transient default,
24     * and if the transient expires before `upgrader_process_complete`
25     * fires, the backup on disk becomes unrecoverable for rollback. Match
26     * the sweep TTL so both expire on the same clock.
27     */
28    const LIFETIME         = HOUR_IN_SECONDS;
29    const STALE_BACKUP_TTL = HOUR_IN_SECONDS;
30
31    /**
32     * Capture and persist the snapshot for $plugin_file.
33     *
34     * @param string $plugin_file Basename relative to WP_PLUGIN_DIR, e.g. "akismet/akismet.php".
35     * @return array{plugin_file:string,slug:string,version:string,was_active:bool,backup_path:string,timestamp:float}|null
36     *         The stored snapshot, or null when we lack enough info to make one.
37     */
38    public static function capture( $plugin_file ) {
39        $plugin_file = (string) $plugin_file;
40        if ( '' === $plugin_file ) {
41            return null;
42        }
43
44        if ( ! function_exists( 'get_plugin_data' ) ) {
45            require_once ABSPATH . 'wp-admin/includes/plugin.php';
46        }
47
48        $abs = WP_PLUGIN_DIR . '/' . $plugin_file;
49        if ( ! is_file( $abs ) ) {
50            return null;
51        }
52
53        $data = get_plugin_data( $abs, false, false );
54
55        $snapshot = array(
56            'plugin_file' => $plugin_file,
57            'slug'        => self::slug_from_file( $plugin_file ),
58            'version'     => (string) ( $data['Version'] ?? '' ),
59            'was_active'  => is_plugin_active( $plugin_file ),
60            'backup_path' => self::create_backup( $plugin_file ),
61            'timestamp'   => microtime( true ),
62        );
63
64        set_transient( self::transient_key( $plugin_file ), $snapshot, self::LIFETIME );
65
66        return $snapshot;
67    }
68
69    /**
70     * Read the snapshot for $plugin_file, consuming it (single use).
71     *
72     * @param string $plugin_file Basename relative to WP_PLUGIN_DIR.
73     * @return array|null
74     */
75    public static function consume( $plugin_file ) {
76        $key  = self::transient_key( (string) $plugin_file );
77        $data = get_transient( $key );
78        delete_transient( $key );
79        return is_array( $data ) ? $data : null;
80    }
81
82    /**
83     * Copy the plugin's current on-disk files to an isolated backup
84     * directory under `wp-content/upgrade/pcg-backups/<unique>/`.
85     *
86     * The plugin asset (its directory for dir-style plugins, its file
87     * for single-file plugins) is copied to the same name inside the
88     * backup dir, so rollback knows where to find it via $backup_path.
89     *
90     * @param string $plugin_file Basename relative to WP_PLUGIN_DIR.
91     * @return string Absolute path to the backup root, or '' on failure.
92     */
93    public static function create_backup( $plugin_file ) {
94        $asset_name = self::asset_name( $plugin_file );
95        if ( '' === $asset_name ) {
96            return '';
97        }
98        $src = WP_PLUGIN_DIR . '/' . $asset_name;
99        $fs  = self::fs();
100        if ( ! $fs || ! $fs->exists( $src ) ) {
101            return '';
102        }
103
104        $root = self::backup_root();
105        if ( '' === $root || ! wp_mkdir_p( $root ) ) {
106            return '';
107        }
108
109        // Sweep orphaned backups left behind when a previous update
110        // captured a snapshot but `upgrader_process_complete` never
111        // fired (upgrader fatal, redirect, etc.).
112        self::sweep_stale_backups();
113
114        $dest_root = $root . '/' . md5( uniqid( '', true ) );
115        if ( ! wp_mkdir_p( $dest_root ) ) {
116            return '';
117        }
118
119        $dest = $dest_root . '/' . $asset_name;
120        if ( $fs->is_file( $src ) ) {
121            if ( ! $fs->copy( $src, $dest, true ) ) {
122                $fs->delete( $dest_root, true );
123                return '';
124            }
125            return $dest_root;
126        }
127        if ( ! wp_mkdir_p( $dest ) || true !== copy_dir( $src, $dest ) ) {
128            $fs->delete( $dest_root, true );
129            return '';
130        }
131
132        return $dest_root;
133    }
134
135    /**
136     * Drop the backup directory associated with this snapshot, if any.
137     *
138     * @param array $snapshot Snapshot.
139     * @return void
140     */
141    public static function cleanup_backup( $snapshot ) {
142        $path = is_array( $snapshot ) ? (string) ( $snapshot['backup_path'] ?? '' ) : '';
143        if ( '' === $path ) {
144            return;
145        }
146        // Sanity: only delete things we own under the backup root.
147        $root = self::backup_root();
148        if ( '' === $root || 0 !== strpos( $path, $root . '/' ) ) {
149            return;
150        }
151        $fs = self::fs();
152        if ( $fs ) {
153            $fs->delete( $path, true );
154        }
155    }
156
157    /**
158     * Delete backup subdirectories under the backup root that are older
159     * than {@see self::STALE_BACKUP_TTL}. Cheap to run — the root is
160     * shallow and only touched during plugin updates.
161     *
162     * Only entries whose names look like our own md5-named dirs are
163     * considered, so a misconfigured `pcg_backup_root` filter pointing
164     * at a shared directory can't trash unrelated files.
165     *
166     * @return void
167     */
168    public static function sweep_stale_backups() {
169        $root = self::backup_root();
170        if ( '' === $root || ! is_dir( $root ) ) {
171            return;
172        }
173        $fs = self::fs();
174        if ( ! $fs ) {
175            return;
176        }
177        $entries = @scandir( $root ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- scandir on a missing/unreadable dir is non-fatal here.
178        if ( ! is_array( $entries ) ) {
179            return;
180        }
181        $cutoff = time() - self::STALE_BACKUP_TTL;
182        foreach ( $entries as $entry ) {
183            if ( ! preg_match( '/^[a-f0-9]{32}$/', $entry ) ) {
184                continue;
185            }
186            $path  = $root . '/' . $entry;
187            $mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- transient FS races shouldn't warn.
188            if ( false === $mtime || $mtime > $cutoff ) {
189                continue;
190            }
191            $fs->delete( $path, true );
192        }
193    }
194
195    /**
196     * Resolve the backup root directory. Defaults to a `pcg-backups`
197     * folder inside WordPress's temp dir (`get_temp_dir()` — typically
198     * the system tmpdir, falling back to `wp-content/uploads/` only
199     * when nothing else is writable). Override via `pcg_backup_root`.
200     *
201     * @return string Absolute path with trailing slash trimmed; '' to disable backups.
202     */
203    public static function backup_root() {
204        if ( ! function_exists( 'get_temp_dir' ) ) {
205            require_once ABSPATH . 'wp-admin/includes/file.php';
206        }
207        $default = rtrim( get_temp_dir(), '/' ) . '/' . self::BACKUP_DIRNAME;
208        /**
209         * Filter the directory where pre-update plugin backups are
210         * staged. Return an absolute path; an empty string disables
211         * the local-backup rollback path entirely.
212         *
213         * @param string $default Absolute default path.
214         */
215        $root = (string) apply_filters( 'pcg_backup_root', $default );
216        return rtrim( $root, '/' );
217    }
218
219    /**
220     * The plugin "asset" path relative to WP_PLUGIN_DIR. For dir-style
221     * plugins that's the slug directory; for single-file plugins it's
222     * the file itself.
223     *
224     * @param string $plugin_file Basename relative to WP_PLUGIN_DIR.
225     * @return string
226     */
227    public static function asset_name( $plugin_file ) {
228        $plugin_file = (string) $plugin_file;
229        if ( '' === $plugin_file ) {
230            return '';
231        }
232        if ( false !== strpos( $plugin_file, '/' ) ) {
233            return self::slug_from_file( $plugin_file );
234        }
235        return $plugin_file;
236    }
237
238    /**
239     * Derive the plugin slug (directory name) from the plugin_file basename.
240     * Single-file plugins (e.g. "hello.php") use the stem.
241     *
242     * @param string $plugin_file Basename relative to WP_PLUGIN_DIR.
243     * @return string
244     */
245    public static function slug_from_file( $plugin_file ) {
246        $plugin_file = (string) $plugin_file;
247        if ( false !== strpos( $plugin_file, '/' ) ) {
248            return dirname( $plugin_file );
249        }
250        return pathinfo( $plugin_file, PATHINFO_FILENAME );
251    }
252
253    /**
254     * Transient key for a plugin_file snapshot.
255     *
256     * @param string $plugin_file Basename relative to WP_PLUGIN_DIR.
257     * @return string
258     */
259    public static function transient_key( $plugin_file ) {
260        return 'pcg_snap_' . md5( (string) $plugin_file );
261    }
262
263    /**
264     * Lazy-init WP_Filesystem and return the global instance, or null
265     * when initialization fails (e.g. credentialed FTP not configured).
266     *
267     * Inside upgrader hooks, core has already initialized WP_Filesystem,
268     * so this is effectively a no-op fetch in that context.
269     *
270     * @return WP_Filesystem_Base|null
271     */
272    protected static function fs() {
273        global $wp_filesystem;
274        if ( $wp_filesystem ) {
275            return $wp_filesystem;
276        }
277        if ( ! function_exists( 'WP_Filesystem' ) ) {
278            require_once ABSPATH . 'wp-admin/includes/file.php';
279        }
280        WP_Filesystem();
281        return $wp_filesystem;
282    }
283}