0 ) { return new WP_Error( 'transtrix_archive_missing', __( 'The package file was removed while it was being built.', 'transtrix' ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. $handle = fopen( $path, $exists ? 'r+b' : 'w+b' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. if ( false === $handle ) { return new WP_Error( 'transtrix_archive_unwritable', __( 'The package could not be saved. Check that the uploads folder is writable.', 'transtrix' ) ); } if ( ! ftruncate( $handle, $offset ) ) { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. return new WP_Error( 'transtrix_archive_truncate_failed', __( 'The export could not recover after an interruption. Start it again.', 'transtrix' ) ); } fseek( $handle, $offset ); $writer = new self(); $writer->handle = $handle; $writer->offset = $offset; return $writer; } /** * Append an entry whose contents are already in memory. * * @param string $name Entry path inside the archive. * @param string $data Contents. * @return array|WP_Error The entry record to journal. */ public function add_string( string $name, string $data ) { $start = $this->offset; $size = strlen( $data ); $crc = crc32( $data ); // One write for header and contents: one system call per small file, not two. $written = $this->write( $this->local_header( $name, $crc, $size ) . $data ); if ( is_wp_error( $written ) ) { return $written; } return $this->commit( $name, $start, $size, $crc ); } /** * Append part of a file, straight from disk. * * The range is read twice: once to compute the CRC that has to sit in the * header before the bytes, and once to copy it. Reading twice beats holding * the range in memory, and beats a data descriptor, which several readers * handle badly. * * @param string $name Entry path inside the archive. * @param string $source Source file. * @param int $from Byte offset in the source. * @param int $length Bytes to copy. * @return array|WP_Error */ public function add_file_range( string $name, string $source, int $from, int $length ) { // Most files on a site are small: read them once, rather than once for // the CRC the header needs and again to copy them. if ( $length <= self::CHUNK ) { $data = 0 === $length ? '' : file_get_contents( $source, false, null, $from, $length ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. if ( false === $data ) { return new WP_Error( 'transtrix_source_unreadable', sprintf( /* translators: %s: file name. */ __( '%s could not be read and was left out.', 'transtrix' ), wp_basename( $source ) ) ); } if ( strlen( $data ) !== $length ) { return new WP_Error( 'transtrix_source_short_read', sprintf( /* translators: %s: file name. */ __( '%s changed while it was being copied. Try the export again.', 'transtrix' ), wp_basename( $source ) ) ); } return $this->add_string( $name, $data ); } $crc = $this->range_crc( $source, $from, $length ); if ( is_wp_error( $crc ) ) { return $crc; } $start = $this->offset; $written = $this->write( $this->local_header( $name, $crc, $length ) ); if ( is_wp_error( $written ) ) { return $written; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. $in = fopen( $source, 'rb' ); if ( false === $in ) { return new WP_Error( 'transtrix_source_unreadable', sprintf( /* translators: %s: file name. */ __( '%s could not be read and was left out.', 'transtrix' ), wp_basename( $source ) ) ); } fseek( $in, $from ); $left = $length; while ( $left > 0 ) { $chunk = fread( $in, (int) min( self::CHUNK, $left ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. if ( false === $chunk || '' === $chunk ) { fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. return new WP_Error( 'transtrix_source_short_read', sprintf( /* translators: %s: file name. */ __( '%s changed while it was being copied. Try the export again.', 'transtrix' ), wp_basename( $source ) ) ); } $written = $this->write( $chunk ); if ( is_wp_error( $written ) ) { fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. return $written; } $left -= strlen( $chunk ); } fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. return $this->commit( $name, $start, $length, $crc ); } /** * Write the central directory and close the archive off. * * Until this runs the file is not a valid zip, which is exactly right: a * cancelled or half-finished archive must never look restorable. * * @param array> $entries Journalled entries, in order. * @return int|WP_Error Final archive size. */ public function finalize( array $entries ) { $start = $this->offset; $dir = ''; foreach ( $entries as $entry ) { $dir .= $this->central_header( (string) $entry['name'], (int) $entry['crc'], (int) $entry['size'], (int) $entry['offset'] ); } $written = $this->write( $dir ); if ( is_wp_error( $written ) ) { return $written; } $written = $this->write( $this->end_record( count( $entries ), strlen( $dir ), $start ) ); if ( is_wp_error( $written ) ) { return $written; } return $this->offset; } /** * The current committed offset. * * @return int */ public function offset(): int { return $this->offset; } /** * Close the handle. * * @return void */ public function close(): void { if ( is_resource( $this->handle ) ) { fflush( $this->handle ); fclose( $this->handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. } $this->handle = null; } /** * Flush, then hand back the record the caller journals. * * The order matters and is the whole crash story: bytes, then flush, then * the journal line, then the offset in the cursor. A crash between any two * of those is repaired by the next step's truncate. * * @param string $name Entry name. * @param int $start Offset the local header began at. * @param int $size Entry size. * @param int $crc CRC32 of the contents. * @return array|WP_Error */ private function commit( string $name, int $start, int $size, int $crc ) { if ( ! fflush( $this->handle ) ) { return new WP_Error( 'transtrix_archive_flush_failed', __( 'The package could not be saved. Check that the server has free space.', 'transtrix' ) ); } return array( 'name' => $name, 'offset' => $start, 'size' => $size, 'crc' => $crc, ); } /** * Append raw bytes. * * @param string $bytes Bytes. * @return true|WP_Error */ private function write( string $bytes ) { $count = fwrite( $this->handle, $bytes ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. if ( false === $count || strlen( $bytes ) !== $count ) { return new WP_Error( 'transtrix_archive_write_failed', __( 'The package could not be saved. The server may be out of space.', 'transtrix' ) ); } $this->offset += $count; return true; } /** * CRC32 of a byte range, computed without holding it in memory. * * @param string $source Source file. * @param int $from Offset. * @param int $length Bytes. * @return int|WP_Error */ private function range_crc( string $source, int $from, int $length ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. $in = fopen( $source, 'rb' ); if ( false === $in ) { return new WP_Error( 'transtrix_source_unreadable', sprintf( /* translators: %s: file name. */ __( '%s could not be read and was left out.', 'transtrix' ), wp_basename( $source ) ) ); } fseek( $in, $from ); $context = hash_init( 'crc32b' ); $left = $length; while ( $left > 0 ) { $chunk = fread( $in, (int) min( self::CHUNK, $left ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. if ( false === $chunk || '' === $chunk ) { fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. return new WP_Error( 'transtrix_source_short_read', sprintf( /* translators: %s: file name. */ __( '%s changed while it was being copied. Try the export again.', 'transtrix' ), wp_basename( $source ) ) ); } hash_update( $context, $chunk ); $left -= strlen( $chunk ); } fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. return (int) hexdec( hash_final( $context ) ); } /** * A local file header, always carrying its ZIP64 extra field. * * ZIP64 is emitted unconditionally rather than only past four gigabytes. * Branching on size means one of the two branches is barely exercised, and * the rarely-taken one is where the corrupt-archive bug hides. * * @param string $name Entry name. * @param int $crc CRC32. * @param int $size Size in bytes. * @return string */ private function local_header( string $name, int $crc, int $size ): string { $big = $size >= self::threshold(); $extra = $big ? pack( 'vvPP', 0x0001, 16, $size, $size ) : ''; $field = $big ? 0xFFFFFFFF : $size; return pack( 'VvvvvvVVVvv', 0x04034b50, $big ? self::VERSION_ZIP64 : self::VERSION, 0, 0, self::dos_time(), self::dos_date(), $crc, $field, $field, strlen( $name ), strlen( $extra ) ) . $name . $extra; } /** * A central directory header for one entry. * * @param string $name Entry name. * @param int $crc CRC32. * @param int $size Size in bytes. * @param int $offset Offset of the local header. * @return string */ private function central_header( string $name, int $crc, int $size, int $offset ): string { $threshold = self::threshold(); $big_size = $size >= $threshold; $big_off = $offset >= $threshold; // The ZIP64 extra field carries only the fields that were masked, in // the order the spec fixes: uncompressed size, compressed size, local // header offset. Emitting it unconditionally is legal and Info-ZIP // 6.00 mis-reads the result, so it is emitted only when needed. $extra = ''; if ( $big_size || $big_off ) { $payload = ( $big_size ? pack( 'PP', $size, $size ) : '' ) . ( $big_off ? pack( 'P', $offset ) : '' ); $extra = pack( 'vv', 0x0001, strlen( $payload ) ) . $payload; } $version = '' === $extra ? self::VERSION : self::VERSION_ZIP64; return pack( 'VvvvvvvVVVvvvvvVV', 0x02014b50, $version, $version, 0, 0, self::dos_time(), self::dos_date(), $crc, $big_size ? 0xFFFFFFFF : $size, $big_size ? 0xFFFFFFFF : $size, strlen( $name ), strlen( $extra ), 0, 0, 0, 0, $big_off ? 0xFFFFFFFF : $offset ) . $name . $extra; } /** * The size at which an entry switches to ZIP64. * * @return int */ private static function threshold(): int { /** * Filter the ZIP64 threshold. Lowered by the test suite so the ZIP64 * path runs without producing a four-gigabyte fixture. * * @param int $threshold Bytes. */ return (int) apply_filters( 'transtrix_zip64_threshold', self::ZIP64_THRESHOLD ); } /** * The ZIP64 end record, its locator, and the classic end record. * * @param int $count Entry count. * @param int $size Central directory size. * @param int $offset Central directory offset. * @return string */ private function end_record( int $count, int $size, int $offset ): string { $zip64 = pack( 'VPvvVVPPPP', 0x06064b50, 44, self::VERSION_ZIP64, self::VERSION_ZIP64, 0, 0, $count, $count, $size, $offset ); $locator = pack( 'VVPV', 0x07064b50, 0, $offset + $size, 1 ); // The ZIP64 records above are always written. The classic record below // carries real values whenever they fit, and only falls back to the // 0xFFFF / 0xFFFFFFFF "look in ZIP64" markers when they genuinely do // not. Masking unconditionally is legal by the letter of the spec and // Info-ZIP refuses to read it, which is a good reminder that the spec // is not the thing that has to open the file. $end = pack( 'VvvvvVVv', 0x06054b50, 0, 0, min( $count, 0xFFFF ), min( $count, 0xFFFF ), min( $size, 0xFFFFFFFF ), min( $offset, 0xFFFFFFFF ), 0 ); return $zip64 . $locator . $end; } /** * DOS time field. Fixed, so an archive is reproducible. * * @return int */ private static function dos_time(): int { return 0; } /** * DOS date field: 1 January 1980, the epoch of the format. * * @return int */ private static function dos_date(): int { return 33; } }