job = $job; } /** * @return string */ public function zip_path() { return $this->job->work_dir() . '/backup.zip'; } /** * @return string */ private function index_path() { return $this->job->work_dir() . '/zip-index.json'; } /** * Close file handles. */ public function close() { if ( $this->writer ) { $this->writer->close(); $this->writer = null; } } /** * @return Part_Writer */ private function writer() { if ( ! $this->writer ) { $this->writer = new Part_Writer( $this->zip_path(), (int) $this->job->s['zip']['bytes'] ); } return $this->writer; } /** * @return array[] Completed entries. */ private function index() { $data = Storage::read_json( $this->index_path() ); return is_array( $data ) ? $data : array(); } /** * Starting a new entry wants its own tick (§10.1). * * @return bool */ public function starting_entry() { return empty( $this->job->s['zip']['current'] ); } /** * @return int Chunk size (adaptive; halves after crashes). */ private function chunk_size() { $size = (int) $this->job->s['chunk_size']; $size = (int) apply_filters( 'dbmg_zip_chunk_size', $size > 0 ? $size : 4 * MB_IN_BYTES ); return max( 65536, $size ); } /** * DOS time/date of the backup, in the site's timezone. * * @return int[] [ time, date ] */ private function dos_time() { $dt = new \DateTime( '@' . (int) $this->job->s['id'] ); $dt->setTimezone( wp_timezone() ); $y = max( 1980, (int) $dt->format( 'Y' ) ); return array( ( (int) $dt->format( 'G' ) << 11 ) | ( (int) $dt->format( 'i' ) << 5 ) | ( (int) $dt->format( 's' ) >> 1 ), ( ( $y - 1980 ) << 9 ) | ( (int) $dt->format( 'n' ) << 5 ) | (int) $dt->format( 'j' ), ); } /** * Local file header. * * @param string $name Entry name. * @param int $method 8 deflate | 0 store. * @param int $crc CRC32 (0 when patched later). * @param int $csize Compressed size. * @param int $usize Uncompressed size. * @return string */ private function local_header( $name, $method, $crc, $csize, $usize ) { list( $time, $date ) = $this->dos_time(); return pack( 'VvvvvvVVVvv', 0x04034b50, 20, 0x0800, $method, $time, $date, $crc, $csize, $usize, strlen( $name ), 0 ) . $name; } /** * Compress one chunk as an independent raw-deflate segment. * * @param string $data Uncompressed. * @param bool $last Final chunk of the entry. * @param int $method 8|0. * @return string */ private static function compress( $data, $last, $method ) { if ( 0 === $method ) { return $data; } $ctx = deflate_init( ZLIB_ENCODING_RAW, array( 'level' => 6 ) ); $out = deflate_add( $ctx, $data, $last ? ZLIB_FINISH : ZLIB_FULL_FLUSH ); if ( false === $out ) { throw new Backup_Exception( 'zip_verify_failed', 'deflate_add failed' ); } return $out; } /** * Add (a slice of) the first pending part. True once that part is fully in the zip. * * @return bool */ public function zip_step() { $s = &$this->job->s; $z = &$s['zip']; $part = $s['pending_parts'][0]; $w = $this->writer(); if ( empty( $z['current'] ) ) { $method = Environment::has_deflate() && 'store' !== apply_filters( 'dbmg_zip_compression', 'deflate' ) ? 8 : 0; $offset = $w->size(); $w->write( $this->local_header( $part['entry'], $method, 0, 0, 0 ) ); $w->flush(); $z['current'] = array( 'name' => $part['entry'], 'method' => $method, 'crc' => $part['crc'], 'usize' => (int) $part['bytes'], 'offset' => $offset, 'data_start' => $w->size(), 'in' => 0, 'out' => 0, 'chunks' => array(), 'started' => microtime( true ), ); $z['bytes'] = $w->size(); return false; } $c = &$z['current']; $file = $this->job->work_dir() . '/' . $part['file']; if ( $c['in'] < $c['usize'] ) { $len = (int) min( $this->chunk_size(), $c['usize'] - $c['in'] ); $fh = fopen( $file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( ! $fh ) { throw new Backup_Exception( 'zip_verify_failed', 'Part file missing: ' . $part['file'] ); } fseek( $fh, $c['in'] ); $data = (string) fread( $fh, $len ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( strlen( $data ) !== $len ) { throw new Backup_Exception( 'zip_verify_failed', 'Short read from ' . $part['file'] ); } $last = ( $c['in'] + $len ) >= $c['usize']; $out = self::compress( $data, $last, (int) $c['method'] ); $w->write( $out ); $w->flush(); $c['chunks'][] = array( $len, strlen( $out ), hash( 'crc32b', $data ), (int) $c['data_start'] + (int) $c['out'] ); $c['in'] += $len; $c['out'] += strlen( $out ); $z['bytes'] = $w->size(); unset( $data, $out ); if ( ! $last ) { return false; } } // Entry complete: patch CRC + sizes into the local header (idempotent on resume). $w->patch( (int) $c['offset'] + 14, pack( 'VVV', hexdec( $c['crc'] ), (int) $c['out'], (int) $c['usize'] ) ); $w->flush(); $index = $this->index(); $prev = end( $index ); if ( ! $prev || $prev['name'] !== $c['name'] || (int) $prev['offset'] !== (int) $c['offset'] ) { $index[] = array( 'name' => $c['name'], 'method' => (int) $c['method'], 'crc' => $c['crc'], 'csize' => (int) $c['out'], 'usize' => (int) $c['usize'], 'offset' => (int) $c['offset'], 'chunks' => $c['chunks'], ); Storage::write_json( $this->index_path(), $index ); } $this->adapt_part_size( $c ); unset( $c ); $z['current'] = null; $z['count'] = count( $index ); array_shift( $s['pending_parts'] ); @unlink( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.unlink_unlink ++$s['parts_zipped']; return true; } /** * Lower part_size for later parts on slow hosts (§10.1 step 4). * * @param array $c Finished entry state. */ private function adapt_part_size( array $c ) { $s = &$this->job->s; $elapsed = microtime( true ) - (float) $c['started']; if ( $elapsed <= 0 || count( $c['chunks'] ) < 2 ) { return; } $speed = $c['usize'] / $elapsed; // bytes/s across ticks (pessimistic: includes waits between ticks). $budget = Environment::budget_seconds(); $target = (int) max( 8 * MB_IN_BYTES, min( 256 * MB_IN_BYTES, $speed * $budget * 0.5 * 8 ) ); if ( $target < (int) $s['part_size'] ) { $s['part_size'] = $target; } } /** * Append README + backup-info.json, the central directory and end records. * * @param array $info backup-info.json payload. */ public function finish_archive( array $info ) { $s = &$this->job->s; $w = $this->writer(); $index = $this->index(); $method = Environment::has_deflate() && 'store' !== apply_filters( 'dbmg_zip_compression', 'deflate' ) ? 8 : 0; $extras = array( 'IMPORT-README.txt' => $this->readme( $index ), 'backup-info.json' => wp_json_encode( $info, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), ); foreach ( $extras as $name => $content ) { $crc = hash( 'crc32b', $content ); $out = self::compress( $content, true, $method ); $offset = $w->size(); $w->write( $this->local_header( $name, $method, hexdec( $crc ), strlen( $out ), strlen( $content ) ) ); $start = $w->size(); $w->write( $out ); $index[] = array( 'name' => $name, 'method' => $method, 'crc' => $crc, 'csize' => strlen( $out ), 'usize' => strlen( $content ), 'offset' => $offset, 'chunks' => array( array( strlen( $content ), strlen( $out ), $crc, $start ) ), ); } $force = (bool) apply_filters( 'dbmg_force_zip64', false ); $cd_offset = $w->size(); $needs_z64 = $force; list( $time, $date ) = $this->dos_time(); foreach ( $index as $e ) { $z64_offset = $force || $e['offset'] >= self::U32; $extra = $z64_offset ? pack( 'vvP', 0x0001, 8, (int) $e['offset'] ) : ''; $version = $z64_offset ? 45 : 20; $needs_z64 = $needs_z64 || $z64_offset; $w->write( pack( 'VvvvvvvVVVvvvvvVV', 0x02014b50, ( 3 << 8 ) | $version, $version, 0x0800, (int) $e['method'], $time, $date, hexdec( $e['crc'] ), (int) $e['csize'], (int) $e['usize'], strlen( $e['name'] ), strlen( $extra ), 0, 0, 0, 0x81A40000, $z64_offset ? self::U32 : (int) $e['offset'] ) . $e['name'] . $extra ); } $cd_size = $w->size() - $cd_offset; $count = count( $index ); $needs_z64 = $needs_z64 || $cd_offset >= self::U32 || $count >= 0xFFFF; if ( $needs_z64 ) { $z64_eocd = $w->size(); $w->write( pack( 'VPvvVVPPPP', 0x06064b50, 44, ( 3 << 8 ) | 45, 45, 0, 0, $count, $count, $cd_size, $cd_offset ) ); $w->write( pack( 'VVPV', 0x07064b50, 0, $z64_eocd, 1 ) ); } $w->write( pack( 'VvvvvVVv', 0x06054b50, 0, 0, $needs_z64 ? 0xFFFF : $count, $needs_z64 ? 0xFFFF : $count, $needs_z64 ? self::U32 : $cd_size, $needs_z64 ? self::U32 : $cd_offset, 0 ) ); $w->flush(); $s['zip']['bytes'] = $w->size(); $s['zip']['finished'] = true; $s['zip']['extras'] = array_slice( $index, -2 ); $s['zip']['count'] = $count; $s['zip']['zip64'] = $needs_z64; $this->close(); } /** * IMPORT-README.txt (§10.4). * * @param array[] $index Entries. * @return string */ private function readme( array $index ) { $env = $this->job->s['env']; $names = wp_list_pluck( $index, 'name' ); $multi = count( $names ) > 1; $file = $multi ? 'database.part-001.sql, database.part-002.sql, ...' : 'database.sql'; $lines = array( 'This ZIP contains a standard MySQL/MariaDB dump of your WordPress database.', 'Created by Database Export Genie ' . DBMG_VERSION . ' on ' . gmdate( 'Y-m-d H:i', (int) $this->job->s['id'] ) . ' UTC.', 'Backup name: ' . $this->job->s['name'], '', 'Files: ' . implode( ', ', $names ), '', 'Import options (all free):', ' * phpMyAdmin / Adminer: Import -> choose ' . $file . ( $multi ? ' (each part in order: 001, 002, ...)' : '' ), ' * Command line: mysql -u USER -p DBNAME < database.sql', ' * WP-CLI: wp db import database.sql', '', 'Multiple parts? Import them in numeric order, or join them first:', ' macOS/Linux: cat database.part-*.sql > database.sql', ' Windows: copy /b database.part-*.sql database.sql', '', 'Site URL at export time: ' . $env['site_url'] . ' (table prefix: ' . $env['table_prefix'] . ')', "If you're moving to a different domain, run a serialized-safe search-replace after import,", "e.g. `wp search-replace 'https://old.com' 'https://new.com' --all-tables`.", '', 'Note: a backup taken while the site is live is not a point-in-time snapshot unless', 'backup-info.json says "consistent_snapshot": true.', '', ); return implode( "\r\n", $lines ); } /** * One verification unit (§10.3). True when everything checked out. * * @return bool * @throws Backup_Exception On any mismatch. */ public function verify_step() { $s = &$this->job->s; if ( empty( $s['verify'] ) ) { $s['verify'] = array( 'phase' => 'structure', 'entry' => 0, 'chunk' => 0, ); } $v = &$s['verify']; $entries = array_merge( $this->index(), (array) $s['zip']['extras'] ); // De-duplicate extras if they were already part of the index. $seen = array(); foreach ( $entries as $i => $e ) { if ( isset( $seen[ $e['name'] ] ) ) { unset( $entries[ $i ] ); } $seen[ $e['name'] ] = true; } $entries = array_values( $entries ); if ( 'structure' === $v['phase'] ) { $this->verify_structure( $entries ); $v['phase'] = 'chunks'; return false; } if ( 'chunks' === $v['phase'] ) { if ( ! isset( $entries[ $v['entry'] ] ) ) { $v['phase'] = 'external'; return false; } $e = $entries[ $v['entry'] ]; if ( ! isset( $e['chunks'][ $v['chunk'] ] ) ) { ++$v['entry']; $v['chunk'] = 0; return false; } $this->verify_chunk( $e, $v['chunk'] ); ++$v['chunk']; return false; } if ( 'external' === $v['phase'] ) { $this->verify_external( $entries ); if ( empty( $s['footer_found'] ) ) { throw new Backup_Exception( 'zip_verify_failed', 'SQL completion marker missing from the last part' ); } $v['phase'] = 'done'; } return true; } /** * Parse the central directory we wrote and compare with the index; check each local header. * * @param array[] $entries Expected entries. */ private function verify_structure( array $entries ) { $path = $this->zip_path(); clearstatcache( true, $path ); $size = (int) filesize( $path ); if ( $size !== (int) $this->job->s['zip']['bytes'] ) { throw new Backup_Exception( 'zip_verify_failed', sprintf( 'Zip is %d bytes, expected %d', $size, $this->job->s['zip']['bytes'] ) ); } $fh = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen fseek( $fh, $size - 22 ); $eocd = unpack( 'Vsig/vdisk/vcddisk/vcount_disk/vcount/Vcd_size/Vcd_offset/vcomment', (string) fread( $fh, 22 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( ! $eocd || 0x06054b50 !== $eocd['sig'] ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose throw new Backup_Exception( 'zip_verify_failed', 'End-of-central-directory record not found' ); } $count = $eocd['count']; $cd_offset = $eocd['cd_offset']; $cd_size = $eocd['cd_size']; if ( self::U32 === $cd_offset || 0xFFFF === $count ) { fseek( $fh, $size - 22 - 20 ); $loc = unpack( 'Vsig/Vdisk/Poffset/Vdisks', (string) fread( $fh, 20 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( ! $loc || 0x07064b50 !== $loc['sig'] ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose throw new Backup_Exception( 'zip_verify_failed', 'ZIP64 locator missing' ); } fseek( $fh, $loc['offset'] ); $z64 = unpack( 'Vsig/Psize/vmade/vneed/Vdisk/Vcddisk/Pcount_disk/Pcount/Pcd_size/Pcd_offset', (string) fread( $fh, 56 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( ! $z64 || 0x06064b50 !== $z64['sig'] ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose throw new Backup_Exception( 'zip_verify_failed', 'ZIP64 end record missing' ); } $count = $z64['count']; $cd_offset = $z64['cd_offset']; $cd_size = $z64['cd_size']; } if ( (int) $count !== count( $entries ) ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose throw new Backup_Exception( 'zip_verify_failed', sprintf( 'Zip lists %d entries, expected %d', $count, count( $entries ) ) ); } fseek( $fh, $cd_offset ); $cd = (string) fread( $fh, (int) $cd_size ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread $pos = 0; foreach ( $entries as $e ) { $rec = unpack( 'Vsig/vmade/vneed/vflags/vmethod/vtime/vdate/Vcrc/Vcsize/Vusize/vnlen/vxlen/vclen/vdisk/viattr/Vxattr/Voffset', substr( $cd, $pos, 46 ) ); if ( ! $rec || 0x02014b50 !== $rec['sig'] ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose throw new Backup_Exception( 'zip_verify_failed', 'Bad central directory record' ); } $name = substr( $cd, $pos + 46, $rec['nlen'] ); $extra = substr( $cd, $pos + 46 + $rec['nlen'], $rec['xlen'] ); $offset = $rec['offset']; if ( self::U32 === $offset && strlen( $extra ) >= 12 ) { $x = unpack( 'vid/vsize/Poffset', substr( $extra, 0, 12 ) ); $offset = $x['offset']; } $pos += 46 + $rec['nlen'] + $rec['xlen'] + $rec['clen']; if ( $name !== $e['name'] || (int) $offset !== (int) $e['offset'] || sprintf( '%08x', $rec['crc'] ) !== strtolower( $e['crc'] ) || (int) $rec['csize'] !== (int) $e['csize'] || (int) $rec['usize'] !== (int) $e['usize'] ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose throw new Backup_Exception( 'zip_verify_failed', 'Central directory mismatch for ' . $e['name'] ); } fseek( $fh, $offset ); $lh = unpack( 'Vsig/vneed/vflags/vmethod/vtime/vdate/Vcrc/Vcsize/Vusize/vnlen/vxlen', (string) fread( $fh, 30 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( ! $lh || 0x04034b50 !== $lh['sig'] || (int) $lh['crc'] !== (int) $rec['crc'] || (int) $lh['csize'] !== (int) $rec['csize'] || fread( $fh, $lh['nlen'] ) !== $name ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose throw new Backup_Exception( 'zip_verify_failed', 'Local header mismatch for ' . $e['name'] ); } } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } /** * Inflate one recorded chunk and compare length + CRC with what was read from the part file. * * @param array $e Entry. * @param int $i Chunk index. */ private function verify_chunk( array $e, $i ) { list( $in_len, $out_len, $crc, $offset ) = $e['chunks'][ $i ]; $fh = fopen( $this->zip_path(), 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen fseek( $fh, (int) $offset ); $raw = $out_len > 0 ? (string) fread( $fh, (int) $out_len ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( 8 === (int) $e['method'] ) { $last = ( count( $e['chunks'] ) - 1 ) === $i; $ctx = inflate_init( ZLIB_ENCODING_RAW ); $data = @inflate_add( $ctx, $raw, $last ? ZLIB_FINISH : ZLIB_SYNC_FLUSH ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } else { $data = $raw; } if ( false === $data || strlen( $data ) !== (int) $in_len || hash( 'crc32b', $data ) !== $crc ) { throw new Backup_Exception( 'zip_verify_failed', sprintf( 'Chunk %d of %s does not decompress to the dumped bytes', $i + 1, $e['name'] ) ); } } /** * Independent check with ZipArchive (CHECKCONS) or PclZip when available. * * @param array[] $entries Expected entries. */ private function verify_external( array $entries ) { $path = $this->zip_path(); if ( class_exists( 'ZipArchive' ) && apply_filters( 'dbmg_use_ziparchive', true ) ) { $zip = new \ZipArchive(); $res = $zip->open( $path, \ZipArchive::CHECKCONS ); if ( true !== $res ) { throw new Backup_Exception( 'zip_verify_failed', 'ZipArchive CHECKCONS failed with code ' . (int) $res ); } foreach ( $entries as $e ) { $stat = $zip->statName( $e['name'] ); if ( ! $stat || (int) $stat['size'] !== (int) $e['usize'] || sprintf( '%08x', $stat['crc'] & 0xFFFFFFFF ) !== strtolower( $e['crc'] ) ) { $zip->close(); throw new Backup_Exception( 'zip_verify_failed', 'ZipArchive stat mismatch for ' . $e['name'] ); } } $zip->close(); return; } if ( empty( $this->job->s['zip']['zip64'] ) && file_exists( ABSPATH . 'wp-admin/includes/class-pclzip.php' ) ) { require_once ABSPATH . 'wp-admin/includes/class-pclzip.php'; $pcl = new \PclZip( $path ); $list = $pcl->listContent(); if ( ! is_array( $list ) || count( $list ) !== count( $entries ) ) { throw new Backup_Exception( 'zip_verify_failed', 'PclZip could not list the archive' ); } } } }