.relay-part"; a finished * file is checked against the listed size and sha1 before it takes its name, and gets the listed mtime. * Resent pieces are written again; a piece past what this site has reports how much it has. */ final class Unpacker { public const PART = '.relay-part'; private FileList $list; private string $staging; private ?string $uploads; /** @param string|null $uploads low-disk mode: uploads are written straight into this folder */ public function __construct(FileList $list, string $staging, ?string $uploads = null) { $this->list = $list; $this->staging = rtrim($staging, '/'); $this->uploads = $uploads !== null ? rtrim($uploads, '/') : null; } /** * Each file costs only what it must on the server's disk: the batch's headers are read first so the list is * looked up once for all of them, each folder is checked once a batch, and a file that arrives whole is checked * against its sha1 in memory, so a damaged one is never written and a good one isn't read back. * * @return array{done: int[], gone: int[], failed: int[], partial: ?array{i: int, have: int}, bytes: int} */ public function unpack(string $payload): array { $result = ['done' => [], 'gone' => [], 'failed' => [], 'partial' => null, 'bytes' => 0]; $items = []; $pos = 0; $end = strlen($payload); while ($pos < $end) { $nl = strpos($payload, "\n", $pos); $h = $nl === false ? null : json_decode(substr($payload, $pos, $nl - $pos), true); if (!is_array($h) || !is_int($h['i'] ?? null)) { throw self::bad(); } $pos = $nl + 1; $len = !empty($h['gone']) ? 0 : ($h['len'] ?? null); if (!is_int($len) || $len < 0 || $pos + $len > $end) { throw self::bad(); } $items[] = [$h, $pos]; $pos += $len; } $entries = $this->list->entries(array_map(static fn (array $item): int => $item[0]['i'], $items)); $folders = []; foreach ($items as [$h, $at]) { $e = $entries[$h['i']]; if ($e === null) { throw self::bad(); } if (!empty($h['gone'])) { $this->forget($e); $result['gone'][] = $h['i']; continue; } $off = $h['off'] ?? null; $len = (int) $h['len']; if (!is_int($off) || $off < 0 || $off + $len > $e['s'] || ($h['size'] ?? null) !== $e['s'] || ($h['h'] ?? null) !== $e['h']) { throw self::bad(); } $data = substr($payload, $at, $len); [$dest, $part, $had_part] = $this->target($e, $folders); if ($off === 0 && $len === $e['s']) { if (sha1($data) !== $e['h']) { if ($had_part) { wp_delete_file($part); } $result['failed'][] = $h['i']; continue; } // Into staging it takes its name straight away: nothing reads staging before the switch-over, and a // request that dies mid-write gets the file sent again. A live upload (low-disk mode) is swapped in whole. $live = $this->uploads !== null && $e['g'] === 'uploads'; $this->write($live ? $part : $dest, 0, $data, $e); $result['bytes'] += $len; if (!$live) { if ($had_part) { wp_delete_file($part); // an earlier try in pieces: it would go live with the folder } Fs::touch($dest, $e['m']); $result['done'][] = $h['i']; continue; } } else { clearstatcache(true, $part); $have = is_file($part) ? (int) filesize($part) : 0; if ($off > $have) { $result['partial'] = ['i' => $h['i'], 'have' => $have]; continue; } $this->write($part, $off, $data, $e); $result['bytes'] += $len; if ($off + $len < $e['s']) { continue; } clearstatcache(true, $part); if (filesize($part) !== $e['s'] || sha1_file($part) !== $e['h']) { wp_delete_file($part); $result['failed'][] = $h['i']; continue; } } if (!Fs::rename($part, $dest)) { wp_delete_file($part); throw self::write_failed(esc_html($e['r'])); } Fs::touch($dest, $e['m']); $result['done'][] = $h['i']; } return $result; } /** * A file gone at the source: what staging has of it (a request may have died writing it) must not go live with * its folder. Nothing is followed through a symlink, and a live upload (low-disk mode) stays; only its part goes. */ private function forget(array $e): void { if (!RelPath::valid($e['r'])) { throw self::bad(); } $live = $this->uploads !== null && $e['g'] === 'uploads'; $dir = $live ? $this->uploads : $this->staging . '/' . $e['g']; foreach (dirname($e['r']) === '.' ? [] : explode('/', dirname($e['r'])) as $segment) { $dir .= '/' . $segment; if (is_link($dir) || !is_dir($dir)) { return; } } $dest = $dir . '/' . basename($e['r']); foreach ($live ? [$dest . self::PART] : [$dest, $dest . self::PART] as $path) { if (is_file($path) && !is_link($path)) { wp_delete_file($path); } } } /** Writes $data into $file from $off (0: a new file). */ private function write(string $file, int $off, string $data, array $e): void { $fh = Fs::open($file, $off === 0 ? 'wb' : 'r+b'); if ($fh === false) { throw self::write_failed(esc_html($e['r'])); } if ($off > 0) { ftruncate($fh, $off); fseek($fh, $off); } $written = $data !== '' ? Fs::write($fh, $data) : 0; Fs::close($fh); if ($written !== strlen($data)) { throw self::write_failed(esc_html($e['r'])); } } /** * Where a listed file lands: staging//, or uploads/ in low-disk mode; its part file; and * whether that part file is there already. $folders holds the folders already checked (and made) for this * batch, so each is checked once. Neither name may be a symlink or a folder. * * @param array $folders * @return array{0: string, 1: string, 2: bool} */ public function target(array $e, array &$folders = []): array { $direct = $this->uploads !== null && $e['g'] === 'uploads'; $base = $direct ? $this->uploads : $this->staging . '/' . $e['g']; if (!RelPath::valid($e['r'])) { throw self::bad(); } $sub = dirname($e['r']) === '.' ? '' : dirname($e['r']); $key = $base . '/' . $sub; if (!isset($folders[$key])) { try { $folders[$key] = Fs::ensure_dir_within($base, $sub); } catch (RelayError $err) { throw self::write_failed(esc_html($e['r'])); } } $dest = $folders[$key] . '/' . basename($e['r']); $kinds = [Fs::kind($dest), Fs::kind($dest . self::PART)]; if (array_intersect($kinds, ['link', 'dir']) !== []) { throw self::write_failed(esc_html($e['r'])); } return [$dest, $dest . self::PART, $kinds[1] !== null]; } private static function bad(): RelayError { return new RelayError('files.bad_batch', esc_html__('A batch of files from the other site could not be read.', 'siterelay'), '', true); } private static function write_failed(string $rel): RelayError { return new RelayError( 'files.write_failed', /* translators: %s: file path */ esc_html(sprintf(__('Could not write %s. The disk may be full, or a file or folder is in the way.', 'siterelay'), $rel)), esc_html__('Check the free disk space and the file permissions of wp-content.', 'siterelay') ); } }