*/ private $state; /** * Archive handle. * * @var resource|null */ private $fh = null; /** * Central directory working file handle (append). * * @var resource|null */ private $cdh = null; /** * Checksum list handle (append). * * @var resource|null */ private $sh = null; /** State format version. */ public const STATE_VERSION = 2; /** * Constructor. * * @param string $zip_path Archive path. * @param string $cd_path Central directory working file. * @param array $state Persisted state (from state()). * @param string $sums_path Checksum list working file. * @throws State_Exception When the state was written by an incompatible version. */ public function __construct( string $zip_path, string $cd_path, array $state = array(), string $sums_path = '' ) { $this->zip_path = $zip_path; $this->cd_path = $cd_path; $this->sums_path = '' !== $sums_path ? $sums_path : dirname( $cd_path ) . '/checksums.txt'; if ( ! empty( $state ) && (int) ( $state['v'] ?? 0 ) !== self::STATE_VERSION ) { throw new State_Exception( esc_html( __( 'This backup was started by an earlier version of the plugin and cannot be resumed safely. Please start a new backup.', '007aj-backup-restore-verifier' ) ) ); } $this->state = array_merge( array( 'v' => self::STATE_VERSION, 'off' => 0, 'n' => 0, 'cd' => 0, 'sl' => 0, 'sn' => 0, ), $state ); } /** * Whether a partly hashed stream can be carried across requests (native on PHP 8+, portable SHA-256 on 64-bit PHP 7.4). */ public static function can_resume_hash(): bool { return Resumable_Hash::can_pause_any(); } /** * Open the archive and roll back anything not committed. * * @throws State_Exception On I/O failure. */ public function open(): void { $end = $this->expected_size(); clearstatcache(); foreach ( array( array( $this->zip_path, $end ), array( $this->cd_path, (int) $this->state['cd'] ), array( $this->sums_path, (int) $this->state['sl'] ), ) as $chk ) { $have = file_exists( $chk[0] ) ? (int) filesize( $chk[0] ) : 0; if ( $have < $chk[1] ) { throw new State_Exception( esc_html( sprintf( /* translators: 1: file name, 2: expected bytes, 3: actual bytes */ __( 'Backup working file %1$s is shorter than the last committed step (expected at least %2$d bytes, found %3$d). It was changed outside this backup; please start a new backup.', '007aj-backup-restore-verifier' ), basename( $chk[0] ), $chk[1], $have ) ) ); } } $mode = file_exists( $this->zip_path ) ? 'r+b' : 'x+b'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $fh = @fopen( $this->zip_path, $mode ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( false === $fh ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to open backup archive for writing', '007aj-backup-restore-verifier' ), $this->zip_path ) ) ); } $this->fh = $fh; // Roll all three files back to the last commit (drops anything a killed or retried tick wrote). if ( ! ftruncate( $this->fh, $end ) || 0 !== fseek( $this->fh, $end ) ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to rewind backup archive', '007aj-backup-restore-verifier' ), $this->zip_path ) ) ); } State_Files::truncate( $this->cd_path, (int) $this->state['cd'] ); State_Files::truncate( $this->sums_path, (int) $this->state['sl'] ); $this->cdh = $this->open_append( $this->cd_path ); $this->sh = $this->open_append( $this->sums_path ); } /** * Archive size implied by the committed state (including a half-written stored entry). */ private function expected_size(): int { $cur = isset( $this->state['cur'] ) && is_array( $this->state['cur'] ) ? $this->state['cur'] : null; if ( null !== $cur ) { return (int) $cur['lho'] + (int) $cur['hlen'] + (int) $cur['done']; } return (int) $this->state['off']; } /** * Open a working file for appending. * * @param string $path Path. * @return resource * @throws State_Exception On failure. */ private function open_append( string $path ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $h = @fopen( $path, 'ab' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( false === $h ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to open working file for writing', '007aj-backup-restore-verifier' ), $path ) ) ); } return $h; } /** * Flush all three files and confirm their sizes match the state that is about to be committed. * * Call this at the end of every tick, before the cursor is saved. A mismatch means * something else wrote to the working files during this tick; the backup stops with * a clear error instead of committing an archive whose parts disagree. * * @throws State_Exception On mismatch or I/O failure. */ public function sync(): void { foreach ( array( $this->fh, $this->cdh, $this->sh ) as $h ) { if ( is_resource( $h ) && ! fflush( $h ) ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to flush backup working files (disk full?)', '007aj-backup-restore-verifier' ), $this->zip_path ) ) ); } } clearstatcache(); foreach ( array( array( $this->zip_path, isset( $this->state['fin'] ) ? (int) $this->state['off'] : $this->expected_size() ), array( $this->cd_path, (int) $this->state['cd'] ), array( $this->sums_path, (int) $this->state['sl'] ), ) as $chk ) { $have = file_exists( $chk[0] ) ? (int) filesize( $chk[0] ) : 0; if ( $have !== $chk[1] ) { throw new State_Exception( esc_html( sprintf( /* translators: 1: file name, 2: expected bytes, 3: actual bytes */ __( 'Backup working file %1$s changed outside this step (expected %2$d bytes, found %3$d). Another request may be running the same backup; it was stopped to keep the archive consistent.', '007aj-backup-restore-verifier' ), basename( $chk[0] ), $chk[1], $have ) ) ); } } } /** * Close the archive handle. */ public function close(): void { foreach ( array( 'fh', 'cdh', 'sh' ) as $k ) { if ( is_resource( $this->$k ) ) { fclose( $this->$k ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } $this->$k = null; } } /** * Persistable state. * * @return array */ public function state(): array { return $this->state; } /** * Entries committed so far. */ public function count(): int { return (int) $this->state['n']; } /** * Checksum lines committed so far. */ public function listed(): int { return (int) $this->state['sn']; } /** * Whether a large file is half-written. */ public function in_progress(): ?string { return isset( $this->state['cur']['name'] ) ? (string) $this->state['cur']['name'] : null; } /** * Add (or continue adding) a file. * * @param string $src Absolute source path. * @param string $name Entry name. * @param float $deadline microtime deadline; a stored file may stop early and resume. * @param bool $store Force STORE. * @param bool $listed Record the entry in the checksum list (payload members). * @return array{done:bool,sha256?:string,size?:int} * @throws State_Exception On I/O failure. */ public function add_file( string $src, string $name, float $deadline, bool $store = false, bool $listed = true ): array { self::assert_name( $name ); $cur = isset( $this->state['cur'] ) && is_array( $this->state['cur'] ) ? $this->state['cur'] : null; if ( null !== $cur && (string) $cur['name'] !== $name ) { throw new State_Exception( esc_html( sprintf( /* translators: 1: expected entry, 2: requested entry */ __( 'Archive writer resumed with the wrong entry (expected %1$s, got %2$s).', '007aj-backup-restore-verifier' ), (string) $cur['name'], $name ) ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $in = @fopen( $src, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( false === $in ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to read file', '007aj-backup-restore-verifier' ), $src ) ) ); } try { if ( null !== $cur && (int) $cur['done'] > 0 ) { // The source changed between ticks: restart this entry so the copy is not torn. $st = fstat( $in ); if ( ! is_array( $st ) || (int) $st['size'] !== (int) $cur['size'] || (int) $st['mtime'] !== (int) $cur['mt'] ) { $this->abandon_entry(); $cur = null; } } if ( null === $cur ) { $st = fstat( $in ); if ( ! is_array( $st ) ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to stat file', '007aj-backup-restore-verifier' ), $src ) ) ); } $size = (int) $st['size']; $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); $method = ( $store || $size > self::DEFLATE_MAX || $size < 64 || in_array( $ext, self::STORE_EXT, true ) || ! function_exists( 'deflate_init' ) ) ? 0 : 8; $cur = $this->begin_entry( $name, $size, (int) $st['mtime'], $method, $listed ); } if ( 8 === (int) $cur['method'] ) { $res = $this->write_deflated( $in, $cur, $src ); } else { $res = $this->write_stored( $in, $cur, $deadline, $src ); } } finally { fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } return $res; } /** * Add a small in-memory entry. * * @param string $name Entry name. * @param string $data Contents. * @return array{done:bool,sha256:string,size:int} * @throws State_Exception On I/O failure. */ public function add_string( string $name, string $data, bool $listed = true ): array { self::assert_name( $name ); if ( null !== $this->in_progress() ) { throw new State_Exception( esc_html( __( 'Archive writer cannot start a new entry while another is still being written.', '007aj-backup-restore-verifier' ) ) ); } $cur = $this->begin_entry( $name, strlen( $data ), time(), 0, $listed ); $this->write( $data ); $sha = hash( 'sha256', $data ); $this->commit_entry( $cur, hash( 'crc32b', $data ), strlen( $data ), strlen( $data ), $sha ); return array( 'done' => true, 'sha256' => $sha, 'size' => strlen( $data ), ); } /** * Reject entry names that cannot be represented as one checksum line. * * @param string $name Entry name. * @throws State_Exception For empty names or names with control characters. */ public static function assert_name( string $name ): void { if ( '' === $name || 1 === preg_match( '/[\x00-\x1F\x7F]/', $name ) ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: entry name */ __( 'Invalid archive entry name (empty or contains control characters): %s', '007aj-backup-restore-verifier' ), (string) preg_replace( '/[\x00-\x1F\x7F]/', '?', $name ) ) ) ); } } /** * Drop a half-written entry whose source disappeared between ticks. * * @throws State_Exception On I/O failure. */ public function discard_in_progress(): void { if ( null !== $this->in_progress() ) { $this->abandon_entry(); } } /** * Drop a half-written entry (truncate back to its local header offset). * * @throws State_Exception On I/O failure. */ private function abandon_entry(): void { $lho = (int) $this->state['cur']['lho']; unset( $this->state['cur'] ); if ( ! ftruncate( $this->fh, $lho ) || 0 !== fseek( $this->fh, $lho ) ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to rewind backup archive', '007aj-backup-restore-verifier' ), $this->zip_path ) ) ); } } /** * Write the central directory and end records. * * @return int Final archive size. * @throws State_Exception On I/O failure. */ public function finish(): int { if ( null !== $this->in_progress() ) { throw new State_Exception( esc_html( __( 'Cannot finish the archive while an entry is still being written.', '007aj-backup-restore-verifier' ) ) ); } $cd_off = (int) $this->state['off']; $cd_size = (int) $this->state['cd']; $n = (int) $this->state['n']; if ( is_resource( $this->cdh ) ) { fflush( $this->cdh ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $cd = @fopen( $this->cd_path, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( false === $cd ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to read archive directory working file', '007aj-backup-restore-verifier' ), $this->cd_path ) ) ); } $copied = stream_copy_to_stream( $cd, $this->fh, $cd_size ); fclose( $cd ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( $copied !== $cd_size ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to write archive directory (disk full?)', '007aj-backup-restore-verifier' ), $this->zip_path ) ) ); } $tail = ''; if ( $n >= 0xFFFF || $cd_off >= 0xFFFFFFFF || $cd_size >= 0xFFFFFFFF ) { $z64_off = $cd_off + $cd_size; $tail .= pack( 'VPvvVVPPPP', 0x06064b50, 44, 45, 45, 0, 0, $n, $n, $cd_size, $cd_off ); $tail .= pack( 'VVPV', 0x07064b50, 0, $z64_off, 1 ); } $tail .= pack( 'VvvvvVVv', 0x06054b50, 0, 0, min( $n, 0xFFFF ), min( $n, 0xFFFF ), min( $cd_size, 0xFFFFFFFF ), min( $cd_off, 0xFFFFFFFF ), 0 ); $this->write( $tail ); fflush( $this->fh ); $this->state['off'] = $cd_off + $cd_size + strlen( $tail ); $this->state['fin'] = 1; return (int) $this->state['off']; } /** * Start an entry: write the local header. * * @param string $name Entry name. * @param int $size Uncompressed size. * @param int $mtime Unix mtime. * @param int $method 0 store / 8 deflate. * @param bool $listed Record in the checksum list. * @return array */ private function begin_entry( string $name, int $size, int $mtime, int $method, bool $listed = true ): array { $z64 = $size >= 0xFFFFFFFF ? 1 : 0; $extra = $z64 ? pack( 'vvPP', 0x0001, 16, 0, 0 ) : ''; $flags = $this->is_utf8( $name ) ? 0x0800 : 0; list( $dtime, $ddate ) = self::dos_time( $mtime ); $header = pack( 'VvvvvvVVVvv', 0x04034b50, $z64 ? 45 : 20, $flags, $method, $dtime, $ddate, 0, $z64 ? 0xFFFFFFFF : 0, $z64 ? 0xFFFFFFFF : 0, strlen( $name ), strlen( $extra ) ) . $name . $extra; $lho = (int) $this->state['off']; $this->write( $header ); $cur = array( 'name' => $name, 'lho' => $lho, 'hlen' => strlen( $header ), 'size' => $size, 'method' => $method, 'z64' => $z64, 'flags' => $flags, 't' => $dtime, 'd' => $ddate, 'mt' => $mtime, 'ls' => $listed ? 1 : 0, 'done' => 0, ); $this->state['cur'] = $cur; return $cur; } /** * Stream a stored entry; may stop at the deadline and resume later (any supported PHP). * * @param resource $in Source handle. * @param array $cur Entry state. * @param float $deadline Deadline. * @param string $src Source path (errors). * @return array{done:bool,sha256?:string,size?:int} */ private function write_stored( $in, array $cur, float $deadline, string $src ): array { $done = (int) $cur['done']; $hash = null; if ( $done > 0 ) { $saved = $cur['rh'] ?? ( empty( $cur['hs'] ) ? null : array( 'm' => 'n', 'c' => 1, 'hs' => $cur['hs'], 'hc' => $cur['hc'] ?? '', ) ); $hash = Resumable_Hash::import( $saved ); if ( null === $hash ) { throw new State_Exception( esc_html( __( 'Archive writer could not resume a partially written file.', '007aj-backup-restore-verifier' ) ) ); } if ( 0 !== fseek( $in, $done ) ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to seek in file', '007aj-backup-restore-verifier' ), $src ) ) ); } } else { $done = 0; $hash = Resumable_Hash::start( (int) $cur['size'], $deadline ); } unset( $cur['hs'], $cur['hc'] ); $size = (int) $cur['size']; while ( $done < $size ) { $want = (int) min( self::SLICE, $size - $done ); $chunk = fread( $in, $want ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( false === $chunk ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Read error', '007aj-backup-restore-verifier' ), $src ) ) ); } if ( '' === $chunk ) { break; // File shrank while reading; record what was read. } $hash->update( $chunk ); $this->write( $chunk ); $done += strlen( $chunk ); if ( $hash->can_pause() && $done < $size && microtime( true ) >= $deadline ) { $cur['done'] = $done; $cur['rh'] = $hash->export(); $this->state['cur'] = $cur; fflush( $this->fh ); return array( 'done' => false ); } } $sha_hex = $hash->sha256(); $this->commit_entry( $cur, $hash->crc32b(), $done, $done, $sha_hex ); return array( 'done' => true, 'sha256' => $sha_hex, 'size' => $done, ); } /** * Stream a deflated entry in one pass (small files only). * * @param resource $in Source handle. * @param array $cur Entry state. * @param string $src Source path (errors). * @return array{done:bool,sha256:string,size:int} */ private function write_deflated( $in, array $cur, string $src ): array { if ( (int) $cur['done'] > 0 ) { throw new State_Exception( esc_html( __( 'Archive writer cannot resume a compressed entry.', '007aj-backup-restore-verifier' ) ) ); } $sha = hash_init( 'sha256' ); $crc = hash_init( 'crc32b' ); $def = deflate_init( ZLIB_ENCODING_RAW, array( 'level' => 6 ) ); $usize = 0; $csize = 0; $limit = (int) $cur['size']; while ( $usize < $limit ) { $chunk = fread( $in, (int) min( self::SLICE, $limit - $usize ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( false === $chunk ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Read error', '007aj-backup-restore-verifier' ), $src ) ) ); } if ( '' === $chunk ) { break; } $usize += strlen( $chunk ); hash_update( $sha, $chunk ); hash_update( $crc, $chunk ); $out = deflate_add( $def, $chunk, ZLIB_NO_FLUSH ); if ( '' !== $out ) { $this->write( $out ); $csize += strlen( $out ); } } $out = deflate_add( $def, '', ZLIB_FINISH ); $this->write( $out ); $csize += strlen( $out ); $sha_hex = hash_final( $sha ); $this->commit_entry( $cur, hash_final( $crc ), $csize, $usize, $sha_hex ); return array( 'done' => true, 'sha256' => $sha_hex, 'size' => $usize, ); } /** * Commit point for one entry: patch the local header, then append the central * directory record and the checksum line together and advance every counter at once. * * @param array $cur Entry state. * @param string $crc crc32b hex. * @param int $csize Compressed size. * @param int $usize Uncompressed size. * @param string $sha256 SHA-256 hex of the entry contents. * @throws State_Exception On I/O failure. */ private function commit_entry( array $cur, string $crc, int $csize, int $usize, string $sha256 ): void { $crc_int = (int) hexdec( $crc ); $lho = (int) $cur['lho']; $end = $lho + (int) $cur['hlen'] + $csize; $z64 = ! empty( $cur['z64'] ); if ( ! $z64 && ( $usize >= 0xFFFFFFFF || $csize >= 0xFFFFFFFF ) ) { throw new State_Exception( esc_html( __( 'File grew beyond 4 GB while it was being archived.', '007aj-backup-restore-verifier' ) ) ); } // Patch crc + sizes in the local header. fseek( $this->fh, $lho + 14 ); if ( $z64 ) { $this->write( pack( 'VVV', $crc_int, 0xFFFFFFFF, 0xFFFFFFFF ) ); fseek( $this->fh, $lho + 30 + strlen( (string) $cur['name'] ) + 4 ); $this->write( pack( 'PP', $usize, $csize ) ); } else { $this->write( pack( 'VVV', $crc_int, $csize, $usize ) ); } fseek( $this->fh, $end ); $big_size = $usize >= 0xFFFFFFFF || $csize >= 0xFFFFFFFF; $big_off = $lho >= 0xFFFFFFFF; $extra = ''; if ( $big_size ) { $extra .= pack( 'PP', $usize, $csize ); } if ( $big_off ) { $extra .= pack( 'P', $lho ); } if ( '' !== $extra ) { $extra = pack( 'vv', 0x0001, strlen( $extra ) ) . $extra; } // Extended timestamp (exact mtime) so restores can keep it and later incrementals see no change. if ( isset( $cur['mt'] ) && (int) $cur['mt'] > 0 && (int) $cur['mt'] <= 0xFFFFFFFF ) { $extra .= pack( 'vvCV', 0x5455, 5, 1, (int) $cur['mt'] ); } $name = (string) $cur['name']; $record = pack( 'VvvvvvvVVVvvvvvVV', 0x02014b50, ( 3 << 8 ) | 45, ( $big_size || $big_off ) ? 45 : 20, (int) $cur['flags'], (int) $cur['method'], (int) $cur['t'], (int) $cur['d'], $crc_int, $big_size ? 0xFFFFFFFF : $csize, $big_size ? 0xFFFFFFFF : $usize, strlen( $name ), strlen( $extra ), 0, 0, 0, 0x81A40000, $big_off ? 0xFFFFFFFF : $lho ) . $name . $extra; $line = ! empty( $cur['ls'] ) ? $sha256 . ' ' . $name . "\n" : ''; $this->append( $this->cdh, $record, $this->cd_path ); $this->append( $this->sh, $line, $this->sums_path ); unset( $this->state['cur'] ); $this->state['off'] = $end; $this->state['n'] = (int) $this->state['n'] + 1; $this->state['cd'] = (int) $this->state['cd'] + strlen( $record ); if ( '' !== $line ) { $this->state['sl'] = (int) $this->state['sl'] + strlen( $line ); $this->state['sn'] = (int) $this->state['sn'] + 1; } } /** * Append to a working file handle or fail loudly. * * @param resource|null $h Handle. * @param string $data Bytes. * @param string $path Path (errors). * @throws State_Exception On short write. */ private function append( $h, string $data, string $path ): void { if ( '' === $data ) { return; } if ( ! is_resource( $h ) ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Archive writer is not open', '007aj-backup-restore-verifier' ), $path ) ) ); } $len = strlen( $data ); $written = 0; while ( $written < $len ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite $w = @fwrite( $h, 0 === $written ? $data : substr( $data, $written ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fwrite if ( false === $w || 0 === $w ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Short write to working file (disk full?)', '007aj-backup-restore-verifier' ), $path ) ) ); } $written += $w; } } /** * Write bytes to the archive or fail loudly. * * @param string $data Bytes. * @throws State_Exception On short write. */ private function write( string $data ): void { if ( '' === $data ) { return; } $len = strlen( $data ); $written = 0; while ( $written < $len ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite $w = @fwrite( $this->fh, 0 === $written ? $data : substr( $data, $written ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fwrite if ( false === $w || 0 === $w ) { throw new State_Exception( esc_html( State_Files::io_message( __( 'Unable to write backup archive (disk full?)', '007aj-backup-restore-verifier' ), $this->zip_path ) ) ); } $written += $w; } } /** * Whether a string is valid UTF-8. * * @param string $s String. */ private function is_utf8( string $s ): bool { return 1 === preg_match( '//u', $s ); } /** * DOS time/date pair for a unix timestamp. * * @param int $ts Timestamp. * @return array{0:int,1:int} */ private static function dos_time( int $ts ): array { $d = getdate( max( 315532800, $ts ) ); $time = ( $d['hours'] << 11 ) | ( $d['minutes'] << 5 ) | ( $d['seconds'] >> 1 ); $date = ( ( $d['year'] - 1980 ) << 9 ) | ( $d['mon'] << 5 ) | $d['mday']; return array( $time, $date ); } }