/files/ (plugins one by one, never our own folder; mu-plugins only for the entries the * archive brings, so the target host's own mu-plugins stay), and the swap map that kept the old tables as mmold_ is * recorded. rollback() puts both back; discard() drops them once the owner keeps the site or the hourly cron finds * the snapshot expired, and the cron never discards one whose import is not finished or rolled back. * * Nothing large is deleted inside a request: what rollback() takes out of the content dir is renamed into * rollback//trash-, and discard() renames the whole job folder into rollback/.trash-- (snapshot.json * removed first, so an interrupted discard never leaves a rollback that restores half-deleted originals). * purge_trash() deletes trash from the hourly cron, in chunks within a time budget. * * The swap, rollback() and discard() run under a site-wide lock (/site.lock), and rollback() refuses while a * newer import's snapshot is still live: undoing an older import then would take the newer site's tables. * * Progress lives in rollback//snapshot.json, written before and after every rename, and every rollback step * checks the disk before acting, so a crash at any point leaves a state rollback() can finish, and a second * rollback() is a no-op. Data only ever moves by rename(); a cross-device move fails instead of copying. * * @package Mench_Migrate */ defined( 'ABSPATH' ) || exit; // phpcs:disable WordPress.WP.AlternativeFunctions -- Streams backups, uploads and job files (often several GB) chunk by chunk inside the plugin's own storage folder and the site being restored; WP_Filesystem has no streaming or locking API. // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are internal machine codes; screens and REST answers show only the escaped sentence from Mench_Migrate_Rest::message(). class Mench_Migrate_Snapshot { public const CRON = 'mench_migrate_cleanup'; /** Site-wide lock file in the storage root. */ public const SITE_LOCK = 'site.lock'; /** wp-content groups merged entry by entry; plugins also keep our own folder, mu-plugins the target's other entries. */ private const MERGED = [ 'plugins', 'mu-plugins' ]; private const STEP = 'snapshot'; /** Nesting depth of the site lock held by this process, and its handle. */ private static int $site_depth = 0; /** @var resource|null */ private static $site_handle = null; private string $job_id; /** Decoded snapshot.json; [] until the first write. */ private array $state = []; public function __construct( string $job_id ) { if ( ! preg_match( '/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $job_id ) || false !== strpos( $job_id, '..' ) ) { throw new Mench_Migrate_Exception( 'invalid_job_id', self::STEP ); } $this->job_id = $job_id; $file = $this->state_file(); if ( is_file( $file ) ) { $state = json_decode( (string) file_get_contents( $file ), true ); if ( ! is_array( $state ) || ( $state['job_id'] ?? null ) !== $job_id || ! is_array( $state['moved'] ?? null ) ) { throw new Mench_Migrate_Exception( 'bad_snapshot', self::STEP ); } $this->state = $state; } } /** * Renames each named wp-content entry aside. `plugins` moves entry by entry, leaving our own folder; `mu-plugins` * moves only the entries named in $entries['mu-plugins'] (those the archive brings), keeping the target's others. * Names the target does not have are recorded so rollback() removes what the import creates there. * * @param string[] $top_dirs Top-level groups of the archive. * @param array $entries For mu-plugins: the archive's entry names. */ public function move_aside( array $top_dirs, array $entries = [] ): void { $names = []; foreach ( $top_dirs as $name ) { $names[ self::check_name( $name ) ] = true; } $mu = []; foreach ( (array) ( $entries['mu-plugins'] ?? [] ) as $child ) { $mu[ self::check_name( $child ) ] = true; } $this->init_state(); if ( ! empty( $this->state['complete'] ) ) { return; // Extraction may have begun: whatever is in place now is the import's, never an original. } $content = self::content_dir(); foreach ( array_keys( $names ) as $name ) { if ( ! in_array( $name, $this->state['groups'], true ) ) { $this->state['groups'][] = $name; } if ( in_array( $name, $this->state['absent'], true ) ) { continue; } if ( in_array( $name, self::MERGED, true ) ) { if ( ! is_dir( $content . '/' . $name ) ) { $this->state['absent'][] = $name; continue; } if ( 'mu-plugins' === $name ) { $this->state['entries']['mu-plugins'] = array_keys( $mu ); $this->save(); } $children = 'plugins' === $name ? self::plugin_entries( $content ) : array_keys( $mu ); foreach ( $children as $child ) { if ( 'plugins' === $name || self::exists( $content . '/mu-plugins/' . $child ) || null !== $this->recorded( 'mu-plugins/' . $child ) ) { $this->move( $name . '/' . $child ); } } continue; } if ( self::exists( $content . '/' . $name ) || $this->recorded( $name ) ) { $this->move( $name ); } elseif ( ! in_array( $name, $this->state['absent'], true ) ) { $this->state['absent'][] = $name; } } $this->state['complete'] = true; $this->save(); } /** * Records the map Db_Importer::plan_swap() planned (before the RENAME) or swap() returned. * * @param array $swap_map */ public function record_db( array $swap_map, ?string $target_prefix = null ): void { global $wpdb; $this->init_state(); $this->state['db_map'] = $swap_map; $this->state['target_prefix'] = $target_prefix ?? (string) $wpdb->prefix; $this->state['db_restored'] = false; $this->save(); } /** * Puts the files back, then the tables, then drops the imported tables. Safe to run again. Runs under the site * lock, and refuses (newer_import_exists) while a newer import's snapshot is live. * * @throws Mench_Migrate_Exception no_snapshot, newer_import_exists, site_busy and the rename errors. */ public function rollback(): void { if ( ! $this->state ) { throw new Mench_Migrate_Exception( 'no_snapshot', self::STEP ); } if ( ! empty( $this->state['rolled_back'] ) ) { return; } self::with_site_lock( function (): void { $this->reload(); if ( ! empty( $this->state['rolled_back'] ) ) { return; } if ( null !== $this->newer_snapshot() ) { throw new Mench_Migrate_Exception( 'newer_import_exists', self::STEP ); } $this->do_rollback(); } ); } private function do_rollback(): void { global $wpdb; $content = self::content_dir(); // Only once every original is aside can anything left in its place be the import's. if ( ! empty( $this->state['complete'] ) && empty( $this->state['cleaned'] ) ) { foreach ( (array) $this->state['absent'] as $name ) { if ( ! in_array( $name, self::MERGED, true ) ) { $this->trash( $content . '/' . self::check_name( (string) $name ) ); } } if ( in_array( 'plugins', (array) $this->state['groups'], true ) ) { foreach ( self::plugin_entries( $content ) as $child ) { $this->trash( $content . '/plugins/' . $child ); } } if ( in_array( 'mu-plugins', (array) $this->state['groups'], true ) ) { if ( in_array( 'mu-plugins', (array) $this->state['absent'], true ) ) { $this->trash( $content . '/mu-plugins' ); // Created by the import: nothing of the target's is in it. } else { foreach ( (array) ( $this->state['entries']['mu-plugins'] ?? [] ) as $child ) { $this->trash( $content . '/mu-plugins/' . self::check_name( (string) $child ) ); } } } // A plugins folder the import created goes too, unless it now holds our own folder. if ( in_array( 'plugins', (array) $this->state['absent'], true ) && is_dir( $content . '/plugins' ) && ! is_link( $content . '/plugins' ) && [] === array_diff( (array) scandir( $content . '/plugins' ), [ '.', '..' ] ) ) { $this->trash( $content . '/plugins' ); } $this->state['cleaned'] = true; $this->save(); } foreach ( array_reverse( array_keys( $this->state['moved'] ) ) as $i ) { $m = $this->state['moved'][ $i ]; $rel = self::check_rel( (string) ( $m['from_rel'] ?? '' ) ); $from = $this->files_dir() . '/' . $rel; if ( ( $m['to_abs'] ?? null ) !== $from ) { throw new Mench_Migrate_Exception( 'bad_snapshot', self::STEP ); } if ( self::exists( $from ) ) { $to = $content . '/' . $rel; if ( self::exists( $to ) ) { $this->trash( $to ); } self::rename( $from, $to ); } $this->state['moved'][ $i ]['restored'] = true; $this->save(); } if ( defined( 'MENCH_MIGRATE_TESTING' ) && apply_filters( 'mench_migrate_test_rollback_fail', false, $this->job_id ) ) { throw new Mench_Migrate_Exception( 'injected_rollback_failure', self::STEP ); // Files back, tables not yet. } if ( is_array( $this->state['db_map'] ?? null ) && empty( $this->state['db_restored'] ) ) { $prefix = (string) $this->state['target_prefix']; $map = []; foreach ( $this->state['db_map'] as $live => $old ) { // One RENAME restores every mmold_ table at once, so a missing one means a finished earlier run. if ( null === $old || self::table_exists( $wpdb, (string) $old ) ) { $map[ (string) $live ] = $old; } } if ( $map ) { Mench_Migrate_Db_Importer::rollback_swap( $wpdb, $map, $prefix, $this->job_id ); } $this->state['db_restored'] = true; $this->save(); } Mench_Migrate_Db_Importer::drop_job_tables( $wpdb, $this->job_id, 'mmtmp' ); $this->state['rolled_back'] = true; $this->save(); } /** Whether rollback() has been run to its end. */ public function is_rolled_back(): bool { return ! empty( $this->state['rolled_back'] ); } /** Whether a rollback started (the import's entries were taken out) but has not finished. */ public function is_mid_rollback(): bool { return ! empty( $this->state['cleaned'] ) && empty( $this->state['rolled_back'] ); } /** * The job id of a live snapshot (not rolled back) taken after this one, or null. Ordered by the microsecond * timestamp recorded at creation (older snapshots: created, then the job id). */ public function newer_snapshot(): ?string { $mine = $this->order_key( $this->state, $this->job_id ); $root = Mench_Migrate_Paths::rollback(); foreach ( (array) @scandir( $root ) as $name ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! is_string( $name ) || '' === $name || '.' === $name[0] || $name === $this->job_id || is_link( $root . '/' . $name ) || ! is_file( $root . '/' . $name . '/snapshot.json' ) ) { continue; } $other = json_decode( (string) file_get_contents( $root . '/' . $name . '/snapshot.json' ), true ); if ( ! is_array( $other ) || ! empty( $other['rolled_back'] ) || ( $other['job_id'] ?? null ) !== $name ) { continue; } if ( $this->order_key( $other, $name ) > $mine ) { return $name; } } return null; } /** Sortable creation key: microseconds, then the job id. */ private function order_key( array $state, string $job_id ): string { $seq = isset( $state['seq'] ) ? (float) $state['seq'] : (float) ( $state['created'] ?? 0 ); return sprintf( '%020.6f|%s', $seq, $job_id ); } /** Created time plus keep_rollback_hours (setting, default 24, at least 1). */ public function expires_at(): int { $settings = get_option( 'mench_migrate_settings', [] ); $hours = is_array( $settings ) && isset( $settings['keep_rollback_hours'] ) ? (int) $settings['keep_rollback_hours'] : 24; return (int) ( $this->state['created'] ?? time() ) + max( 1, $hours ) * HOUR_IN_SECONDS; } /** * Drops this job's rollback: snapshot.json goes first (so an interrupted discard never leaves a rollback that would * restore half-deleted originals), then the mmold_ tables; the folder is renamed into rollback/.trash-- * for the hourly cron to delete. Runs under the site lock. */ public function discard(): void { self::with_site_lock( function (): void { global $wpdb; $dir = $this->job_dir(); if ( is_file( $this->state_file() ) && ! @unlink( $this->state_file() ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors throw new Mench_Migrate_Exception( 'delete_failed', self::STEP ); } Mench_Migrate_Db_Importer::drop_job_tables( $wpdb, $this->job_id, 'mmold' ); if ( is_dir( $dir ) && ! is_link( $dir ) ) { $trash = self::free_name( Mench_Migrate_Paths::rollback() . '/.trash-' . $this->job_id . '-' ); if ( ! @rename( $dir, $trash ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions.rename_rename self::remove_tree( $dir ); // Cannot rename in place: delete it now. } } $this->state = []; } ); } /** * Cron callback: discards each snapshot that is rolled back, or past its expiry with its import finished (done, not * mid-rollback, no failed rollback). A snapshot whose import failed or was cancelled after the switch, or whose * rollback failed, is never discarded: it holds the original site until the owner rolls it back. A snapshot whose job * is unknown is kept too. */ public static function cleanup_expired(): void { $root = Mench_Migrate_Paths::rollback(); if ( ! is_dir( $root ) ) { return; } foreach ( (array) scandir( $root ) as $name ) { if ( ! is_string( $name ) || '' === $name || '.' === $name[0] || ! is_dir( $root . '/' . $name ) || is_link( $root . '/' . $name ) || ! is_file( $root . '/' . $name . '/snapshot.json' ) ) { continue; } try { $snap = new self( $name ); if ( $snap->is_rolled_back() || ( $snap->expires_at() < time() && $snap->expirable() ) ) { $snap->discard(); } } catch ( Mench_Migrate_Exception $e ) { continue; // A damaged or busy snapshot is left for the owner or the next run; the others still get cleaned. } } } /** Whether the import this snapshot belongs to is finished, so the snapshot may expire. */ private function expirable(): bool { if ( $this->is_mid_rollback() ) { return false; } try { $job = Mench_Migrate_Job::load( $this->job_id ); } catch ( Mench_Migrate_Exception $e ) { return false; // No job state to tell whether the import finished: keep the originals. } if ( 'rolled_back' === $job->status ) { return true; } return 'done' === $job->status && empty( $job->data['rollback_failed'] ); } /** * Deletes trash (rollback/.trash-* and rollback//trash-*) within $seconds; what is left waits for the next * run. Returns true when no trash remains. */ public static function purge_trash( float $seconds = 20.0 ): bool { $root = Mench_Migrate_Paths::rollback(); if ( ! is_dir( $root ) ) { return true; } $deadline = microtime( true ) + $seconds; foreach ( (array) scandir( $root ) as $name ) { if ( ! is_string( $name ) || '.' === $name || '..' === $name || is_link( $root . '/' . $name ) || ! is_dir( $root . '/' . $name ) ) { continue; } $doomed = []; if ( 0 === strpos( $name, '.trash-' ) ) { $doomed[] = $root . '/' . $name; } elseif ( '.' !== $name[0] ) { foreach ( (array) scandir( $root . '/' . $name ) as $child ) { if ( is_string( $child ) && 0 === strpos( $child, 'trash-' ) ) { $doomed[] = $root . '/' . $name . '/' . $child; } } } foreach ( $doomed as $path ) { if ( ! self::remove_within( $path, $deadline ) ) { return false; } } } return true; } /** Deletes a tree without following symlinks until $deadline; true when it is gone. */ private static function remove_within( string $path, float $deadline ): bool { if ( is_link( $path ) || is_file( $path ) ) { return @unlink( $path ) || ! self::exists( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors } if ( ! is_dir( $path ) ) { return true; } $h = @opendir( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! $h ) { return false; } $done = true; while ( false !== ( $child = readdir( $h ) ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition if ( '.' === $child || '..' === $child ) { continue; } if ( microtime( true ) >= $deadline || ! self::remove_within( $path . '/' . $child, $deadline ) ) { $done = false; break; } } closedir( $h ); return $done && ( @rmdir( $path ) || ! is_dir( $path ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors } /** * Runs $fn under the site-wide lock (re-entrant within this process), waiting up to $wait seconds for it * (default: filter mench_migrate_site_lock_wait, 10 s). * * @throws Mench_Migrate_Exception site_busy when another process holds it. * @return mixed What $fn returns. */ public static function with_site_lock( callable $fn, ?float $wait = null ) { if ( ! self::lock_site( $wait ) ) { throw new Mench_Migrate_Exception( 'site_busy', self::STEP ); } try { return $fn(); } finally { self::unlock_site(); } } /** Takes the site lock (re-entrant), waiting up to $wait seconds; false when another process holds it. */ public static function lock_site( ?float $wait = null ): bool { if ( self::$site_depth > 0 ) { self::$site_depth++; return true; } $wait = $wait ?? (float) apply_filters( 'mench_migrate_site_lock_wait', 10.0 ); $h = @fopen( Mench_Migrate_Paths::root() . '/' . self::SITE_LOCK, 'c' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! $h ) { throw new Mench_Migrate_Exception( 'site_lock_failed', self::STEP ); } $until = microtime( true ) + max( 0.0, $wait ); while ( ! flock( $h, LOCK_EX | LOCK_NB ) ) { if ( microtime( true ) >= $until ) { fclose( $h ); return false; } usleep( 100000 ); } self::$site_handle = $h; self::$site_depth = 1; return true; } public static function unlock_site(): void { if ( self::$site_depth <= 0 ) { return; } if ( --self::$site_depth > 0 ) { return; } if ( self::$site_handle ) { flock( self::$site_handle, LOCK_UN ); fclose( self::$site_handle ); } self::$site_handle = null; } /** Schedules the hourly cleanup when it is missing. */ public static function schedule(): void { if ( ! wp_next_scheduled( self::CRON ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::CRON ); } } public static function unschedule(): void { wp_clear_scheduled_hook( self::CRON ); } /** Renames content/ into files/, recording it before and after. */ private function move( string $rel ): void { $src = self::content_dir() . '/' . $rel; $dst = $this->files_dir() . '/' . $rel; $i = $this->recorded( $rel ); if ( null !== $i ) { $src_there = self::exists( $src ); $dst_there = self::exists( $dst ); if ( $src_there && $dst_there ) { throw new Mench_Migrate_Exception( 'snapshot_conflict', self::STEP ); } if ( ! $src_there ) { // Renamed before a crash: just record it. $this->state['moved'][ $i ]['done'] = true; $this->save(); return; } } else { $this->state['moved'][] = [ 'from_rel' => $rel, 'to_abs' => $dst, 'done' => false ]; $i = array_key_last( $this->state['moved'] ); $this->save(); } if ( ! is_dir( dirname( $dst ) ) && ! wp_mkdir_p( dirname( $dst ) ) ) { throw new Mench_Migrate_Exception( 'move_failed', self::STEP ); } self::rename( $src, $dst ); $this->state['moved'][ $i ]['done'] = true; $this->save(); } /** rename() only: a move across devices throws cross_device and is never turned into a copy. */ private static function rename( string $from, string $to ): void { if ( ! is_dir( dirname( $to ) ) && ! wp_mkdir_p( dirname( $to ) ) ) { throw new Mench_Migrate_Exception( 'move_failed', self::STEP ); } if ( ! self::same_device( dirname( $from ), dirname( $to ) ) ) { throw new Mench_Migrate_Exception( 'cross_device', self::STEP ); } if ( ! @rename( $from, $to ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions.rename_rename $err = (string) ( error_get_last()['message'] ?? '' ); throw new Mench_Migrate_Exception( preg_match( '/cross-device|EXDEV|across/i', $err ) ? 'cross_device' : 'move_failed', self::STEP ); } } /** Whether two existing folders sit on one device; stat() follows symlinks, so a linked plugins/ counts where it points. */ public static function same_device( string $a, string $b ): bool { $sa = @stat( $a ); // phpcs:ignore WordPress.PHP.NoSilencedErrors $sb = @stat( $b ); // phpcs:ignore WordPress.PHP.NoSilencedErrors return $sa && $sb && $sa['dev'] === $sb['dev']; } /** * Takes a file, link or folder out of the content dir by renaming it into rollback//trash- (instant; the * cron deletes trash). Only a path inside the content dir; deleted in place when the rename cannot work. */ private function trash( string $path ): void { if ( 0 !== strpos( $path, rtrim( self::content_dir(), '/' ) . '/' ) ) { throw new Mench_Migrate_Exception( 'unsafe_path', self::STEP ); } if ( ! self::exists( $path ) ) { return; } $dir = $this->job_dir(); if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { throw new Mench_Migrate_Exception( 'move_failed', self::STEP ); } $to = self::free_name( $dir . '/trash-' ); if ( self::same_device( dirname( $path ), $dir ) && @rename( $path, $to ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions.rename_rename return; } self::remove_tree( $path ); } /** $prefix followed by the first free number. */ private static function free_name( string $prefix ): string { $n = 1; while ( self::exists( $prefix . $n ) ) { $n++; } return $prefix . $n; } private static function remove_tree( string $path ): void { if ( is_link( $path ) || is_file( $path ) ) { if ( ! @unlink( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors throw new Mench_Migrate_Exception( 'delete_failed', self::STEP ); } return; } if ( ! is_dir( $path ) ) { return; } foreach ( (array) scandir( $path ) as $child ) { if ( '.' !== $child && '..' !== $child ) { self::remove_tree( $path . '/' . $child ); } } if ( ! @rmdir( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors throw new Mench_Migrate_Exception( 'delete_failed', self::STEP ); } } private function init_state(): void { global $wpdb; if ( $this->state ) { return; } $created = time(); $this->state = [ 'job_id' => $this->job_id, 'created' => $created, 'seq' => microtime( true ), 'expires' => 0, 'groups' => [], 'moved' => [], 'absent' => [], 'entries' => [], 'complete' => false, 'cleaned' => false, 'db_map' => null, 'target_prefix' => (string) $wpdb->prefix, 'db_restored' => false, 'rolled_back' => false, ]; $this->state['expires'] = $this->expires_at(); } /** Re-reads snapshot.json (another process may have changed it while this one waited for the site lock). */ private function reload(): void { $file = $this->state_file(); if ( is_file( $file ) ) { $state = json_decode( (string) file_get_contents( $file ), true ); if ( is_array( $state ) && ( $state['job_id'] ?? null ) === $this->job_id && is_array( $state['moved'] ?? null ) ) { $this->state = $state; } } } /** Writes snapshot.json atomically. */ private function save(): void { $dir = $this->job_dir(); if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { throw new Mench_Migrate_Exception( 'state_write_failed', self::STEP ); } $tmp = $this->state_file() . '.tmp'; if ( false === file_put_contents( $tmp, (string) wp_json_encode( $this->state, JSON_UNESCAPED_SLASHES ) ) || ! rename( $tmp, $this->state_file() ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions throw new Mench_Migrate_Exception( 'state_write_failed', self::STEP ); } } private function recorded( string $rel ): ?int { foreach ( $this->state['moved'] ?? [] as $i => $m ) { if ( ( $m['from_rel'] ?? null ) === $rel ) { return (int) $i; } } return null; } private function job_dir(): string { return Mench_Migrate_Paths::rollback() . '/' . $this->job_id; } private function files_dir(): string { return $this->job_dir() . '/files'; } private function state_file(): string { return $this->job_dir() . '/snapshot.json'; } private static function content_dir(): string { return rtrim( (string) apply_filters( 'mench_migrate_content_dir', WP_CONTENT_DIR ), '/' ); } /** Entries of content/plugins that are not our own folder (compared by name: it may be a symlink). */ private static function plugin_entries( string $content ): array { $dir = $content . '/plugins'; if ( ! is_dir( $dir ) ) { return []; } $own = [ MENCH_MIGRATE_SLUG, basename( untrailingslashit( MENCH_MIGRATE_DIR ) ) ]; $out = []; foreach ( (array) scandir( $dir ) as $child ) { if ( is_string( $child ) && '.' !== $child && '..' !== $child && ! in_array( $child, $own, true ) ) { $out[] = $child; } } return $out; } /** A top-level name: no separator, no "..", no NUL, never the storage root. */ private static function check_name( $name ): string { $name = is_string( $name ) ? $name : ''; if ( '' === $name || '.' === $name || false !== strpbrk( $name, "/\\\0" ) || false !== strpos( $name, '..' ) || basename( Mench_Migrate_Paths::root() ) === $name ) { throw new Mench_Migrate_Exception( 'unsafe_name', self::STEP ); } return $name; } /** A recorded from_rel: a top-level name, or plugins/ or mu-plugins/. */ private static function check_rel( string $rel ): string { $parts = explode( '/', $rel, 2 ); if ( 2 === count( $parts ) ) { if ( ! in_array( $parts[0], self::MERGED, true ) ) { throw new Mench_Migrate_Exception( 'bad_snapshot', self::STEP ); } self::check_name( $parts[1] ); return $rel; } return self::check_name( $rel ); } private static function exists( string $path ): bool { return file_exists( $path ) || is_link( $path ); } private static function table_exists( wpdb $db, string $table ): bool { return in_array( $table, (array) $db->get_col( $db->prepare( 'SHOW TABLES LIKE %s', $db->esc_like( $table ) ) ), true ); } }