fail( __( 'Multisite is not supported.', '007aj-backup-restore-verifier' ), $cursor ); } if ( ! Working_Dir::ensure() ) { return $this->fail( __( 'Unable to create protected working directory.', '007aj-backup-restore-verifier' ), $cursor ); } $job_dir = Working_Dir::ensure_job( $job_id ); try { // Roll working files back to what the last committed tick recorded. $state_dir = State_Files::dir( $job_dir ); State_Files::rewind( $state_dir, isset( $cursor['jr'] ) && is_array( $cursor['jr'] ) ? $cursor['jr'] : array() ); switch ( $phase ) { case Job_States::PHASE_INIT: $result = $this->phase_init( $job_id, $job_dir, $cursor, $config ); break; case Job_States::PHASE_DUMP_DB: $result = $this->phase_dump_db( $job_id, $job_dir, $cursor, $deadline ); break; case Job_States::PHASE_PACK: $result = $this->phase_pack( $job_id, $job_dir, $cursor, $config, $deadline ); break; case Job_States::PHASE_CHECKSUM: $result = $this->phase_checksum( $job_id, $job_dir, $cursor, $config, $deadline ); break; case Job_States::PHASE_FINALIZE: $result = $this->phase_finalize( $job_id, $job_dir, $cursor, $config, $deadline ); break; default: return $this->fail( sprintf( /* translators: %s: phase name */ __( 'Unknown backup phase: %s', '007aj-backup-restore-verifier' ), $phase ), $cursor ); } if ( Job_States::STATUS_RUNNING === ( $result['status'] ?? '' ) && isset( $result['cursor'] ) && is_array( $result['cursor'] ) ) { $result['cursor']['jr'] = State_Files::journal( $state_dir ); } return $result; } catch ( \Throwable $e ) { $message = Job_Error::describe( $e, $phase ); Logger::error( $job_id, 'Backup exception: ' . $message ); return $this->fail( $message, $cursor ); } } /** * Init phase. * * @param int $job_id Job id. * @param string $job_dir Job dir. * @param array $cursor Cursor. * @param array $config Config. * @return array{status:string,phase?:string,progress_pct?:int,cursor?:array,error?:string} */ private function phase_init( int $job_id, string $job_dir, array $cursor, array $config ): array { unset( $config ); Logger::info( $job_id, 'Backup init.' ); $free = @disk_free_space( Working_Dir::root() ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false !== $free && $free < 25 * MB_IN_BYTES ) { return $this->fail( __( 'Not enough disk space available for backup.', '007aj-backup-restore-verifier' ), $cursor ); } $cursor['job_dir'] = $job_dir; $cursor['started'] = time(); return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_DUMP_DB, 'progress_pct' => 5, 'cursor' => $cursor, ); } /** * Dump DB phase. * * @param int $job_id Job id. * @param string $job_dir Job dir. * @param array $cursor Cursor. * @param float $deadline Deadline. * @return array{status:string,phase?:string,progress_pct?:int,cursor?:array,error?:string} */ private function phase_dump_db( int $job_id, string $job_dir, array $cursor, float $deadline ): array { $dumper = new Db_Dumper(); $result = $dumper->tick( $job_id, $job_dir, $cursor, $deadline ); if ( ! empty( $result['error'] ) ) { return $this->fail( (string) $result['error'], $result['cursor'] ); } $cursor = $result['cursor']; $index = (int) ( $cursor['table_index'] ?? 0 ); $total = max( 1, (int) ( $cursor['table_count'] ?? 1 ) ); $pct = 5 + (int) floor( 35 * min( 1, $index / $total ) ); if ( ! empty( $result['done'] ) ) { return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_PACK, 'progress_pct' => 40, 'cursor' => $cursor, ); } return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_DUMP_DB, 'progress_pct' => $pct, 'cursor' => $cursor, ); } /** * Pack files phase. * * @param int $job_id Job id. * @param string $job_dir Job dir. * @param array $cursor Cursor. * @param array $config Config. * @param float $deadline Deadline. * @return array{status:string,phase?:string,progress_pct?:int,cursor?:array,error?:string} */ private function phase_pack( int $job_id, string $job_dir, array $cursor, array $config, float $deadline ): array { $packer = new File_Packer(); $result = $packer->tick( $job_id, $job_dir, $cursor, $config, $deadline ); if ( ! empty( $result['error'] ) ) { return $this->fail( (string) $result['error'], $result['cursor'] ); } $cursor = $result['cursor']; $pct = 40 + (int) floor( 45 * File_Packer::progress( $cursor ) ); if ( ! empty( $result['done'] ) ) { return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_CHECKSUM, 'progress_pct' => 85, 'cursor' => $cursor, ); } return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_PACK, 'progress_pct' => min( 84, $pct ), 'cursor' => $cursor, ); } /** * Checksum phase: seal (sidecars, checksums.sha256, manifest.json, directory), * re-read every member to verify it, then hash the finished archive. * * @param int $job_id Job id. * @param string $job_dir Job dir. * @param array $cursor Cursor. * @param array $config Config. * @param float $deadline Deadline. * @return array{status:string,phase?:string,progress_pct?:int,cursor?:array,error?:string} */ private function phase_checksum( int $job_id, string $job_dir, array $cursor, array $config, float $deadline ): array { $zip_path = (string) ( $cursor['zip_path'] ?? '' ); if ( '' === $zip_path || ! is_readable( $zip_path ) ) { return $this->fail( __( 'Backup zip missing before checksum phase.', '007aj-backup-restore-verifier' ), $cursor ); } $state_dir = State_Files::dir( $job_dir ); $sums = $state_dir . '/checksums.txt'; $sl = isset( $cursor['sl'] ) && is_array( $cursor['sl'] ) ? $cursor['sl'] : array( 'step' => 'prepare' ); $stop = $deadline - 1.0; $running = function ( array $cursor, array $sl, int $pct ): array { $cursor['sl'] = $sl; return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_CHECKSUM, 'progress_pct' => $pct, 'cursor' => $cursor, ); }; if ( 'prepare' === $sl['step'] ) { $components = isset( $config['components'] ) && is_array( $config['components'] ) ? $config['components'] : array( 'database' => true, 'plugins' => true, 'themes' => true, 'uploads' => true, ); $backup_id = isset( $config['backup_id'] ) ? (string) $config['backup_id'] : ''; if ( '' === $backup_id ) { $backup_id = 'rp-' . $job_id . '-' . wp_generate_password( 8, false, false ); } $manifest_args = array( 'trigger' => $this->job_trigger( $config ), 'pre_update' => $this->job_pre_update( $config ), 'components' => $components, 'exclusions' => Working_Dir::default_exclusions(), 'backup_id' => $backup_id, 'backup_mode' => isset( $config['backup_mode'] ) ? (string) $config['backup_mode'] : 'full', 'encryption' => isset( $cursor['encryption'] ) && is_array( $cursor['encryption'] ) ? $cursor['encryption'] : null, 'config' => $config, 'cursor' => $cursor, 'state_dir' => $state_dir, ); /** * Let extensions attach sidecar members before the archive is sealed. * * Return the payload with optional keys: * extra_files array entry name => absolute file path (streamed; any size) * extra_members array entry name => small string contents * manifest_args array (small values only) * cursor array (small values only) * * @param array $payload Payload. * @param array $config Job config. * @param string $zip_path Zip path. */ $seal = apply_filters( 'brverify_backup_before_manifest', array( 'cursor' => $cursor, 'extra_files' => array(), 'extra_members' => array(), 'manifest_args' => $manifest_args, 'state_dir' => $state_dir, 'checksums_path' => $sums, ), $config, $zip_path ); $extras = array(); if ( is_array( $seal ) ) { if ( isset( $seal['cursor'] ) && is_array( $seal['cursor'] ) ) { $cursor = $seal['cursor']; } if ( isset( $seal['manifest_args'] ) && is_array( $seal['manifest_args'] ) ) { $manifest_args = $seal['manifest_args']; } foreach ( (array) ( $seal['extra_files'] ?? array() ) as $entry => $path ) { $entry = (string) $entry; if ( '' !== $entry && Path_Guard::is_safe_relative( $entry ) && is_readable( (string) $path ) ) { $extras[] = array( $entry, (string) $path ); } } $i = 0; foreach ( (array) ( $seal['extra_members'] ?? array() ) as $entry => $contents ) { $entry = (string) $entry; if ( '' === $entry || ! Path_Guard::is_safe_relative( $entry ) ) { continue; } $file = $state_dir . '/extra-' . ( $i++ ) . '.dat'; State_Files::write_atomic( $file, (string) $contents ); $extras[] = array( $entry, $file ); } } $manifest_args['backup_id'] = (string) ( $manifest_args['backup_id'] ?? $backup_id ); unset( $manifest_args['cursor'], $manifest_args['config'] ); State_Files::write_atomic( $state_dir . '/seal.json', (string) wp_json_encode( array( 'extras' => $extras, 'manifest_args' => $manifest_args, ) ) ); $cursor['backup_id'] = (string) $manifest_args['backup_id']; $sl = array( 'step' => 'extras', 'i' => 0, ); } $seal_data = json_decode( (string) file_get_contents( $state_dir . '/seal.json' ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( ! is_array( $seal_data ) ) { return $this->fail( __( 'Seal state file is missing or corrupt.', '007aj-backup-restore-verifier' ), $cursor ); } if ( in_array( $sl['step'], array( 'extras', 'sums', 'manifest' ), true ) ) { $writer = new Zip_Writer( $zip_path, $state_dir . '/zipcd.bin', isset( $cursor['zw'] ) && is_array( $cursor['zw'] ) ? $cursor['zw'] : array(), $sums ); $writer->open(); try { if ( 'extras' === $sl['step'] ) { $extras = (array) ( $seal_data['extras'] ?? array() ); while ( (int) $sl['i'] < count( $extras ) ) { list( $entry, $path ) = $extras[ (int) $sl['i'] ]; // Data, directory record and checksum line are committed together by the writer. $res = $writer->add_file( (string) $path, (string) $entry, $stop ); if ( empty( $res['done'] ) ) { $writer->sync(); return $running( array_merge( $cursor, array( 'zw' => $writer->state() ) ), $sl, 86 ); } $sl['i'] = (int) $sl['i'] + 1; if ( microtime( true ) >= $stop ) { $writer->sync(); return $running( array_merge( $cursor, array( 'zw' => $writer->state() ) ), $sl, 86 ); } } $sl['step'] = 'sums'; } if ( 'sums' === $sl['step'] ) { $writer->sync(); if ( empty( $sl['ph'] ) ) { $sl['ph'] = (string) hash_file( 'sha256', $sums ); $sl['pn'] = $writer->listed(); } // The list itself is not listed in the list. $res = $writer->add_file( $sums, 'checksums.sha256', $stop, false, false ); if ( empty( $res['done'] ) ) { $writer->sync(); return $running( array_merge( $cursor, array( 'zw' => $writer->state() ) ), $sl, 87 ); } $sl['step'] = 'manifest'; } if ( 'manifest' === $sl['step'] ) { $args = (array) $seal_data['manifest_args']; $args['payload_hash'] = (string) $sl['ph']; $args['cursor'] = $cursor; $args['config'] = $config; $manifest = Manifest::build( $args ); $manifest['integrity']['payload_list'] = 'body'; $writer->add_string( 'manifest.json', Manifest::encode( $manifest ), false ); $writer->finish(); $chain = isset( $manifest['chain'] ) && is_array( $manifest['chain'] ) ? $manifest['chain'] : array(); $cursor['chain'] = array( 'mode' => (string) ( $chain['mode'] ?? 'full' ), 'backup_id' => (string) ( $chain['backup_id'] ?? $cursor['backup_id'] ), 'parent_backup_id' => $chain['parent_backup_id'] ?? null, 'base_full_id' => (string) ( $chain['base_full_id'] ?? '' ), 'sequence' => (int) ( $chain['sequence'] ?? 0 ), ); $cursor['payload_hash'] = (string) $sl['ph']; $sl['step'] = 'verify'; $sl['cd'] = 0; $sl['so'] = 0; $sl['n'] = 0; $sl['ci'] = 0; $sl['en'] = $writer->count(); } $writer->sync(); } finally { $cursor['zw'] = $writer->state(); $writer->close(); } return $running( $cursor, $sl, 88 ); } if ( 'verify' === $sl['step'] ) { $res = $this->verify_step( $job_id, $zip_path, $sums, $sl, $stop ); return $running( $cursor, $sl, 'filehash' === $sl['step'] ? 93 : 90 ); } if ( 'filehash' === $sl['step'] ) { $res = self::hash_file_resumable( $zip_path, $sl, $stop ); if ( empty( $res ) ) { return $running( $cursor, $sl, 94 ); } $cursor['zip_sha256'] = $res; unset( $sl['fo'], $sl['fh'], $sl['rh'] ); $sl['step'] = 'done'; $cursor['sl'] = $sl; return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_FINALIZE, 'progress_pct' => 95, 'cursor' => $cursor, ); } return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_FINALIZE, 'progress_pct' => 95, 'cursor' => $cursor, ); } /** * Verify step: walk the central directory and the checksum list in parallel. * * Payload members (directory index below the listed count) must match the list * one-for-one and in the same order; their contents are re-hashed. The last two * members must be checksums.sha256 and manifest.json. At the end both sequences * must be exhausted together: no member without a line and no line without a member. * * @param int $job_id Job id. * @param string $zip_path Archive. * @param string $sums Checksum list. * @param array $sl Seal state (by reference). * @param float $stop Deadline. * @throws State_Exception With a precise description of the first inconsistency. */ private function verify_step( int $job_id, string $zip_path, string $sums, array &$sl, float $stop ): bool { $listed = (int) ( $sl['pn'] ?? -1 ); $total = (int) ( $sl['en'] ?? -1 ); $fail = static function ( string $msg ): void { throw new State_Exception( esc_html( $msg . ' ' . __( 'The archive was not accepted. Please run the backup again; if this repeats, contact support with this message.', '007aj-backup-restore-verifier' ) ) ); }; $reader = new Zip_Reader( $zip_path ); try { if ( $total >= 0 && $reader->count !== $total ) { $fail( sprintf( /* translators: 1: entries in the archive directory, 2: entries written */ __( 'Archive directory lists %1$d members but %2$d were written.', '007aj-backup-restore-verifier' ), (int) $reader->count, $total ) ); } while ( microtime( true ) < $stop ) { $batch = $reader->entries( (int) $sl['cd'], 64 ); if ( empty( $batch['entries'] ) ) { break; } foreach ( $batch['entries'] as $entry ) { $ci = (int) ( $sl['ci'] ?? 0 ); $name = (string) $entry['name']; if ( $listed < 0 && in_array( $name, array( 'checksums.sha256', 'manifest.json' ), true ) ) { // Job sealed by an earlier version: those two members are identified by name. $sl['cd'] = $this->cd_next( $sl, $entry ); $sl['ci'] = $ci + 1; continue; } if ( $listed >= 0 && $ci >= $listed ) { $want = $ci === $listed ? 'checksums.sha256' : ( $ci === $listed + 1 ? 'manifest.json' : '' ); if ( $name !== $want ) { $fail( sprintf( /* translators: 1: member index, 2: member name, 3: number of checksum entries */ __( 'Archive member #%1$d (%2$s) has no entry in the checksum list (the list has %3$d entries).', '007aj-backup-restore-verifier' ), $ci + 1, $name, $listed ) ); } $sl['cd'] = $this->cd_next( $sl, $entry ); $sl['ci'] = $ci + 1; continue; } $lines = State_Files::read_rows_text( $sums, (int) $sl['so'], 1 ); if ( empty( $lines['lines'] ) ) { $fail( sprintf( /* translators: 1: member index, 2: member name */ __( 'Archive member #%1$d (%2$s) has no entry in the checksum list (the list ended early).', '007aj-backup-restore-verifier' ), $ci + 1, $name ) ); } if ( ! preg_match( '/^([a-f0-9]{64}) (.+)$/', (string) $lines['lines'][0], $m ) ) { $fail( sprintf( /* translators: %d: line number */ __( 'Checksum list line %d is malformed.', '007aj-backup-restore-verifier' ), (int) $sl['n'] + 1 ) ); } if ( $m[2] !== $name ) { $fail( sprintf( /* translators: 1: member index, 2: member name, 3: line number, 4: name in the checksum list */ __( 'Archive member does not match the checksum list: member #%1$d is %2$s but checksum entry #%3$d is %4$s.', '007aj-backup-restore-verifier' ), $ci + 1, $name, (int) $sl['n'] + 1, $m[2] ) ); } $res = $reader->stream( $entry, static function ( string $b ): void { unset( $b ); }, isset( $sl['rs'] ) && is_array( $sl['rs'] ) ? $sl['rs'] : array(), $stop ); if ( empty( $res['done'] ) ) { $sl['rs'] = $res['resume']; return false; } unset( $sl['rs'] ); if ( ! hash_equals( $m[1], (string) $res['sha256'] ) ) { $fail( sprintf( /* translators: %s: member name */ __( 'Checksum mismatch after writing archive member: %s', '007aj-backup-restore-verifier' ), $name ) ); } $sl['so'] = (int) $lines['offset']; $sl['cd'] = $this->cd_next( $sl, $entry ); $sl['n'] = (int) $sl['n'] + 1; $sl['ci'] = $ci + 1; if ( microtime( true ) >= $stop ) { break 2; } } } if ( (int) $sl['cd'] >= $reader->cd_size ) { $rest = State_Files::read_rows_text( $sums, (int) $sl['so'], 1 ); if ( ! empty( $rest['lines'] ) ) { $fail( sprintf( /* translators: 1: line number, 2: line */ __( 'The checksum list has more entries than the archive (first extra entry #%1$d: %2$s).', '007aj-backup-restore-verifier' ), (int) $sl['n'] + 1, substr( (string) $rest['lines'][0], 66 ) ) ); } if ( $listed >= 0 && ( (int) $sl['n'] !== $listed || (int) $sl['ci'] !== $listed + 2 ) ) { $fail( sprintf( /* translators: 1: verified members, 2: listed members */ __( 'Verified %1$d payload members but the checksum list has %2$d entries.', '007aj-backup-restore-verifier' ), (int) $sl['n'], $listed ) ); } Logger::info( $job_id, sprintf( 'Checksums verified (%d members). Manifest written.', (int) $sl['n'] ) ); $sl['step'] = 'filehash'; $sl['fo'] = 0; return true; } } finally { $reader->close(); } return false; } /** * Next central directory position after an entry. * * @param array $sl Seal state. * @param array $entry Entry. */ private function cd_next( array $sl, array $entry ): int { unset( $sl ); return (int) $entry['next']; } /** * SHA-256 of a (large) file across ticks. Returns the hash when done, '' otherwise. * * @param string $path File. * @param array $st State (by reference): fo offset, rh saved hash state. * @param float $stop Deadline. * @throws State_Exception On read failure. */ public static function hash_file_resumable( string $path, array &$st, float $stop ): string { $off = (int) ( $st['fo'] ?? 0 ); $hash = null; if ( $off > 0 ) { $saved = $st['rh'] ?? ( empty( $st['fh'] ) ? null : array( 'm' => 'n', 'c' => 0, 'hs' => $st['fh'], ) ); $hash = Resumable_Hash::import( $saved ); } if ( null === $hash ) { clearstatcache( true, $path ); $hash = Resumable_Hash::start( (int) @filesize( $path ), $stop, false ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $off = 0; } unset( $st['fh'] ); // 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 read file', '007aj-backup-restore-verifier' ), $path ) ) ); } fseek( $fh, $off ); while ( ! feof( $fh ) ) { $chunk = fread( $fh, 4194304 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( false === $chunk ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose throw new State_Exception( esc_html( State_Files::io_message( __( 'Read error', '007aj-backup-restore-verifier' ), $path ) ) ); } $hash->update( $chunk ); $off += strlen( $chunk ); if ( $hash->can_pause() && microtime( true ) >= $stop && ! feof( $fh ) ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose $st['fo'] = $off; $st['rh'] = $hash->export(); return ''; } } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose unset( $st['rh'] ); return $hash->sha256(); } /** * Finalize: push to selected destination adapter(s), artifact row(s), cleanup. * * Runs across as many ticks as needed: each destination keeps its own small * upload cursor. Local is handled first so a remote failure cannot affect a * completed local artifact. * * @param int $job_id Job id. * @param string $job_dir Job dir. * @param array $cursor Cursor. * @param array $config Config. * @param float $deadline Deadline. * @return array{status:string,phase?:string,progress_pct?:int,cursor?:array,result?:array,error?:string} */ private function phase_finalize( int $job_id, string $job_dir, array $cursor, array $config, float $deadline ): array { $fz = isset( $cursor['fz'] ) && is_array( $cursor['fz'] ) ? $cursor['fz'] : null; if ( null === $fz ) { $zip_path = (string) ( $cursor['zip_path'] ?? '' ); if ( '' === $zip_path || ! is_readable( $zip_path ) ) { return $this->fail( __( 'Backup zip missing at finalize.', '007aj-backup-restore-verifier' ), $cursor ); } $dest_ids = $this->resolve_destinations( $config ); usort( $dest_ids, static function ( string $a, string $b ): int { if ( 'local' === $a ) { return -1; } if ( 'local' === $b ) { return 1; } return strcmp( $a, $b ); } ); $fz = array( 'dests' => $dest_ids, 'i' => 0, 'dc' => array(), 'src' => $zip_path, 'bytes' => (int) filesize( $zip_path ), 'sha' => (string) ( $cursor['zip_sha256'] ?? '' ), 'ppath' => '', 'pref' => '', 'part' => 0, 'local' => 0, 'any' => 0, 'results' => array(), 'errors' => array(), ); } if ( '' === (string) $fz['sha'] ) { // Normally recorded by the checksum phase; otherwise hash it here, across ticks. $hs = isset( $fz['hs'] ) && is_array( $fz['hs'] ) ? $fz['hs'] : array(); $h = self::hash_file_resumable( (string) $fz['src'], $hs, $deadline - 1.0 ); if ( '' === $h ) { $fz['hs'] = $hs; $cursor['fz'] = $fz; return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_FINALIZE, 'progress_pct' => 95, 'cursor' => $cursor, ); } $fz['sha'] = $h; unset( $fz['hs'] ); } $registry = Plugin::instance()->adapters(); $repo = new Job_Repository(); $stop = $deadline - 1.0; $dest_ids = (array) $fz['dests']; while ( (int) $fz['i'] < count( $dest_ids ) ) { $dest_id = (string) $dest_ids[ (int) $fz['i'] ]; $adapter = $registry->get( $dest_id ); $err = ''; if ( null === $adapter ) { $err = sprintf( /* translators: %s: adapter id */ __( 'Destination adapter "%s" is not registered.', '007aj-backup-restore-verifier' ), $dest_id ); } elseif ( ! $adapter->is_configured() ) { $err = sprintf( /* translators: %s: adapter label */ __( 'Destination "%s" is not configured.', '007aj-backup-restore-verifier' ), $adapter->label() ); } $push = null; if ( '' === $err ) { $context = array( 'job_id' => $job_id, 'zip_name' => (string) ( $cursor['zip_name'] ?? basename( (string) $fz['src'] ) ), ); $dc = isset( $fz['dc'] ) && is_array( $fz['dc'] ) ? $fz['dc'] : array(); $rounds = 0; while ( true ) { $push = $adapter->push_chunk( (string) $fz['src'], $dc, $context ); ++$rounds; if ( empty( $push['ok'] ) ) { $err = (string) ( $push['error'] ?? __( 'Adapter push failed.', '007aj-backup-restore-verifier' ) ); break; } if ( isset( $push['cursor'] ) && is_array( $push['cursor'] ) ) { $dc = $push['cursor']; } $multipart = ! empty( $dc['multipart'] ) || ( ! empty( $dc['upload_id'] ) && empty( $dc['uploaded'] ) ); if ( ! $multipart || ! empty( $dc['uploaded'] ) || ! empty( $dc['complete'] ) ) { break; // Upload finished. } if ( microtime( true ) >= $stop ) { $fz['dc'] = $dc; $cursor['fz'] = $fz; $pct = 95 + (int) floor( 4 * ( (int) $fz['i'] / max( 1, count( $dest_ids ) ) ) ); return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_FINALIZE, 'progress_pct' => min( 99, $pct ), 'cursor' => $cursor, ); } } if ( '' === $err ) { $final = $adapter->finalize( $dc, array( 'job_id' => $job_id ) ); if ( empty( $final['ok'] ) ) { $err = (string) ( $final['error'] ?? __( 'Adapter finalize failed.', '007aj-backup-restore-verifier' ) ); } } } if ( '' !== $err ) { Logger::error( $job_id, sprintf( 'Destination %s failed: %s', $dest_id, $err ) ); $fz['errors'][ $dest_id ] = $err; $fz['results'][ $dest_id ] = array( 'ok' => false, 'error' => $err, ); if ( 'local' === $dest_id ) { $cursor['fz'] = $fz; return $this->fail( sprintf( /* translators: %s: error */ __( 'Local destination failed: %s', '007aj-backup-restore-verifier' ), $err ), $cursor ); } } else { $ref = (string) ( $final['ref'] ?? $push['ref'] ?? '' ); $bytes = (int) ( $push['bytes'] ?? 0 ); $path = (string) ( $push['stored_path'] ?? '' ); $chain = isset( $cursor['chain'] ) && is_array( $cursor['chain'] ) ? $cursor['chain'] : array(); if ( 'local' === $dest_id && '' !== $path && is_readable( $path ) ) { $fz['src'] = $path; $fz['local'] = 1; $fz['ppath'] = $path; $fz['bytes'] = $bytes > 0 ? $bytes : (int) $fz['bytes']; $fz['pref'] = $ref; $fz['part'] = $repo->add_artifact( $job_id, array( 'path' => $path, 'bytes' => (int) $fz['bytes'], 'sha256' => (string) $fz['sha'], 'adapter' => 'local', 'remote_ref' => $ref, 'part_index' => 0, 'meta' => array( 'format' => Manifest::FORMAT, 'zip_name' => basename( $path ), 'payload_hash' => (string) ( $cursor['payload_hash'] ?? '' ), 'backup_id' => (string) ( $cursor['backup_id'] ?? ( $chain['backup_id'] ?? '' ) ), 'backup_mode' => (string) ( $chain['mode'] ?? ( $config['backup_mode'] ?? 'full' ) ), 'parent_backup_id' => $chain['parent_backup_id'] ?? null, 'base_full_id' => (string) ( $chain['base_full_id'] ?? '' ), 'sequence' => (int) ( $chain['sequence'] ?? 0 ), 'trigger' => $this->job_trigger( $config ), 'pre_update' => $this->job_pre_update( $config ), 'encrypted' => ! empty( $cursor['encryption'] ), 'components' => array( 'database' => ! empty( $config['components']['database'] ), 'plugins' => ! empty( $config['components']['plugins'] ), 'themes' => ! empty( $config['components']['themes'] ), 'uploads' => ! empty( $config['components']['uploads'] ), ), ), ) ); } else { $repo->add_artifact( $job_id, array( 'path' => '' !== (string) $fz['ppath'] ? (string) $fz['ppath'] : (string) $fz['src'], 'bytes' => $bytes > 0 ? $bytes : (int) $fz['bytes'], 'sha256' => (string) $fz['sha'], 'adapter' => $dest_id, 'remote_ref' => $ref, 'part_index' => 0, 'meta' => array( 'format' => Manifest::FORMAT, 'zip_name' => (string) ( $cursor['zip_name'] ?? basename( (string) $fz['src'] ) ), 'payload_hash' => (string) ( $cursor['payload_hash'] ?? '' ), 'destination' => $dest_id, 'trigger' => $this->job_trigger( $config ), 'encrypted' => ! empty( $cursor['encryption'] ), ), ) ); } $fz['any'] = 1; $fz['results'][ $dest_id ] = array( 'ok' => true, 'ref' => $ref, ); Logger::info( $job_id, sprintf( 'Destination %s finalized (%s).', $dest_id, $ref ) ); } $fz['i'] = (int) $fz['i'] + 1; $fz['dc'] = array(); if ( microtime( true ) >= $stop && (int) $fz['i'] < count( $dest_ids ) ) { $cursor['fz'] = $fz; return array( 'status' => Job_States::STATUS_RUNNING, 'phase' => Job_States::PHASE_FINALIZE, 'progress_pct' => 97, 'cursor' => $cursor, ); } } $cursor['fz'] = $fz; if ( in_array( 'local', $dest_ids, true ) && empty( $fz['local'] ) ) { return $this->fail( __( 'Local destination failed.', '007aj-backup-restore-verifier' ), $cursor ); } if ( empty( $fz['any'] ) ) { return $this->fail( sprintf( /* translators: %s: errors */ __( 'All selected destinations failed: %s', '007aj-backup-restore-verifier' ), implode( '; ', (array) $fz['errors'] ) ), $cursor ); } Working_Dir::rrmdir( $job_dir ); $primary_path = '' !== (string) $fz['ppath'] ? (string) $fz['ppath'] : (string) $fz['src']; $result = array( 'artifact_id' => (int) $fz['part'], 'path' => $primary_path, 'bytes' => (int) $fz['bytes'], 'sha256' => (string) $fz['sha'], 'ref' => (string) $fz['pref'], 'format' => Manifest::FORMAT, 'destinations' => $dest_ids, 'dest_results' => $fz['results'], 'remote_errors' => $fz['errors'], 'files' => (int) ( $cursor['pk']['pn'] ?? 0 ), ); if ( ! empty( $fz['errors'] ) ) { Logger::error( $job_id, 'Backup completed locally with remote destination errors: ' . implode( '; ', (array) $fz['errors'] ) ); } else { Logger::info( $job_id, 'Backup completed: ' . basename( $primary_path ) ); } // Keep only small summary data in the finished job row. $slim = array( 'zip_name' => (string) ( $cursor['zip_name'] ?? '' ), 'backup_id' => (string) ( $cursor['backup_id'] ?? '' ), 'payload_hash' => (string) ( $cursor['payload_hash'] ?? '' ), 'chain' => $cursor['chain'] ?? array(), ); return array( 'status' => Job_States::STATUS_COMPLETED, 'phase' => Job_States::PHASE_FINALIZE, 'progress_pct' => 100, 'cursor' => $slim, 'result' => $result, ); } /** * Resolve destination adapter ids for this job. * * @param array $config Job config. * @return list */ private function resolve_destinations( array $config ): array { if ( isset( $config['destinations'] ) && is_array( $config['destinations'] ) && ! empty( $config['destinations'] ) ) { $ids = array(); foreach ( $config['destinations'] as $id ) { $id = sanitize_key( (string) $id ); if ( '' !== $id ) { $ids[] = $id; } } $ids = array_values( array_unique( $ids ) ); if ( ! empty( $ids ) ) { /** * Filter job destination ids (after config stamp). * * @param list $ids Adapter ids. * @param array $config Job config. */ $filtered = apply_filters( 'brverify_backup_destinations', $ids, $config ); if ( is_array( $filtered ) && ! empty( $filtered ) ) { $out = array(); foreach ( $filtered as $id ) { $id = sanitize_key( (string) $id ); if ( '' !== $id ) { $out[] = $id; } } $out = array_values( array_unique( $out ) ); if ( ! empty( $out ) ) { return $out; } } return $ids; } } return Settings::backup_destinations(); } /** * Backup trigger label for metadata (manual|schedule|pre_update). * * @param array $config Job config. */ private function job_trigger( array $config ): string { $t = isset( $config['trigger'] ) ? sanitize_key( (string) $config['trigger'] ) : ''; return '' === $t ? 'manual' : $t; } /** * Pre-update descriptor (slug/type/from/to) or null for other triggers. * * @param array $config Job config. * @return array|null */ private function job_pre_update( array $config ): ?array { if ( 'pre_update' !== $this->job_trigger( $config ) ) { return null; } return isset( $config['pre_update'] ) && is_array( $config['pre_update'] ) ? $config['pre_update'] : array(); } /** * Failed tick result. * * @param string $message Error. * @param array $cursor Cursor. * @return array{status:string,error:string,cursor:array} */ private function fail( string $message, array $cursor ): array { return array( 'status' => Job_States::STATUS_FAILED, 'error' => $message, 'cursor' => $cursor, ); } }