.zip with the journal of the moves, and the loose copy goes: replaced code * is never left on disk as files that could run. An undo unpacks the archive where the items were set aside first. * * The archive holds each file under its path in the aside folder, then LIST: what the folder held, with every * file's modification time, its empty folders and its symlinks (which this kind of .zip has no place for). */ final class PointArchive { public const HOOK = 'siterelay_pack_restore_points'; private const LIST = '.siterelay-restore-point.jsonl'; private const WHOLE = 1048576; private const CHUNK = 1048576; private const STORED = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'mp4', 'm4v', 'mov', 'webm', 'mkv', 'mp3', 'm4a', 'ogg', 'opus', 'flac', 'zip', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'woff', 'woff2', 'pdf']; private JobRepository $jobs; private Lock $lock; public function __construct(JobRepository $jobs, Lock $lock) { $this->jobs = $jobs; $this->lock = $lock; } /** Have the scheduled task pack the loose restore points soon (once their jobs have finished). */ public static function schedule(int $delay = 0): void { if (!wp_next_scheduled(self::HOOK)) { wp_schedule_single_event(time() + $delay, self::HOOK); } } /** * The scheduled task: packs what fits in $seconds under SiteRelay's lock, and comes back for the rest. It waits * while a job runs, so it never slows one down or packs what a job is still moving. */ public function run(float $seconds = 20.0): void { if ($this->pending() === []) { return; } if ($this->jobs->active() !== null || !$this->lock->acquire()) { self::schedule(MINUTE_IN_SECONDS); return; } try { $budget = new Budget($seconds, PHP_INT_MAX); foreach ($this->pending() as $job) { try { $packed = $this->pack($job, $budget); } catch (RelayError $e) { // This one stays as it is (an undo still works from the loose copy); the others go on. $job->restore_point['files']['pack_error'] = $e->text(); $this->jobs->save($job); $this->jobs->event($job->id, 'warning', 'point.pack_failed', $e->text()); continue; } if (!$packed) { self::schedule(); return; } } } finally { $this->lock->release(); } } /** @return Job[] finished jobs whose file restore point is still loose */ public function pending(): array { return array_values(array_filter( $this->jobs->with_restore_points(), static fn (Job $job): bool => $job->is_finished() && !empty($job->restore_point['files']['journal']) && empty($job->restore_point['files']['archive']) && empty($job->restore_point['files']['pack_error']) )); } /** Pack $job's restore point as far as $budget allows; true once it is an archive and the loose copy is gone. */ public function pack(Job $job, Budget $budget): bool { $point = $job->restore_point['files']; $id = basename((string) $point['dir']); $zip = self::archive_path($id); $writer = new ZipWriter($zip, self::temp($id, 'cd')); if (empty($point['pack'])) { wp_delete_file($zip); wp_delete_file(self::temp($id, 'cd')); Fs::put(self::temp($id, 'list'), ''); $c = ['phase' => 'scan', 'stack' => is_dir((string) $point['dir']) && !is_link((string) $point['dir']) ? [''] : [], 'line' => 0, 'stream' => null, 'length' => 0, 'entries' => 0]; } else { $c = $point['pack']; $writer->rewind((int) $c['length'], (int) $c['entries']); // cut back what a request that died added } if ($c['phase'] === 'scan') { $c = $this->scan((string) $point['dir'], self::temp($id, 'list'), $c, $budget); } if ($c['phase'] === 'add') { $c = $this->add((string) $point['dir'], self::temp($id, 'list'), $writer, $c, $budget); } if ($c['phase'] !== 'done') { $c['length'] = $writer->length(); $c['entries'] = $writer->entry_count(); $job->restore_point['files']['pack'] = $c; $this->jobs->save($job); return false; } $writer->add_string(self::LIST, (string) file_get_contents(self::temp($id, 'list')), true); $writer->finish(); (new ZipReader($zip))->directory(); // a complete archive, or this throws before anything is removed $journal = Paths::point_archives_dir() . '/' . $id . '.journal'; wp_delete_file($journal); Fs::move((string) $point['journal'], $journal); $job->restore_point['files'] = ['journal' => $journal, 'dir' => (string) $point['dir'], 'archive' => $zip]; $this->jobs->save($job); Fs::remove_tree((string) $point['dir']); wp_delete_file(self::temp($id, 'cd')); wp_delete_file(self::temp($id, 'list')); return true; } /** List what the aside folder holds, one folder at a time: files (size, modification time), empty folders, symlinks. */ private function scan(string $dir, string $list, array $c, Budget $budget): array { while ($c['stack'] !== [] && !$budget->exhausted()) { $rel = (string) array_pop($c['stack']); $abs = $rel === '' ? $dir : $dir . '/' . $rel; $names = is_readable($abs) ? scandir($abs) : false; if ($names === false) { throw self::unreadable(esc_html($abs)); } $lines = ''; $subdirs = []; $empty = true; foreach ($names as $name) { if ($name === '.' || $name === '..') { continue; } $empty = false; $path = $abs . '/' . $name; $r = $rel === '' ? $name : $rel . '/' . $name; if (is_link($path)) { $target = (string) readlink($path); $lines .= self::line('l', $r, self::utf8($target) ? ['l' => $target] : ['lx' => bin2hex($target)]); } elseif (is_dir($path)) { $subdirs[] = $r; } elseif (is_file($path)) { $lines .= self::line('f', $r, ['s' => (int) filesize($path), 'm' => (int) filemtime($path)]); } } if ($empty && $rel !== '') { $lines .= self::line('d', $rel); } if ($lines !== '') { Fs::put($list, $lines, true); } foreach (array_reverse($subdirs) as $sub) { $c['stack'][] = $sub; } } if ($c['stack'] === []) { $c['phase'] = 'add'; } return $c; } /** Add the listed files to the archive: whole when small, else in chunks across requests. */ private function add(string $dir, string $list, ZipWriter $writer, array $c, Budget $budget): array { $h = Fs::open($list, 'rb'); if ($h === false) { throw self::unreadable(esc_html($list)); } try { fseek($h, (int) $c['line']); while (!$budget->exhausted()) { if ($c['stream'] !== null) { $c['stream'] = $this->stream($writer, $c['stream'], $budget); continue; } $line = fgets($h); if ($line === false) { $c['phase'] = 'done'; break; } $c['line'] += strlen($line); $file = json_decode($line, true); if (!is_array($file) || ($file['t'] ?? '') !== 'f') { continue; } $rel = self::rel($file); $path = $dir . '/' . $rel; clearstatcache(true, $path); if (!is_file($path) || is_link($path) || !is_readable($path)) { throw self::unreadable(esc_html($path)); // a restore point that lost a file can't be packed whole } $deflate = !in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), self::STORED, true); $size = (int) filesize($path); if ($size <= self::WHOLE) { $data = (string) file_get_contents($path); $writer->add_string($rel, $data, $deflate); $budget->consume(strlen($data)); } else { $c['stream'] = ['zip' => $writer->begin_stream($rel, $size, false, self::CHUNK), 'path' => $path, 'pos' => 0]; } } } finally { Fs::close($h); } return $c; } /** Continue a file added in chunks; null once it is in. */ private function stream(ZipWriter $writer, array $s, Budget $budget): ?array { $h = Fs::open($s['path'], 'rb'); if ($h === false) { throw self::unreadable(esc_html($s['path'])); } try { fseek($h, (int) $s['pos']); while (!$budget->exhausted()) { $chunk = Fs::read($h, self::CHUNK); if ($chunk === false || $chunk === '') { $writer->end_stream($s['zip']); return null; } $s['zip'] = $writer->stream_chunk($s['zip'], $chunk); $s['pos'] += strlen($chunk); $budget->consume(strlen($chunk)); } return $s; } finally { Fs::close($h); } } /** * Unpack a packed restore point where its items were set aside, as far as $budget allows; the cursor's 'done' says * when everything (files, empty folders, symlinks, modification times) is back. A point that was never packed * needs nothing: its items are still there. */ public static function unpack(array $point, array $c, Budget $budget): array { $c += ['pos' => 0, 'partial' => null, 'done' => empty($point['archive'])]; if ($c['done']) { return $c; } $reader = new ZipReader((string) $point['archive']); $dir = (string) $point['dir']; Fs::ensure_dir($dir); $real = (string) realpath($dir); if ($c['partial'] !== null) { $c['partial'] = EntryCopier::resume($reader, $c['partial'], $budget); if ($c['partial'] !== null) { return $c; } } foreach ($reader->each((int) $c['pos']) as $next => $entry) { if ($budget->exhausted()) { return $c; } $c['pos'] = $next; if ($entry['name'] === self::LIST) { continue; } $c['partial'] = EntryCopier::start($reader, $entry, self::inside($dir, $real, $entry['name']), $budget); if ($c['partial'] !== null) { return $c; } } $list = $reader->find(self::LIST); foreach (explode("\n", $list === null ? '' : $reader->read($list, PHP_INT_MAX)) as $raw) { $item = json_decode($raw, true); if (!is_array($item) || !isset($item['t']) || self::rel($item) === '') { continue; } $path = self::inside($dir, $real, self::rel($item)); if ($item['t'] === 'd') { Fs::ensure_dir($path); } elseif ($item['t'] === 'l' && !file_exists($path) && !is_link($path)) { $target = isset($item['lx']) ? (string) hex2bin((string) $item['lx']) : (string) ($item['l'] ?? ''); if (!function_exists('symlink') || !symlink($target, $path)) { throw self::unreadable(esc_html($path)); } } elseif ($item['t'] === 'f' && isset($item['m'])) { Fs::touch($path, (int) $item['m']); } } $c['done'] = true; return $c; } /** Remove a restore point's files in every form: the loose items, the journal, the archive and unfinished packing. */ public static function discard(array $point): void { $id = basename((string) ($point['dir'] ?? '')); Fs::remove_tree((string) ($point['dir'] ?? '')); wp_delete_file((string) ($point['journal'] ?? '')); wp_delete_file((string) ($point['archive'] ?? '')); if ($id !== '') { wp_delete_file(self::temp($id, 'cd')); wp_delete_file(self::temp($id, 'list')); if (empty($point['archive'])) { wp_delete_file(self::archive_path($id)); // an archive still being packed } } } /** A line of LIST. A name that isn't valid UTF-8 (which JSON can't hold) is kept as hex, so it comes back exactly. */ private static function line(string $type, string $rel, array $more = []): string { $name = self::utf8($rel) ? ['r' => $rel] : ['x' => bin2hex($rel)]; return json_encode(['t' => $type] + $name + $more, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; } private static function rel(array $item): string { return isset($item['x']) ? (string) hex2bin((string) $item['x']) : (string) ($item['r'] ?? ''); } private static function utf8(string $text): bool { return preg_match('//u', $text) === 1; } private static function archive_path(string $id): string { return Paths::point_archives_dir() . '/' . $id . '.zip'; } private static function temp(string $id, string $kind): string { return Paths::point_archives_dir() . '/.' . $id . '.' . $kind; } /** $rel under $dir, refusing a name that would reach outside it. */ private static function inside(string $dir, string $real, string $rel): string { $path = $dir . '/' . $rel; $parts = explode('/', $rel); if ($rel === '' || $rel[0] === '/' || in_array('..', $parts, true) || in_array('', $parts, true)) { throw self::outside(esc_html($rel)); } Fs::ensure_dir(dirname($path)); $parent = realpath(dirname($path)); if ($parent === false || strpos($parent . '/', $real . '/') !== 0 || is_link(dirname($path))) { throw self::outside(esc_html($rel)); } return $path; } private static function unreadable(string $path): RelayError { return new RelayError( 'files.point_unreadable', /* translators: %s: file or folder path */ esc_html(sprintf(__('SiteRelay could not read %s in a restore point.', 'siterelay'), $path)), esc_html__('Check the file permissions of wp-content and wp-content/uploads.', 'siterelay') ); } private static function outside(string $rel): RelayError { /* translators: %s: path inside the restore point */ return new RelayError('archive.entry_invalid', esc_html(sprintf(__('A restore point lists a path SiteRelay will not restore: %s', 'siterelay'), $rel))); } }