*/ private array $entries = []; /** @var array */ private array $by_path = []; private array $package = []; /** In-request CRC contexts keyed by entry path NUL destination: [written, HashContext]. */ private array $hashes = []; private function __construct() {} /** * @throws Mench_Migrate_Archive_Exception unreadable, truncated, bad_header, unsafe_path, bad_index, bad_manifest, * wpress_encrypted_unsupported. */ public static function open( string $file, ?string $cache = null ): self { $r = new self(); $fh = is_file( $file ) && is_readable( $file ) ? @fopen( $file, 'rb' ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( false === $fh ) { throw new Mench_Migrate_Archive_Exception( 'unreadable', 'archive' ); } $r->fh = $fh; $stat = fstat( $fh ); $size = (int) $stat['size']; $key = [ 'v' => 1, 'size' => $size, 'mtime' => (int) $stat['mtime'] ]; if ( null === $cache || '' === $cache || ! $r->load_cache( $cache, $key ) ) { $r->scan( $size ); if ( null !== $cache && '' !== $cache ) { $tmp = $cache . '.tmp'; if ( false !== @file_put_contents( $tmp, (string) json_encode( $key + [ 'entries' => array_map( static fn( $e ) => [ $e['path'], $e['offset'], $e['size'], $e['crc'], $e['kind'] ], $r->entries ) ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors @rename( $tmp, $cache ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions.rename_rename } } } if ( ! isset( $r->by_path['package.json'] ) || $r->entries[ $r->by_path['package.json'] ]['size'] > self::MAX_PACKAGE ) { throw new Mench_Migrate_Archive_Exception( 'bad_manifest', 'archive' ); } $package = json_decode( $r->read_string( 'package.json' ), true, 16 ); if ( ! is_array( $package ) ) { throw new Mench_Migrate_Archive_Exception( 'bad_manifest', 'archive' ); } $compression = $package['Compression'] ?? null; if ( ! empty( $package['Encrypted'] ) || ( is_array( $compression ) ? ! empty( $compression['Enabled'] ) : ! empty( $compression ) ) ) { throw new Mench_Migrate_Archive_Exception( 'wpress_encrypted_unsupported', 'archive' ); } $r->package = $package; return $r; } /** Reads every header from the start of the file. */ private function scan( int $size ): void { $pos = 0; while ( true ) { $h = self::read_at( $this->fh, $pos, self::HEADER ); if ( strlen( $h ) !== self::HEADER ) { throw new Mench_Migrate_Archive_Exception( 'truncated', 'archive' ); } $e = self::parse_header( $h ); if ( null === $e ) { // EOF block: it must be the last thing in the file. if ( $pos + self::HEADER !== $size ) { throw new Mench_Migrate_Archive_Exception( 'bad_header', 'archive' ); } break; } $e['offset'] = $pos + self::HEADER; if ( $e['offset'] + $e['size'] + self::HEADER > $size ) { throw new Mench_Migrate_Archive_Exception( 'truncated', 'archive' ); } if ( isset( $this->by_path[ $e['path'] ] ) ) { throw new Mench_Migrate_Archive_Exception( 'bad_index', 'archive' ); } $this->by_path[ $e['path'] ] = count( $this->entries ); $this->entries[] = $e; $pos = $e['offset'] + $e['size']; } } /** The entry list from a cache file written for this very archive (same size and mtime). */ private function load_cache( string $file, array $key ): bool { $raw = is_file( $file ) ? @file_get_contents( $file ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions $data = false === $raw ? null : json_decode( $raw, true ); if ( ! is_array( $data ) || array_intersect_key( $data, $key ) !== $key || ! is_array( $data['entries'] ?? null ) ) { return false; } $entries = []; $by_path = []; foreach ( $data['entries'] as $row ) { if ( ! is_array( $row ) || 5 !== count( $row ) || ! is_string( $row[0] ) || ! is_int( $row[1] ) || ! is_int( $row[2] ) || ! is_string( $row[3] ) || ! in_array( $row[4], [ 'file', 'wpress-meta' ], true ) || isset( $by_path[ $row[0] ] ) ) { return false; } $by_path[ $row[0] ] = count( $entries ); $entries[] = [ 'path' => $row[0], 'offset' => $row[1], 'size' => $row[2], 'crc' => $row[3], 'kind' => $row[4] ]; } $this->entries = $entries; $this->by_path = $by_path; return true; } /** * Cheap upload check: the first header parses and a root package.json is listed. Reads headers only. */ public static function looks_valid( string $file ): bool { $fh = is_file( $file ) && is_readable( $file ) ? @fopen( $file, 'rb' ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( false === $fh ) { return false; } $size = (int) fstat( $fh )['size']; $pos = 0; try { while ( $pos + self::HEADER <= $size ) { $e = self::parse_header( self::read_at( $fh, $pos, self::HEADER ) ); if ( null === $e ) { return false; } if ( 'package.json' === $e['path'] ) { return true; } $pos += self::HEADER + $e['size']; } } catch ( Mench_Migrate_Archive_Exception $ex ) { return false; } finally { fclose( $fh ); } return false; } /** The decoded package.json. */ public function package(): array { return $this->package; } /** package.json in the shape the .mmig import steps read. */ public function manifest(): array { $p = $this->package; $wp = is_array( $p['WordPress'] ?? null ) ? $p['WordPress'] : []; $db = is_array( $p['Database'] ?? null ) ? $p['Database'] : []; $s = static fn( $v ) => is_scalar( $v ) ? (string) $v : ''; return [ 'source' => 'wpress', 'plugin_version' => $s( $p['Plugin']['Version'] ?? '' ), 'home' => $s( $p['HomeURL'] ?? '' ), 'siteurl' => $s( $p['SiteURL'] ?? '' ), 'prefix' => $s( $db['Prefix'] ?? '' ), 'db_version' => $s( $db['Version'] ?? '' ), 'charset' => $s( $db['Charset'] ?? '' ), 'collate' => $s( $db['Collate'] ?? '' ), 'content_abs' => $s( $wp['Content'] ?? '' ), 'uploads_rel' => 'uploads', 'wp_version' => $s( $wp['Version'] ?? '' ), 'php_version' => $s( $p['PHP']['Version'] ?? '' ), 'active_plugins' => array_values( array_filter( (array) ( $p['Plugins'] ?? [] ), 'is_string' ) ), 'has_plugins' => is_array( $p['Plugins'] ?? null ), // Without the key, the dump's active_plugins stays. 'template' => $s( $p['Template'] ?? '' ), 'stylesheet' => $s( $p['Stylesheet'] ?? '' ), 'user_pairs' => [], ]; } public function is_encrypted(): bool { return false; } /** @return list kind 'file' or 'wpress-meta'. */ public function entries(): array { return $this->entries; } public function entry_count(): int { return count( $this->entries ); } public function entry_at( int $i ): array { if ( ! isset( $this->entries[ $i ] ) ) { throw new Mench_Migrate_Archive_Exception( 'unknown_entry', 'archive' ); } return $this->entries[ $i ]; } public function each(): iterable { return $this->entries; } public function find( string $rel ): ?array { return isset( $this->by_path[ $rel ] ) ? $this->entries[ $this->by_path[ $rel ] ] : null; } public function read_string( string $rel ): string { $e = $this->entry( $rel ); if ( $e['size'] > self::MAX_STRING ) { throw new Mench_Migrate_Archive_Exception( 'entry_too_large', 'archive' ); } $data = self::read_at( $this->fh, $e['offset'], $e['size'] ); if ( strlen( $data ) !== $e['size'] ) { throw new Mench_Migrate_Archive_Exception( 'truncated', 'archive' ); } if ( '' !== $e['crc'] && ! hash_equals( $e['crc'], hash( 'crc32b', $data ) ) ) { throw new Mench_Migrate_Archive_Exception( 'checksum_mismatch', 'archive' ); } return $data; } public function read_head( string $rel, int $max ): string { $e = $this->entry( $rel ); return self::read_at( $this->fh, $e['offset'], max( 0, min( $max, $e['size'] ) ) ); } /** * Extracts (part of) an entry, 1 MiB at a time until done or the deadline passes (at least one block per call). * A resumed call truncates $dest_abs to the bytes the cursor records; the cursor carries the running CRC of those * bytes, so a new request does not re-read them. * * @param array $cursor {written:int, hash?:string}; empty to start. * @return array{done:bool, cursor:array} * @throws Mench_Migrate_Archive_Exception unsafe_path, unknown_entry, truncated, bad_cursor, write_failed, checksum_mismatch. */ public function extract( string $rel, string $dest_abs, array $cursor, float $deadline ): array { Mench_Migrate_Archive_Path::assert_safe( $rel ); $e = $this->entry( $rel ); $hkey = $rel . "\0" . $dest_abs; if ( ! isset( $cursor['written'] ) ) { $written = 0; $dir = dirname( $dest_abs ); if ( ! is_dir( $dir ) && ! mkdir( $dir, 0755, true ) && ! is_dir( $dir ) ) { throw new Mench_Migrate_Archive_Exception( 'write_failed', 'extract' ); } $out = self::fopen( $dest_abs, 'wb' ); $hash = hash_init( 'crc32b' ); } else { $written = $cursor['written']; if ( ! is_int( $written ) || $written < 0 || $written > $e['size'] ) { throw new Mench_Migrate_Archive_Exception( 'bad_cursor', 'extract' ); } $out = self::fopen( $dest_abs, 'c+b' ); if ( fstat( $out )['size'] < $written ) { fclose( $out ); throw new Mench_Migrate_Archive_Exception( 'truncated', 'extract' ); } ftruncate( $out, $written ); $known = $this->hashes[ $hkey ] ?? null; $hash = $known && $known[0] === $written ? $known[1] : Mench_Migrate_Hash_State::import( $cursor['hash'] ?? null, 'crc32b', $written ); if ( null === $hash ) { // A cursor without a usable running CRC: re-hash what is already on disk. $hash = hash_init( 'crc32b' ); rewind( $out ); if ( $written > 0 && hash_update_stream( $hash, $out, $written ) !== $written ) { fclose( $out ); throw new Mench_Migrate_Archive_Exception( 'truncated', 'extract' ); } } fseek( $out, $written ); } unset( $this->hashes[ $hkey ] ); try { while ( $written < $e['size'] ) { $data = self::read_at( $this->fh, $e['offset'] + $written, min( self::BLOCK, $e['size'] - $written ) ); if ( '' === $data ) { throw new Mench_Migrate_Archive_Exception( 'truncated', 'extract' ); } if ( fwrite( $out, $data ) !== strlen( $data ) ) { throw new Mench_Migrate_Archive_Exception( 'write_failed', 'extract' ); } hash_update( $hash, $data ); $written += strlen( $data ); if ( microtime( true ) >= $deadline ) { break; } } } finally { fflush( $out ); fclose( $out ); } $done = $written >= $e['size']; if ( $done ) { if ( '' !== $e['crc'] && ! hash_equals( $e['crc'], hash_final( $hash ) ) ) { throw new Mench_Migrate_Archive_Exception( 'checksum_mismatch', 'extract' ); } return [ 'done' => true, 'cursor' => [ 'written' => $written ] ]; } $this->hashes[ $hkey ] = [ $written, $hash ]; return [ 'done' => false, 'cursor' => [ 'written' => $written, 'hash' => Mench_Migrate_Hash_State::export( $hash, $written ) ] ]; } // --- internals ---------------------------------------------------------------------------------------------- /** * One header as an entry, or null for the EOF block (empty name). * * @throws Mench_Migrate_Archive_Exception bad_header, unsafe_path. */ private static function parse_header( string $h ): ?array { $name = rtrim( substr( $h, 0, 255 ), "\0" ); if ( '' === $name ) { return null; } $size = rtrim( substr( $h, 255, 14 ), "\0" ); $path = rtrim( substr( $h, 281, 4088 ), "\0" ); $crc = strtolower( rtrim( substr( $h, 4369, 8 ), "\0" ) ); if ( ! preg_match( '/^\d{1,14}$/', $size ) || '' === $path || ( '' !== $crc && ! preg_match( '/^[0-9a-f]{8}$/', $crc ) ) || false !== strpbrk( $name, "/\\\0" ) ) { throw new Mench_Migrate_Archive_Exception( 'bad_header', 'archive' ); } // Classified on the normalised folder: "./", "." and "" all mean the root, so a ./database.sql is never a site file. $dir = rtrim( Mench_Migrate_Archive_Path::normalise( $path . '/' ), '/' ); $root = '' === $dir; $rel = $root ? $name : $dir . '/' . $name; Mench_Migrate_Archive_Path::assert_safe( $rel ); return [ 'path' => $rel, 'offset' => 0, 'size' => (int) $size, 'crc' => $crc, 'kind' => $root && in_array( $name, self::ROOT_SKIP, true ) ? 'wpress-meta' : 'file', ]; } private function entry( string $rel ): array { if ( ! isset( $this->by_path[ $rel ] ) ) { throw new Mench_Migrate_Archive_Exception( 'unknown_entry', 'archive' ); } return $this->entries[ $this->by_path[ $rel ] ]; } /** Up to $n bytes at $pos (fewer only at the end of the file). */ private static function read_at( $fh, int $pos, int $n ): string { if ( $n <= 0 ) { return ''; } fseek( $fh, $pos ); $out = ''; while ( strlen( $out ) < $n ) { $part = fread( $fh, $n - strlen( $out ) ); if ( false === $part || '' === $part ) { break; } $out .= $part; } return $out; } /** @return resource */ private static function fopen( string $file, string $mode ) { $fh = @fopen( $file, $mode ); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( false === $fh ) { throw new Mench_Migrate_Archive_Exception( 'write_failed', 'extract' ); } return $fh; } public function __destruct() { if ( is_resource( $this->fh ) ) { fclose( $this->fh ); } } }