key = $key ? $key : null; } /** * Open (or reopen in append mode) the archive for writing. * * @param string $path Archive path (volume 1). * @param bool $append Continue an existing archive. * @param int $part_bytes Per-volume size cap (0 = no splitting). * @return true|\WP_Error */ public function open( $path, $append = false, $part_bytes = 0 ) { $this->base = $path; $this->part_bytes = max( 0, (int) $part_bytes ); if ( $append && file_exists( $path ) ) { // Continue on the highest existing volume. $this->vol_index = 1; while ( file_exists( self::volume_path( $path, $this->vol_index + 1 ) ) ) { ++$this->vol_index; } $vp = self::volume_path( $path, $this->vol_index ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $this->fh = @fopen( $vp, 'ab' ); if ( ! $this->fh ) { return new \WP_Error( 'aisiteark_archive_open', __( 'Could not open the archive for writing.', 'ai-siteark' ) ); } $this->vol_size = (int) filesize( $vp ); return true; } $this->vol_index = 1; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $this->fh = @fopen( $path, 'wb' ); if ( ! $this->fh ) { return new \WP_Error( 'aisiteark_archive_open', __( 'Could not open the archive for writing.', 'ai-siteark' ) ); } fwrite( $this->fh, self::MAGIC ); $this->vol_size = strlen( self::MAGIC ); return true; } /** * Roll to the next volume between entries when the cap is reached. * * @return void */ private function maybe_roll() { if ( $this->part_bytes <= 0 || ! $this->fh || $this->vol_size < $this->part_bytes ) { return; } fclose( $this->fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose ++$this->vol_index; $vp = self::volume_path( $this->base, $this->vol_index ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $this->fh = @fopen( $vp, 'wb' ); fwrite( $this->fh, self::MAGIC ); $this->vol_size = strlen( self::MAGIC ); } /** * Write bytes to the current volume, tracking its size. * * @param string $bytes Bytes. * @return void */ private function put( $bytes ) { fwrite( $this->fh, $bytes ); $this->vol_size += strlen( $bytes ); } /** * Write an in-memory entry (manifest, DB chunk, small blob). * * @param array $header Entry header (type, path, enc, …). `size` is set here. * @param string $payload Raw payload bytes. * @return void */ public function write_entry( array $header, $payload ) { $this->maybe_roll(); // Split only between entries. $payload = (string) $payload; if ( $this->key ) { $iv = Crypto::iv(); $payload = Crypto::enc( $payload, $this->key, $iv ); $header['iv'] = base64_encode( $iv ); } $header['size'] = strlen( $payload ); $this->write_header( $header, strlen( $payload ) ); $this->put( $payload ); } /** * Stream a file into the archive without loading it into memory. * * @param array $header Entry header (type=file, path, mtime, mode). * @param string $source_path Absolute source file path. * @param int $chunk Read chunk size in bytes. * @return bool */ public function write_file_entry( array $header, $source_path, $chunk = 1048576 ) { $this->maybe_roll(); // Split only between entries — this file stays in one volume. // Open the source FIRST. The header commits to an exact payload length, so if // the file can't be read we must write nothing at all — writing a header that // promises N bytes with no payload would desync (corrupt) the whole archive. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $src = @fopen( $source_path, 'rb' ); if ( ! $src ) { return false; } // Size from the open handle (consistent with what we're about to read). $stat = fstat( $src ); $size = ( is_array( $stat ) && isset( $stat['size'] ) ) ? (int) $stat['size'] : (int) filesize( $source_path ); $iv = null; if ( $this->key ) { $iv = Crypto::iv(); $header['iv'] = base64_encode( $iv ); // CTR keeps length == filesize. } $header['size'] = $size; $this->write_header( $header, $size ); // Stream exactly $size bytes. If the file shrank mid-read (a live file), pad // the remainder with zeros so the payload length always matches the header — // the archive stays consistent rather than corrupting the next entry. $blocks = 0; $remaining = $size; while ( $remaining > 0 ) { $buf = fread( $src, (int) min( $chunk, $remaining ) ); if ( '' === $buf || false === $buf ) { break; } $read = strlen( $buf ); // CTR is length-preserving, so this holds post-encrypt. $remaining -= $read; if ( $this->key ) { $buf = Crypto::enc( $buf, $this->key, Crypto::iv_add( $iv, $blocks ) ); $blocks += Crypto::blocks( $read ); } $this->put( $buf ); } fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( $remaining > 0 ) { $this->pad_zeros( $remaining, $iv, $blocks, $chunk ); } return true; } /** * Write $count zero bytes to the current entry (encrypting to match), used to * backfill when a source file shrank between sizing and reading. * * @param int $count Plaintext bytes to pad. * @param string|null $iv Base IV when encrypting. * @param int $blocks Cipher blocks already emitted for this entry. * @param int $chunk Chunk size. * @return void */ private function pad_zeros( $count, $iv, $blocks, $chunk ) { $remaining = (int) $count; while ( $remaining > 0 ) { $n = (int) min( $chunk, $remaining ); $buf = str_repeat( "\0", $n ); if ( $this->key ) { $buf = Crypto::enc( $buf, $this->key, Crypto::iv_add( $iv, $blocks ) ); $blocks += Crypto::blocks( $n ); } $this->put( $buf ); $remaining -= $n; } } /** * Write an entry header + payload length. * * @param array $header Header array. * @param int $payload_len Payload byte length. * @return void */ private function write_header( array $header, $payload_len ) { $hjson = wp_json_encode( $header ); $this->put( pack( 'N', strlen( $hjson ) ) ); $this->put( $hjson ); $this->put( pack( 'J', $payload_len ) ); } /** * Current byte offset within the current volume. * * @return int */ public function tell() { return $this->fh ? (int) ftell( $this->fh ) : 0; } /** * Number of volumes written so far. * * @return int */ public function volume_count() { return (int) $this->vol_index; } /** * Close the archive. * * @return void */ public function close() { if ( $this->fh ) { fclose( $this->fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose $this->fh = null; } } }