$cursor Cursor. * @param array $config Job config (components). * @param float $deadline Deadline. * @return array{done:bool,cursor:array,error?:string} */ public function tick( int $job_id, string $job_dir, array $cursor, array $config, float $deadline ): array { $state_dir = State_Files::dir( $job_dir ); $pk = isset( $cursor['pk'] ) && is_array( $cursor['pk'] ) ? $cursor['pk'] : array( 'step' => 'seed' ); if ( empty( $cursor['zip_path'] ) ) { $host = wp_parse_url( home_url(), PHP_URL_HOST ); $host = is_string( $host ) ? preg_replace( '/[^a-zA-Z0-9.-]/', '', $host ) : 'site'; $name = sprintf( 'brverify-%s-%s-%s.zip', $host ? $host : 'site', gmdate( 'Ymd-His' ), wp_generate_password( 32, false, false ) ); $cursor['zip_path'] = trailingslashit( $job_dir ) . $name; $cursor['zip_name'] = $name; } $sums = $state_dir . '/checksums.txt'; $writer = new Zip_Writer( (string) $cursor['zip_path'], $state_dir . '/zipcd.bin', isset( $cursor['zw'] ) && is_array( $cursor['zw'] ) ? $cursor['zw'] : array(), $sums ); $writer->open(); try { $guard = 0; while ( microtime( true ) < $deadline - self::RESERVE && $guard++ < 8 ) { $step = (string) ( $pk['step'] ?? 'seed' ); if ( 'seed' === $step ) { $res = $this->seed( $job_id, $job_dir, $cursor, $pk, $writer, $sums, $deadline ); if ( ! empty( $res['error'] ) ) { return array( 'done' => false, 'cursor' => $cursor, 'error' => (string) $res['error'], ); } if ( empty( $res['done'] ) ) { break; } $pk['step'] = 'discover'; $pk['roots'] = File_Walker::component_roots( isset( $config['components'] ) && is_array( $config['components'] ) ? $config['components'] : array( 'plugins' => true, 'themes' => true, 'uploads' => true, ) ); continue; } if ( 'discover' === $step ) { $walker = new File_Walker( $pk['roots'], $state_dir . '/dirs.ndjson', $state_dir . '/files.ndjson', Working_Dir::default_exclusions() ); $pk['w'] = $walker->step( isset( $pk['w'] ) && is_array( $pk['w'] ) ? $pk['w'] : array(), $deadline - self::RESERVE ); if ( empty( $pk['w']['done'] ) ) { break; } Logger::info( $job_id, sprintf( 'File discovery: %d file(s), %s in %d folder(s).', (int) $pk['w']['files'], size_format( (int) $pk['w']['bytes'] ), (int) $pk['w']['dirs'] ) ); $pk['step'] = 'select'; continue; } if ( 'select' === $step ) { /** * Let an extension choose which discovered files go into this archive * (incremental mode). Called repeatedly until it reports done; keep all * large state in files under $ctx['state_dir'] and only small values in * the returned 'state'. Return null to pack every discovered file. * * Discovered rows ($ctx['files']): {"r":root index,"p":path inside root,"s":size,"m":mtime}. * The extension writes the rows to pack (same shape) to $ctx['pack_list']. * * @param array|null $result null, or array{done:bool,state:array,error?:string,count?:int,bytes?:int,pack_all?:bool}. * @param array $ctx Context. */ $sel = apply_filters( 'brverify_backup_select', null, array( 'job_id' => $job_id, 'state_dir' => $state_dir, 'files' => $state_dir . '/files.ndjson', 'pack_list' => $state_dir . '/pack.ndjson', 'roots' => $pk['roots'], 'state' => isset( $pk['sel'] ) && is_array( $pk['sel'] ) ? $pk['sel'] : array(), 'config' => $config, 'deadline' => $deadline - self::RESERVE, 'cursor' => $cursor, ) ); if ( null === $sel || ( is_array( $sel ) && ! empty( $sel['pack_all'] ) && ! empty( $sel['done'] ) ) ) { if ( is_array( $sel ) ) { $pk['sel'] = isset( $sel['state'] ) && is_array( $sel['state'] ) ? $sel['state'] : array(); } $pk['list'] = 'files.ndjson'; $pk['total'] = (int) $pk['w']['files']; $pk['tb'] = (int) $pk['w']['bytes']; } else { if ( ! is_array( $sel ) ) { throw new State_Exception( esc_html__( 'File selection returned an invalid result.', '007aj-backup-restore-verifier' ) ); } if ( ! empty( $sel['error'] ) ) { return array( 'done' => false, 'cursor' => $cursor, 'error' => (string) $sel['error'], ); } $pk['sel'] = isset( $sel['state'] ) && is_array( $sel['state'] ) ? $sel['state'] : array(); if ( empty( $sel['done'] ) ) { break; } $pk['list'] = 'pack.ndjson'; $pk['total'] = (int) ( $sel['count'] ?? 0 ); $pk['tb'] = (int) ( $sel['bytes'] ?? 0 ); } $pk['step'] = 'pack'; $pk['po'] = 0; $pk['pn'] = 0; $pk['pb'] = 0; Logger::info( $job_id, sprintf( 'File pack: %d file(s) (%s) queued.', (int) $pk['total'], size_format( (int) $pk['tb'] ) ) ); continue; } if ( 'pack' === $step ) { $done = $this->pack( $job_id, $state_dir, $pk, $writer, $sums, $config, $deadline ); if ( ! $done ) { break; } $pk['step'] = 'done'; Logger::info( $job_id, sprintf( 'File pack completed (%d file(s), %s).', (int) $pk['pn'], size_format( (int) $pk['pb'] ) ) ); } if ( 'done' === (string) $pk['step'] ) { break; } } // End of tick: every file must match the state that is about to be committed. $writer->sync(); } finally { $cursor['zw'] = $writer->state(); $writer->close(); } $cursor['pk'] = $pk; return array( 'done' => 'done' === (string) ( $pk['step'] ?? '' ), 'cursor' => $cursor, ); } /** * Progress fraction 0..1 for the pack phase. * * @param array $cursor Cursor. */ public static function progress( array $cursor ): float { $pk = isset( $cursor['pk'] ) && is_array( $cursor['pk'] ) ? $cursor['pk'] : array(); $step = (string) ( $pk['step'] ?? 'seed' ); if ( 'pack' === $step && ! empty( $pk['tb'] ) ) { return 0.15 + 0.85 * min( 1.0, (float) $pk['pb'] / max( 1, (int) $pk['tb'] ) ); } if ( 'done' === $step ) { return 1.0; } return in_array( $step, array( 'select', 'pack' ), true ) ? 0.15 : 0.05; } /** * Seed: database dump (possibly transformed by an add-on) + meta members. * * @param int $job_id Job id. * @param string $job_dir Job dir. * @param array $cursor Cursor (by reference). * @param array $pk Pack state (by reference). * @param Zip_Writer $writer Writer. * @param string $sums Checksums file. * @param float $deadline Deadline. * @return array{done:bool,error?:string} */ private function seed( int $job_id, string $job_dir, array &$cursor, array &$pk, Zip_Writer $writer, string $sums, float $deadline ): array { if ( empty( $pk['dump'] ) ) { $dump = trailingslashit( $job_dir ) . 'database/dump.sql'; if ( ! is_readable( $dump ) ) { return array( 'done' => false, 'error' => sprintf( /* translators: %s: path */ __( 'Database dump missing before file pack: %s', '007aj-backup-restore-verifier' ), $dump ), ); } $member = 'database/dump.sql'; $store = false; $enc_info = null; /** * Let an add-on transform the database dump before it enters the archive (the * extended edition encrypts it). Default: the plain dump.sql. * * Return the same shape: path (readable file), member (safe relative zip entry), store * (bool, do not deflate), encryption (non-secret descriptor recorded in the manifest, * or null) and optional error (string; aborts the backup). The default carries * "deadline" (microtime); an add-on that cannot finish by then returns * array( 'pending' => true ) and is called again in the next tick. * * @param array $prepared Default prepared dump. * @param int $job_id Job id. * @param string $job_dir Job dir. * @param array $cursor Job cursor. */ $prepared = apply_filters( 'brverify_backup_dump_member', array( 'path' => $dump, 'member' => $member, 'store' => false, 'encryption' => null, 'error' => '', 'deadline' => $deadline - self::RESERVE, ), $job_id, $job_dir, $cursor ); if ( is_array( $prepared ) ) { if ( ! empty( $prepared['error'] ) ) { return array( 'done' => false, 'error' => (string) $prepared['error'], ); } if ( ! empty( $prepared['pending'] ) ) { // The add-on keeps its own resumable state and is called again next tick. return array( 'done' => false ); } $cand_path = isset( $prepared['path'] ) ? (string) $prepared['path'] : ''; $cand_member = isset( $prepared['member'] ) ? (string) $prepared['member'] : ''; if ( '' !== $cand_path && is_readable( $cand_path ) && '' !== $cand_member && Path_Guard::is_safe_relative( $cand_member ) ) { $dump = $cand_path; $member = $cand_member; $store = ! empty( $prepared['store'] ); $enc_info = isset( $prepared['encryption'] ) && is_array( $prepared['encryption'] ) ? $prepared['encryption'] : null; } } $pk['dump'] = array( 'path' => $dump, 'member' => $member, 'store' => $store ? 1 : 0, ); $cursor['dump_member'] = $member; $cursor['encryption'] = $enc_info; } if ( empty( $pk['dump_done'] ) ) { $d = $pk['dump']; // A big dump is stored so it can be written across several ticks. $res = $writer->add_file( (string) $d['path'], (string) $d['member'], $deadline - self::RESERVE, ! empty( $d['store'] ) || (int) @filesize( (string) $d['path'] ) > Zip_Writer::DEFLATE_MAX ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( empty( $res['done'] ) ) { return array( 'done' => false ); } $pk['dump_done'] = 1; } foreach ( array( 'meta/php-version.txt' => PHP_VERSION . "\n", 'meta/wp-version.txt' => get_bloginfo( 'version' ) . "\n", 'meta/generator.txt' => "brverify\n", ) as $name => $body ) { $writer->add_string( $name, $body ); } unset( $sums ); Logger::info( $job_id, 'Archive seeded with database dump and meta.' ); return array( 'done' => true ); } /** * Pack files from the list until the deadline. * * @param int $job_id Job id. * @param string $state_dir State dir. * @param array $pk Pack state (by reference). * @param Zip_Writer $writer Writer. * @param string $sums Checksums file. * @param array $config Job config. * @param float $deadline Deadline. * @return bool Done. * @throws State_Exception When a file cannot be read. */ private function pack( int $job_id, string $state_dir, array &$pk, Zip_Writer $writer, string $sums, array $config, float $deadline ): bool { $list = $state_dir . '/' . basename( (string) $pk['list'] ); $roots = $pk['roots']; $stop = $deadline - self::RESERVE; $done = false; unset( $sums ); while ( microtime( true ) < $stop ) { $batch = State_Files::read_rows( $list, (int) $pk['po'], 200 ); if ( empty( $batch['rows'] ) ) { $done = true; break; } foreach ( $batch['rows'] as $k => $row ) { $end = (int) $batch['ends'][ $k ]; if ( microtime( true ) >= $stop ) { break 2; } $ri = (int) ( $row['r'] ?? -1 ); $p = (string) ( $row['p'] ?? '' ); if ( ! isset( $roots[ $ri ] ) || '' === $p || ! Path_Guard::is_safe_relative( $p ) ) { $pk['po'] = $end; continue; } $root = (string) $roots[ $ri ]['abs']; $rel = (string) $roots[ $ri ]['rel'] . '/' . $p; $abs = $root . '/' . $p; $zip = 'files/' . $rel; if ( null !== $writer->in_progress() && ! file_exists( $abs ) ) { // A large file that was half written in an earlier tick was deleted since: drop it cleanly. $writer->discard_in_progress(); Logger::warning( $job_id, 'File removed during backup, skipped: ' . $rel ); $pk['po'] = $end; continue; } if ( null === $writer->in_progress() ) { if ( 1 === preg_match( '/[\x00-\x1F\x7F]/', $zip ) ) { // Such a name cannot be stored as one checksum line; never let it shift the list. Logger::warning( $job_id, 'Skipped file whose name contains control characters: ' . (string) preg_replace( '/[\x00-\x1F\x7F]/', '?', $rel ) ); $pk['po'] = $end; continue; } clearstatcache( true, $abs ); if ( is_link( $abs ) ) { $pk['po'] = $end; continue; } if ( ! file_exists( $abs ) ) { // Deleted since discovery (normal on a live site). Logger::warning( $job_id, 'File removed during backup, skipped: ' . $rel ); $pk['po'] = $end; continue; } $safe = Path_Guard::resolve_under( $abs, $root ); if ( null === $safe ) { Logger::warning( $job_id, 'Skipped path outside its root: ' . $rel ); $pk['po'] = $end; continue; } if ( ! is_readable( $safe ) ) { throw new State_Exception( esc_html( sprintf( /* translators: %s: file path */ __( 'Cannot read file (permission denied): %s', '007aj-backup-restore-verifier' ), $safe ) ) ); } $abs = $safe; } $res = $writer->add_file( $abs, $zip, $stop ); if ( empty( $res['done'] ) ) { break 2; // Large file continues next tick. } // The writer has committed the data, directory record and checksum line together. $pk['pn'] = (int) $pk['pn'] + 1; $pk['pb'] = (int) $pk['pb'] + (int) $res['size']; /** * Fires after a file was written to the archive. * * @param int $job_id Job id. * @param string $state_dir Job state dir. * @param array $file rel, zip, size, mtime, sha256. * @param array $config Job config. */ do_action( 'brverify_backup_file_packed', $job_id, $state_dir, array( 'rel' => $rel, 'zip' => $zip, 'size' => (int) $res['size'], 'mtime' => (int) ( $row['m'] ?? 0 ), 'sha256' => (string) $res['sha256'], ), $config ); $pk['po'] = $end; } if ( $batch['eof'] ) { $done = true; break; } } return $done; } }