[ 'jpg', 'jpeg', 'jpe' ], 'image/png' => [ 'png' ], 'image/gif' => [ 'gif' ], 'image/webp' => [ 'webp' ], 'image/avif' => [ 'avif' ], 'image/svg+xml' => [ 'svg' ], 'application/pdf' => [ 'pdf' ], 'audio/mpeg' => [ 'mp3' ], 'audio/mp4' => [ 'm4a' ], 'audio/ogg' => [ 'ogg', 'oga' ], 'audio/wav' => [ 'wav' ], 'audio/x-wav' => [ 'wav' ], 'audio/flac' => [ 'flac' ], 'video/mp4' => [ 'mp4', 'm4v' ], 'video/quicktime' => [ 'mov', 'qt' ], 'video/webm' => [ 'webm' ], 'video/ogg' => [ 'ogv' ], ]; public static function inventory(): array { require_once ABSPATH . 'wp-admin/includes/plugin.php'; $plugins = []; foreach ( get_plugins() as $file => $data ) { if ( self::excluded_plugin( $file ) ) { continue; } $plugins[] = [ 'file' => $file, 'name' => $data['Name'], 'version' => $data['Version'], 'active' => is_plugin_active( $file ) || is_plugin_active_for_network( $file ), 'requiresPHP' => $data['RequiresPHP'] ]; } $themes = []; foreach ( wp_get_themes() as $slug => $theme ) { $themes[] = [ 'slug' => $slug, 'name' => $theme->get( 'Name' ), 'version' => $theme->get( 'Version' ), 'template' => $theme->get_template() ]; } return [ 'wp' => get_bloginfo( 'version' ), 'php' => PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, 'plugins' => $plugins, 'themes' => $themes, 'theme' => get_stylesheet() ]; } private static function excluded_plugin( string $file ): bool { $self = plugin_basename( SMOOTH_SANDBOX_FILE ); // Exclude the complete package, including other entry points and renamed installation directories. return $file === $self || ( '.' !== dirname( $self ) && dirname( $file ) === dirname( $self ) ); } public static function credential_inventory( array $selection ): array { if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'install_plugins' ) || ! current_user_can( 'edit_theme_options' ) || is_multisite() ) { throw new \RuntimeException( 'Administrator access to an individual WordPress site is required.' ); } $plugins = $selection['plugins'] ?? []; $installed = array_column( self::inventory()['plugins'], null, 'file' ); if ( ! is_array( $plugins ) || count( $plugins ) > 200 ) { throw new \RuntimeException( 'Invalid plugin selection.' ); } foreach ( $plugins as $plugin ) { if ( ! is_array( $plugin ) || ! is_string( $plugin['file'] ?? null ) || ! isset( $installed[ $plugin['file'] ] ) ) { throw new \RuntimeException( 'Unknown selected plugin.' ); } } $users = []; foreach ( self::ids( $selection['users'] ?? [] ) as $id ) { if ( ! current_user_can( 'edit_user', $id ) || ! get_userdata( $id ) ) { throw new \RuntimeException( 'You cannot inspect credentials for a selected user.' ); } $users[] = [ 'id' => $id ]; } // Discovery never writes a snapshot or returns database rows, passwords, or credential values. return Database::snapshot( [ 'plugins' => $plugins, 'privateContent' => ! empty( $selection['privateContent'] ) ], $users, true )['credentials']; } private static function ids( $values ): array { if ( ! is_array( $values ) || count( $values ) > 100 ) { throw new \RuntimeException( 'Invalid user selection.' ); } foreach ( $values as $id ) { if ( ! is_scalar( $id ) || ! ctype_digit( (string) $id ) || (int) $id < 1 ) { throw new \RuntimeException( 'Invalid user selection.' ); } } return array_values( array_unique( array_map( 'intval', $values ) ) ); } private static function safe_relative_path( string $path ): bool { if ( strlen( $path ) > 500 || preg_match( '#(^/|\\\\|[\x00-\x1f]|(^|/)\.{1,2}(/|$))#', $path ) ) { return false; } foreach ( explode( '/', $path ) as $part ) { if ( str_starts_with( $part, '.' ) || in_array( strtolower( $part ), [ 'node_modules', 'tests', 'test', 'logs', 'cache', 'backups', 'backup', 'uploads', 'integration', 'scripts', 'coverage' ], true ) ) { return false; } } if ( preg_match( '/(^|\/)(wp-config|credentials|secrets?|id_rsa|auth\.json|composer\.lock|package-lock\.json)(\.|$)/i', $path ) ) { return false; } return true; } public static function safe_path( string $path ): bool { return self::safe_relative_path( $path ) && (bool) preg_match( '/\.(php|js|mjs|cjs|css|json|html|htm|txt|md|xml|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf|eot|mo|po|map|wasm)$/i', $path ); } public static function safe_media_path( string $path, string $mime ): bool { return self::safe_relative_path( $path ) && in_array( strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ), self::MEDIA_TYPES[ $mime ] ?? [], true ); } private static function runtime_mu_file( string $path ): bool { return (bool) preg_match( '#^mu-plugins/((?:000-)?smooth-sandbox-guard\.php|pluginpeer-(history|recorder)\.(php|js))$#D', $path ); } private static function omit_runtime_mu_file( string $path ): bool { // These files belong to the current Playground session. The destination // creates fresh guards and recorder channels when it boots. return ( defined( 'PLAYGROUND_AUTO_LOGIN_AS_USER' ) || ( defined( 'SMOOTH_SANDBOX' ) && SMOOTH_SANDBOX ) ) && self::runtime_mu_file( $path ); } private static function files( string $root, string $prefix, array &$files, array &$paths, array &$omitted ): void { if ( is_link( $root ) ) { throw new \RuntimeException( 'Symbolic-link packages must be copied to a regular directory before export.' ); } $root = realpath( $root ); if ( ! $root || is_link( $root ) ) { throw new \RuntimeException( 'A package directory is unavailable.' ); } $iterator = new \RecursiveIteratorIterator( new \RecursiveCallbackFilterIterator( new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ), static function ( $item ) { if ( $item->isLink() ) { return false; } return ! $item->isDir() || ( ! str_starts_with( $item->getFilename(), '.' ) && ! in_array( strtolower( $item->getFilename() ), [ 'node_modules', 'tests', 'test', 'logs', 'cache', 'backups', 'backup', 'uploads', 'integration', 'scripts', 'coverage' ], true ) ); } ) ); foreach ( $iterator as $file ) { $relative = substr( $file->getPathname(), strlen( $root ) + 1 ); if ( self::omit_runtime_mu_file( $prefix . '/' . $relative ) ) { $omitted['package_files']++; continue; } if ( ! self::safe_path( $relative ) ) { $omitted['package_files']++; continue; } self::file( $file->getPathname(), $prefix . '/' . $relative, $files, $paths ); } } private static function file( string $path, string $target, array &$files, array &$paths ): void { if ( isset( $paths[ $target ] ) ) { return; } if ( is_link( $path ) || ! is_file( $path ) || ! is_readable( $path ) ) { throw new \RuntimeException( 'A selected file is unavailable or is a symbolic link.' ); } $size = filesize( $path ); if ( $size > 20971520 ) { throw new \RuntimeException( 'A file exceeds 20 MiB. Exclude its package or media before retrying.' ); } $files[] = [ 'path' => $target, 'bytes' => $size, 'sha256' => hash_file( 'sha256', $path ) ]; $paths[ $target ] = $path; self::$file_bytes += $size; if ( count( $files ) > self::MAX_FILES || self::$file_bytes > self::MAX_BYTES ) { throw new \RuntimeException( 'The selected files exceed 256 MiB or 15,000 files. Narrow the selection; nothing was uploaded.' ); } } public static function build( array $selection, bool $with_paths = false, bool $all_themes = false, ?callable $progress = null ): array { if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'install_plugins' ) || ! current_user_can( 'edit_theme_options' ) ) { throw new \RuntimeException( 'Administrator access to plugins and themes is required.' ); } if ( is_multisite() ) { throw new \RuntimeException( 'Environment export currently supports individual WordPress installations only.' ); } self::$file_bytes = 0; $inventory = self::inventory(); $wp = $selection['wp'] ?? $inventory['wp']; $php = $selection['php'] ?? $inventory['php']; $versions = Runtime_Versions::get(); if ( ! in_array( $wp, $versions['wordpress'], true ) || ! in_array( $php, $versions['php'], true ) ) { throw new \RuntimeException( 'Choose WordPress and PHP versions from the PluginPeer catalogue.' ); } $files = []; $paths = []; $plugins = []; $themes = []; $omitted = [ 'package_files' => 0, 'media' => 0 ]; $installed = get_plugins(); $choices = $selection['plugins'] ?? []; if ( ! is_array( $choices ) || count( $choices ) > 200 ) { throw new \RuntimeException( 'Invalid plugin selection.' ); } foreach ( $choices as $choice_index => $choice ) { if ( $progress ) { $progress( 'plugins', $choice_index, count( $choices ), 'plugins' ); } if ( ! is_array( $choice ) || ! is_string( $choice['file'] ?? null ) || ! isset( $installed[ $choice['file'] ] ) ) { throw new \RuntimeException( 'Unknown selected plugin.' ); } $file = $choice['file']; $data = $installed[ $file ]; // Enforce this on the server too, for stale forms and manually supplied selections. if ( self::excluded_plugin( $file ) ) { continue; } $version = $data['Version']; if ( isset( $choice['version'] ) && $choice['version'] !== $version ) { throw new \RuntimeException( 'Plugins must use their installed version.' ); } if ( ! is_string( $version ) || ! preg_match( '/^[a-zA-Z0-9][a-zA-Z0-9.+_-]{0,49}$/D', $version ) ) { throw new \RuntimeException( 'Invalid plugin version.' ); } if ( $data['RequiresPHP'] && version_compare( $php, $data['RequiresPHP'], '<' ) ) { throw new \RuntimeException( 'A selected plugin requires a newer PHP version: ' . sanitize_text_field( $data['Name'] ) ); } $slug = dirname( $file ); $local = $version === $data['Version']; if ( ! $local && ( '.' === $slug || ! preg_match( '/^[a-z0-9-]+$/D', $slug ) ) ) { throw new \RuntimeException( 'This plugin needs its installed local version.' ); } $package_slug = '.' === $slug ? basename( $file, '.php' ) : $slug; // Root PHP filenames may contain dots or spaces. Their slug is only // metadata: preserve the actual filename and use a portable label. if ( '.' === $slug && ! preg_match( '/^[a-zA-Z0-9_-]{1,150}$/D', $package_slug ) ) { $package_slug = 'local-' . substr( hash( 'sha256', $file ), 0, 32 ); } $plugins[] = [ 'file' => $file, 'version' => $version, 'source' => $local ? 'local' : 'wordpress.org', 'active' => is_plugin_active( $file ), 'slug' => $package_slug ]; if ( $local ) { if ( '.' === $slug ) { self::file( WP_PLUGIN_DIR . '/' . $file, 'plugins/' . $file, $files, $paths ); } else { self::files( WP_PLUGIN_DIR . '/' . $slug, 'plugins/' . $slug, $files, $paths, $omitted ); } } } $theme_slug = $selection['theme'] ?? get_stylesheet(); if ( ! is_string( $theme_slug ) || ! isset( wp_get_themes()[ $theme_slug ] ) ) { throw new \RuntimeException( 'Choose an installed theme.' ); } $theme = wp_get_theme( $theme_slug ); foreach ( $all_themes ? array_keys( wp_get_themes() ) : array_unique( [ $theme->get_template(), $theme_slug ] ) as $slug ) { if ( $progress ) { $progress( 'themes', count( $themes ), $all_themes ? count( wp_get_themes() ) : count( array_unique( [ $theme->get_template(), $theme_slug ] ) ), 'themes' ); } $item = wp_get_theme( $slug ); $version = $item->get( 'Version' ); if ( $slug === $theme_slug && isset( $selection['themeVersion'] ) && $selection['themeVersion'] !== $version ) { throw new \RuntimeException( 'The theme must use its installed version.' ); } if ( ! is_string( $version ) || ! preg_match( '/^[a-zA-Z0-9][a-zA-Z0-9.+_-]{0,49}$/D', $version ) ) { throw new \RuntimeException( 'Invalid theme version.' ); } $local = $version === $item->get( 'Version' ); $themes[] = [ 'slug' => $slug, 'version' => $version, 'source' => $local ? 'local' : 'wordpress.org' ]; if ( $local ) { self::files( $item->get_stylesheet_directory(), 'themes/' . $slug, $files, $paths, $omitted ); } } if ( $progress ) { $progress( 'media', 0, 0, 'files' ); } $mu_plugins = []; if ( is_dir( WPMU_PLUGIN_DIR ) || is_link( WPMU_PLUGIN_DIR ) ) { self::files( WPMU_PLUGIN_DIR, 'mu-plugins', $files, $paths, $omitted ); foreach ( get_mu_plugins() as $file => $data ) { if ( self::omit_runtime_mu_file( 'mu-plugins/' . $file ) ) { continue; } if ( ! isset( $paths[ 'mu-plugins/' . $file ] ) ) { throw new \RuntimeException( 'A required MU plugin cannot be copied: ' . sanitize_text_field( $file ) ); } $mu_plugins[] = [ 'file' => $file, 'name' => $data['Name'], 'version' => $data['Version'] ]; } foreach ( array_keys( $paths ) as $path ) { if ( self::runtime_mu_file( $path ) ) { throw new \RuntimeException( 'An MU plugin conflicts with a Playground runtime file: ' . sanitize_text_field( $path ) ); } } } $password = $selection['sandboxPassword'] ?? ''; if ( ! is_string( $password ) || strlen( $password ) < 16 || strlen( $password ) > 128 ) { throw new \RuntimeException( 'Use a new sandbox password of 16–128 characters.' ); } $users = []; $selected_users = $selection['users'] ?? []; if ( ! is_array( $selected_users ) || count( $selected_users ) > 100 ) { throw new \RuntimeException( 'Invalid user selection.' ); } $seen = []; foreach ( $selected_users as $row ) { $id = self::ids( [ is_array( $row ) ? ( $row['id'] ?? null ) : $row ] )[0]; if ( isset( $seen[ $id ] ) ) { throw new \RuntimeException( 'Choose each user only once.' ); } $seen[ $id ] = true; $user_password = is_array( $row ) ? ( $row['password'] ?? '' ) : $password; if ( ! is_string( $user_password ) || strlen( $user_password ) < 16 || strlen( $user_password ) > 128 ) { throw new \RuntimeException( 'Each user needs a new password of 16–128 characters.' ); } if ( ! current_user_can( 'edit_user', $id ) ) { throw new \RuntimeException( 'You cannot export a selected user.' ); } $user = get_userdata( $id ); if ( ! $user ) { throw new \RuntimeException( 'A selected user no longer exists.' ); } if ( wp_check_password( $user_password, $user->user_pass, $id ) ) { throw new \RuntimeException( 'The sandbox password must differ from every selected source account password.' ); } // Account selection supplies new passwords; Database excludes source hashes and sessions. $users[] = [ 'id' => $id, 'login' => $user->user_login, 'name' => $user->display_name, 'password' => $user_password, 'roles' => array_values( array_intersect( $user->roles, [ 'administrator', 'editor', 'author', 'contributor', 'subscriber', 'customer', 'shop_manager' ] ) ) ]; } $posts = []; $terms = []; $options = [ 'blogname' => get_option( 'blogname' ) ]; $mods = []; $media = []; // The complete library is always selected, independently of post relationships or content status. $attachments = get_posts( [ 'post_type' => 'attachment', 'post_status' => [ 'inherit', 'publish', 'private', 'draft', 'pending', 'future' ], 'posts_per_page' => 5001, 'orderby' => 'ID', 'order' => 'ASC', 'suppress_filters' => true ] ); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- Bounded administrator snapshot must include unattached library items regardless of frontend filters. if ( count( $attachments ) > 5000 ) { throw new \RuntimeException( 'The media library exceeds 5,000 items. No partial copy was made.' ); } $media_report = [ 'total' => count( $attachments ), 'included' => 0, 'excluded' => [ 'permissions' => 0, 'unsupported_type' => 0, 'unavailable_file' => 0 ], 'missing_variants' => 0 ]; $uploads = wp_get_upload_dir(); $base = realpath( $uploads['basedir'] ); foreach ( $attachments as $attachment ) { $id = $attachment->ID; if ( ! current_user_can( 'edit_post', $id ) ) { $media_report['excluded']['permissions']++; continue; } if ( ! isset( self::MEDIA_TYPES[ $attachment->post_mime_type ] ) ) { $media_report['excluded']['unsupported_type']++; continue; } $relative = get_post_meta( $id, '_wp_attached_file', true ); $source = is_string( $relative ) ? $uploads['basedir'] . '/' . $relative : ''; $path = $source ? realpath( $source ) : false; if ( ! $base || ! $path || is_link( $source ) || ! is_file( $path ) || ! is_readable( $path ) || ! str_starts_with( $path, $base . '/' ) || ! self::safe_media_path( $relative, $attachment->post_mime_type ) ) { $media_report['excluded']['unavailable_file']++; continue; } self::file( $path, 'uploads/' . $relative, $files, $paths ); $media[] = [ 'id' => (int) $id, 'title' => $attachment->post_title, 'file' => $relative, 'mime' => $attachment->post_mime_type, 'parent' => (int) $attachment->post_parent, 'alt' => sanitize_text_field( get_post_meta( $id, '_wp_attachment_image_alt', true ) ) ]; $metadata = wp_get_attachment_metadata( $id ); $variants = array_column( (array) ( $metadata['sizes'] ?? [] ), 'file' ); if ( ! empty( $metadata['original_image'] ) ) { $variants[] = $metadata['original_image']; } foreach ( array_unique( $variants ) as $filename ) { $variant = ( '.' === dirname( $relative ) ? '' : dirname( $relative ) . '/' ) . basename( $filename ); $variant_source = $uploads['basedir'] . '/' . $variant; $variant_path = realpath( $variant_source ); if ( $variant_path && ! is_link( $variant_source ) && is_file( $variant_path ) && is_readable( $variant_path ) && str_starts_with( $variant_path, $base . '/' ) && self::safe_path( $variant ) && preg_match( '/\.(png|jpe?g|gif|webp|avif)$/i', $variant ) ) { self::file( $variant_path, 'uploads/' . $variant, $files, $paths ); } else { $media_report['missing_variants']++; } } } $media_report['included'] = count( $media ); $omitted['media'] = array_sum( $media_report['excluded'] ); $database = Database::snapshot( $selection, $users, false, $progress ); try { foreach ( $database['files'] as $file ) { self::file( $database['paths'][ $file['path'] ], $file['path'], $files, $paths ); } usort( $files, static fn( $a, $b ) => strcmp( $a['path'], $b['path'] ) ); $manifest = [ 'protocol' => 2, 'name' => mb_substr( wp_strip_all_tags( get_option( 'blogname' ) ) ?: 'WordPress', 0, 200 ), 'wp' => $wp, 'php' => $php, 'sandboxPassword' => $password, 'sourceUrl' => home_url(), 'uploadsUrl' => wp_get_upload_dir()['baseurl'], 'theme' => $theme_slug, 'themes' => $themes, 'plugins' => $plugins, 'posts' => $posts, 'terms' => array_values( $terms ), 'options' => $options, 'themeMods' => $mods, 'users' => $users, 'media' => $media, 'files' => $files, 'omitted' => $omitted ]; $manifest['database'] = $database['manifest']; // Omit the extension entirely for sites without MU files, preserving older receiver compatibility. if ( array_filter( $files, static fn( $file ) => str_starts_with( $file['path'], 'mu-plugins/' ) ) ) { $manifest['muPlugins'] = $mu_plugins; } $json = wp_json_encode( $manifest ); if ( ! $json || strlen( $json ) > self::MAX_JSON ) { throw new \RuntimeException( 'The data manifest exceeds 16 MiB. No partial copy was made.' ); } $result = [ 'manifest' => $manifest, 'reviewHash' => hash( 'sha256', $json ), 'mediaReport' => $media_report ]; if ( $with_paths ) { $result['paths'] = $paths; $result['databaseDirectory'] = $database['directory']; } else { $review = 'smooth_sandbox_transfer_review_' . get_current_user_id() . '_' . $result['reviewHash']; $previous = get_transient( $review ); if ( is_array( $previous ) ) { Database::remove_files( $previous['databaseDirectory'] ?? '' ); } $saved = set_transient( $review, [ 'owner' => get_current_user_id(), 'selectionHash' => hash( 'sha256', wp_json_encode( $selection ) ), 'databaseDirectory' => $database['directory'], 'paths' => $paths, 'files' => $manifest['files'] ], 30 * MINUTE_IN_SECONDS ); if ( ! $saved ) { throw new \RuntimeException( 'Could not retain the reviewed database snapshot.' ); } } return $result; } catch ( \Throwable $error ) { Database::remove_files( $database['directory'] ); throw $error; } } public static function begin( array $selection, string $hash ): array { if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'install_plugins' ) || ! current_user_can( 'edit_theme_options' ) || ! preg_match( '/^[a-f0-9]{64}$/D', $hash ) ) { throw new \RuntimeException( 'Invalid transfer authorization.' ); } $review = 'smooth_sandbox_transfer_review_' . get_current_user_id() . '_' . $hash; $export = get_transient( $review ); if ( ! is_array( $export ) || $export['owner'] !== get_current_user_id() || ! hash_equals( $export['selectionHash'], hash( 'sha256', wp_json_encode( $selection ) ) ) ) { throw new \RuntimeException( 'The preview expired or the selection changed. Prepare a fresh preview.' ); } // Copy the already reviewed consistent snapshot: background jobs can continue changing the source. $id = bin2hex( random_bytes( 24 ) ); if ( ! set_transient( 'smooth_sandbox_transfer_' . $id, $export, 30 * MINUTE_IN_SECONDS ) ) { throw new \RuntimeException( 'Could not start the transfer.' ); } delete_transient( $review ); return [ 'id' => $id ]; } public static function batch( string $id, int $index, int $offset ): array { $transfer = self::owned( $id ); $chunks = []; $used = 0; $files = $transfer['files']; if ( $index < 0 || $index > count( $files ) || $offset < 0 ) { throw new \RuntimeException( 'Invalid file cursor.' ); } while ( isset( $files[ $index ] ) && $used < self::CHUNK ) { $file = $files[ $index ]; $path = $transfer['paths'][ $file['path'] ]; if ( $offset > $file['bytes'] || is_link( $path ) ) { throw new \RuntimeException( 'Invalid or changed file.' ); } $bytes = file_get_contents( $path, false, null, 0, 20971521 ); // Max 20 MiB per file; never a public temporary archive. if ( false === $bytes || ! hash_equals( $file['sha256'], hash( 'sha256', $bytes ) ) ) { throw new \RuntimeException( 'A reviewed file changed. Prepare a fresh export.' ); } $part = substr( $bytes, $offset, min( self::CHUNK - $used, $file['bytes'] - $offset ) ); $chunks[] = [ 'index' => $index, 'offset' => $offset, 'data' => base64_encode( $part ) ]; $used += strlen( $part ); $offset += strlen( $part ); if ( $offset === $file['bytes'] ) { $index++; $offset = 0; } if ( count( $chunks ) >= 100 ) { break; } } return [ 'chunks' => $chunks, 'index' => $index, 'offset' => $offset, 'done' => $index === count( $files ) ]; } private static function owned( string $id ): array { if ( ! preg_match( '/^[a-f0-9]{48}$/D', $id ) ) { throw new \RuntimeException( 'Invalid transfer.' ); } $transfer = get_transient( 'smooth_sandbox_transfer_' . $id ); if ( ! is_array( $transfer ) || $transfer['owner'] !== get_current_user_id() ) { throw new \RuntimeException( 'Transfer expired or access denied.' ); } return $transfer; } public static function end( string $id ): void { $transfer = self::owned( $id ); Database::remove_files( $transfer['databaseDirectory'] ?? '' ); delete_transient( 'smooth_sandbox_transfer_' . $id ); } }