path = $path; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $fh = @fopen( $path, 'rb' ); // 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 archive', '007aj-backup-restore-verifier' ), $path ) ) ); } $this->fh = $fh; $this->locate(); } /** * Close. */ public function close(): void { if ( is_resource( $this->fh ) ) { fclose( $this->fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } } /** * Find EOCD (and Zip64 records). * * @throws State_Exception When no end record is found. */ private function locate(): void { $size = (int) fstat( $this->fh )['size']; $scan = (int) min( $size, 65557 ); if ( $scan < 22 ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: path */ __( 'Not a zip archive (too small): %s', '007aj-backup-restore-verifier' ), basename( $this->path ) ) ) ); } fseek( $this->fh, $size - $scan ); $tail = (string) fread( $this->fh, $scan ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread $pos = strrpos( $tail, "PK\x05\x06" ); if ( false === $pos ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: path */ __( 'Not a zip archive (end record missing): %s', '007aj-backup-restore-verifier' ), basename( $this->path ) ) ) ); } $e = unpack( 'Vsig/vdisk/vcd_disk/ventries_disk/ventries/Vcd_size/Vcd_off/vclen', substr( $tail, $pos, 22 ) ); $this->count = (int) $e['entries']; $this->cd_size = (int) $e['cd_size']; $this->cd_off = (int) $e['cd_off']; $eocd_abs = $size - $scan + $pos; if ( $eocd_abs >= 20 && ( 0xFFFF === $this->count || 0xFFFFFFFF === $this->cd_size || 0xFFFFFFFF === $this->cd_off ) ) { fseek( $this->fh, $eocd_abs - 20 ); $loc = unpack( 'Vsig/Vdisk/Poff/Vdisks', (string) fread( $this->fh, 20 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( is_array( $loc ) && 0x07064b50 === (int) $loc['sig'] ) { fseek( $this->fh, (int) $loc['off'] ); $z = unpack( 'Vsig/Psize/vmade/vneed/Vdisk/Vcd_disk/Pentries_disk/Pentries/Pcd_size/Pcd_off', (string) fread( $this->fh, 56 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( is_array( $z ) && 0x06064b50 === (int) $z['sig'] ) { $this->count = (int) $z['entries']; $this->cd_size = (int) $z['cd_size']; $this->cd_off = (int) $z['cd_off']; } } } if ( $this->cd_off + $this->cd_size > $size ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: path */ __( 'Archive is truncated (central directory out of range): %s', '007aj-backup-restore-verifier' ), basename( $this->path ) ) ) ); } } /** * Read central directory records starting at a relative position. * * @param int $pos Position relative to the directory start. * @param int $max Max records. * @param float $deadline Optional deadline. * @return array{entries:list>,pos:int,eof:bool} * @throws State_Exception On a corrupt record. */ public function entries( int $pos, int $max, float $deadline = 0.0 ): array { $out = array(); fseek( $this->fh, $this->cd_off + $pos ); $buf = ''; $buf_pos = $pos; while ( count( $out ) < $max && $pos < $this->cd_size ) { if ( strlen( $buf ) - ( $pos - $buf_pos ) < 65536 + 46 ) { $buf = substr( $buf, $pos - $buf_pos ) . (string) fread( $this->fh, 262144 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread $buf_pos = $pos; } $o = $pos - $buf_pos; if ( strlen( $buf ) < $o + 46 ) { throw new State_Exception( esc_html( __( 'Archive central directory is truncated.', '007aj-backup-restore-verifier' ) ) ); } $h = unpack( 'Vsig/vmade/vneed/vflags/vmethod/vtime/vdate/Vcrc/Vcsize/Vusize/vnlen/velen/vclen/vdisk/viattr/Veattr/Vlho', substr( $buf, $o, 46 ) ); if ( 0x02014b50 !== (int) $h['sig'] ) { throw new State_Exception( esc_html( __( 'Archive central directory is corrupt.', '007aj-backup-restore-verifier' ) ) ); } $name = substr( $buf, $o + 46, (int) $h['nlen'] ); $extra = substr( $buf, $o + 46 + (int) $h['nlen'], (int) $h['elen'] ); $csize = (int) $h['csize']; $usize = (int) $h['usize']; $lho = (int) $h['lho']; $mtime = 0; $x = 0; while ( $x + 4 <= strlen( $extra ) ) { $f = unpack( 'vid/vlen', substr( $extra, $x, 4 ) ); $data = substr( $extra, $x + 4, (int) $f['len'] ); if ( 0x0001 === (int) $f['id'] ) { $d = 0; if ( 0xFFFFFFFF === $usize && strlen( $data ) >= $d + 8 ) { $usize = (int) unpack( 'P', substr( $data, $d, 8 ) )[1]; $d += 8; } if ( 0xFFFFFFFF === $csize && strlen( $data ) >= $d + 8 ) { $csize = (int) unpack( 'P', substr( $data, $d, 8 ) )[1]; $d += 8; } if ( 0xFFFFFFFF === $lho && strlen( $data ) >= $d + 8 ) { $lho = (int) unpack( 'P', substr( $data, $d, 8 ) )[1]; } } elseif ( 0x5455 === (int) $f['id'] && strlen( $data ) >= 5 && ( ord( $data[0] ) & 1 ) ) { $mtime = (int) unpack( 'V', substr( $data, 1, 4 ) )[1]; } $x += 4 + (int) $f['len']; } $out[] = array( 'name' => $name, 'method' => (int) $h['method'], 'flags' => (int) $h['flags'], 'crc' => sprintf( '%08x', (int) $h['crc'] ), 'csize' => $csize, 'usize' => $usize, 'lho' => $lho, 'mtime' => $mtime, ); $pos += 46 + (int) $h['nlen'] + (int) $h['elen'] + (int) $h['clen']; $out[ count( $out ) - 1 ]['next'] = $pos; if ( $deadline > 0 && 0 === ( count( $out ) & 511 ) && microtime( true ) >= $deadline ) { break; } } return array( 'entries' => $out, 'pos' => $pos, 'eof' => $pos >= $this->cd_size, ); } /** * Find one entry by exact name (walks the directory; use for a few well-known members). * * @param string $name Entry name. * @return array|null */ public function find( string $name ): ?array { $pos = 0; do { $r = $this->entries( $pos, 2048 ); foreach ( $r['entries'] as $e ) { if ( $e['name'] === $name ) { return $e; } } $pos = $r['pos']; } while ( ! $r['eof'] && ! empty( $r['entries'] ) ); return null; } /** * Find several entries by name in one directory pass (names from the end first: * sidecars are written last). * * @param list $names Names. * @return array> */ public function find_many( array $names ): array { $want = array_flip( $names ); $out = array(); $pos = 0; do { $r = $this->entries( $pos, 4096 ); foreach ( $r['entries'] as $e ) { if ( isset( $want[ $e['name'] ] ) ) { $out[ $e['name'] ] = $e; } } $pos = $r['pos']; } while ( ! $r['eof'] && ! empty( $r['entries'] ) && count( $out ) < count( $want ) ); return $out; } /** * Stream a member to a sink callback. * * @param array $entry Entry from entries(). * @param callable $sink function( string $bytes ): void. * @param array $resume Resume state from a previous partial call. * @param float $deadline Deadline (0 = run to the end). * @return array{done:bool,sha256?:string,size?:int,resume?:array} * @throws State_Exception On corrupt data or CRC mismatch. */ public function stream( array $entry, callable $sink, array $resume = array(), float $deadline = 0.0 ): array { $data_off = $this->data_offset( (int) $entry['lho'], (string) $entry['name'] ); $method = (int) $entry['method']; $csize = (int) $entry['csize']; $usize = (int) $entry['usize']; if ( ( (int) $entry['flags'] ) & 1 ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: entry */ __( 'Encrypted zip entries are not supported: %s', '007aj-backup-restore-verifier' ), (string) $entry['name'] ) ) ); } if ( 0 !== $method && 8 !== $method ) { throw new State_Exception( esc_html( sprintf( /* translators: 1: method, 2: entry */ __( 'Unsupported compression method %1$d for %2$s', '007aj-backup-restore-verifier' ), $method, (string) $entry['name'] ) ) ); } $done = 0; $hash = null; if ( 0 === $method && ! empty( $resume['done'] ) ) { $saved = $resume['rh'] ?? ( empty( $resume['hs'] ) ? null : array( 'm' => 'n', 'c' => 1, 'hs' => $resume['hs'], 'hc' => $resume['hc'] ?? '', ) ); $hash = Resumable_Hash::import( $saved ); if ( null !== $hash ) { $done = (int) $resume['done']; } } if ( 0 === $done || null === $hash ) { $done = 0; $hash = Resumable_Hash::start( 0 === $method ? $usize : 0, $deadline ); } fseek( $this->fh, $data_off + ( 0 === $method ? $done : 0 ) ); $inf = 8 === $method ? inflate_init( ZLIB_ENCODING_RAW ) : null; $read = 0 === $method ? $done : 0; $out_n = $done; $resume_ok = 0 === $method && $hash->can_pause(); while ( $read < $csize ) { $chunk = fread( $this->fh, (int) min( self::SLICE, $csize - $read ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( false === $chunk || '' === $chunk ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: entry */ __( 'Archive is truncated while reading %s', '007aj-backup-restore-verifier' ), (string) $entry['name'] ) ) ); } $read += strlen( $chunk ); if ( null !== $inf ) { $chunk = @inflate_add( $inf, $chunk, $read >= $csize ? ZLIB_FINISH : ZLIB_SYNC_FLUSH ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $chunk ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: entry */ __( 'Corrupt compressed data in %s', '007aj-backup-restore-verifier' ), (string) $entry['name'] ) ) ); } } if ( '' !== $chunk ) { $hash->update( $chunk ); $out_n += strlen( $chunk ); $sink( $chunk ); } if ( $resume_ok && $deadline > 0 && $read < $csize && microtime( true ) >= $deadline ) { return array( 'done' => false, 'resume' => array( 'done' => $out_n, 'rh' => $hash->export(), ), ); } } $crc_hex = $hash->crc32b(); if ( $out_n !== $usize || ! hash_equals( (string) $entry['crc'], $crc_hex ) ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: entry */ __( 'CRC or size mismatch in archive member %s', '007aj-backup-restore-verifier' ), (string) $entry['name'] ) ) ); } return array( 'done' => true, 'sha256' => $hash->sha256(), 'size' => $out_n, ); } /** * Byte range of a stored (uncompressed) member's data inside the archive, so a caller can * read it at any offset across requests. * * @param array $entry Entry from entries() / find_many(). * @return array{0:int,1:int} Absolute offset and length. * @throws State_Exception When the member is compressed or encrypted. */ public function stored_range( array $entry ): array { if ( 0 !== (int) $entry['method'] || ( ( (int) $entry['flags'] ) & 1 ) ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: entry */ __( 'Archive member is not stored uncompressed: %s', '007aj-backup-restore-verifier' ), (string) $entry['name'] ) ) ); } return array( $this->data_offset( (int) $entry['lho'], (string) $entry['name'] ), (int) $entry['csize'] ); } /** * Read a whole (small) member into memory. * * @param array $entry Entry. * @param int $limit Max bytes. * @throws State_Exception When too large. */ public function read_small( array $entry, int $limit = 8388608 ): string { if ( (int) $entry['usize'] > $limit ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: entry */ __( 'Archive member is unexpectedly large: %s', '007aj-backup-restore-verifier' ), (string) $entry['name'] ) ) ); } $buf = ''; $this->stream( $entry, static function ( string $b ) use ( &$buf ): void { $buf .= $b; } ); return $buf; } /** * Absolute data offset of a member (after its local header). * * @param int $lho Local header offset. * @param string $name Entry name (errors). * @throws State_Exception On a bad local header. */ private function data_offset( int $lho, string $name ): int { fseek( $this->fh, $lho ); $h = unpack( 'Vsig/vneed/vflags/vmethod/vtime/vdate/Vcrc/Vcsize/Vusize/vnlen/velen', (string) fread( $this->fh, 30 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( ! is_array( $h ) || 0x04034b50 !== (int) $h['sig'] ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: entry */ __( 'Corrupt local header for archive member %s', '007aj-backup-restore-verifier' ), $name ) ) ); } return $lho + 30 + (int) $h['nlen'] + (int) $h['elen']; } }