PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | vendor/wp-php-toolkit/reprint-server/src/class-push-session.php +2906 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,2906 @@
1 +<?php
2 +
3 +// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Push errors become authenticated API JSON, never HTML output.
4 +
5 +use function WordPress\Reprint\Server\assert_valid_relative_path;
6 +use function WordPress\Reprint\Server\normalize_excluded_paths;
7 +use function WordPress\Reprint\Server\path_is_same_as_or_descendant_of;
8 +use function WordPress\Reprint\Server\path_remainder_under;
9 +use function WordPress\Reprint\Server\relative_path_under;
10 +use function WordPress\Reprint\Server\trim_right_slash;
11 +use function WordPress\Reprint\Server\wp_join_unix_paths;
12 +
13 +require_once __DIR__ . '/utils.php';
14 +
15 +if (!class_exists('Site_Export_Multipart_Processor', false)) {
16 + require_once __DIR__ . '/class-multipart-processor.php';
17 +}
18 +if (!class_exists('Site_Export_Push_Exception', false)) {
19 + require_once __DIR__ . '/class-push-exception.php';
20 +}
21 +
22 +/**
23 + * Receives push work privately, then commits its deletes and files directly.
24 + *
25 + * `work/files/` is both the completed work tree and the work-file queue.
26 + * In-flight work is recorded in `work/inflight.json`; bytes for an in-flight
27 + * file live in `work/inflight.data` rather than in a second path-shaped tree.
28 + * Successful file installation consumes each entry. Deletes remain raw NUL-delimited
29 + * bytes in `work/deletes`; their confirmed cursor is the file's actual size.
30 + * Commit persists only one delete, one work-files descendant, and a path-depth-bounded
31 + * commit cursor. It never builds a candidate tree, action plan, backup, path
32 + * index, or second queue.
33 + *
34 + * @phpstan-type CurrentChange (
35 + * array{path_b64:string,state:'partial'|'complete',type:'file',accepted_bytes:int}
36 + * | array{path_b64:string,state:'complete',type:'directory'|'symlink',accepted_bytes:0}
37 + * | array{state:'partial'|'complete',type:'delete-list',accepted_bytes:int}
38 + * )
39 + * @phpstan-type PathStatus (
40 + * array{path_b64:string,state:'missing',accepted_bytes:0}
41 + * | array{path_b64:string,state:'partial',type:'file',accepted_bytes:int}
42 + * | array{path_b64:string,state:'partial',type:'directory'|'symlink',accepted_bytes:0}
43 + * | array{path_b64:string,state:'complete',type:'file',accepted_bytes:int}
44 + * | array{path_b64:string,state:'complete',type:'directory'|'symlink',accepted_bytes:0}
45 + * )
46 + * @phpstan-type InFlightWork (
47 + * array{phase:'preparing'|'receiving'|'completing',path_b64:string,type:'file',total_bytes:int}
48 + * | array{phase:'preparing'|'completing',path_b64:string,type:'directory'}
49 + * | array{phase:'preparing'|'completing',path_b64:string,type:'symlink',target_b64:string}
50 + * )
51 + * @phpstan-type CommitState array{
52 + * phase:'deleting_files'|'installing_files'|'complete',
53 + * work_deletes_byte_offset:int,
54 + * current_delete_path:?string,
55 + * current_work_files_descendant:?array{path_b64:string,expected_type:'file'|'directory'|'symlink'},
56 + * commit_cursor:list<array{component_b64:string}>,
57 + * non_recoverable_commit_failure?:array{reason:'unexpected_docroot_mutation'|'same_device',detail:string,context:array<string,mixed>}
58 + * }
59 + */
60 +final class Site_Export_Push_Session {
61 +
62 + public const ERROR_LOCK_ACQUISITION_FAILURE = 'lock_acquisition_failure';
63 + public const ERROR_OFFSET_GAP = 'offset_gap';
64 + public const ERROR_PUSH_NOT_FOUND = 'push_not_found';
65 + public const ERROR_FILESYSTEM = 'filesystem_error';
66 + public const ERROR_COMMIT_REQUIRED = 'commit_required';
67 + public const ERROR_UNEXPECTED_DOCROOT_MUTATION = 'unexpected_docroot_mutation';
68 + public const ERROR_CORRUPTED_PUSH_STATE = 'corrupted_push_state';
69 + public const ERROR_SAME_DEVICE = 'same_device';
70 + public const ERROR_REQUEST_TOO_LARGE = 'request_too_large';
71 + public const ERROR_PUSH_DISABLED = 'push_disabled';
72 +
73 + private const MAX_PATH_BYTES = 4096;
74 + private const MAX_METADATA_BYTES = 1048576;
75 + private const REMOVE_ENTRY_LIMIT = 256;
76 +
77 + /** @var string */
78 + private $reprint_directory;
79 + /** @var string */
80 + private $docroot;
81 + /** @var string */
82 + private $push_session_id;
83 + /** @var list<string> */
84 + private $excluded_paths;
85 + /** @var string */
86 + private $commit_state_path;
87 + /** @var string */
88 + private $commit_state_lock_path;
89 + /** @var string */
90 + private $push_directory;
91 + /** @var string */
92 + private $work_dir;
93 + /** @var string */
94 + private $work_files_directory;
95 + /** @var string */
96 + private $work_inflight_path;
97 + /** @var string */
98 + private $work_inflight_data_path;
99 + /** @var string */
100 + private $work_deletes_path;
101 + /** @var string */
102 + private $push_json_path;
103 + /** @var string */
104 + private $commit_json_path;
105 + /** @var string */
106 + private $push_lock_path;
107 + /** @var string */
108 + private $maintenance_copy_path;
109 +
110 + /** @var resource|null */
111 + private $upload_lock = null;
112 + /** @var resource|null */
113 + private $upload_input = null;
114 + /** @var Site_Export_Multipart_Processor|null */
115 + private $upload_processor = null;
116 + /** @var bool */
117 + private $current_upload_part_ended = false;
118 + /** @var CurrentChange|null */
119 + private $current_change = null;
120 + /** @var int */
121 + private $maximum_upload_part_bytes = PHP_INT_MAX;
122 + /** @var int */
123 + private $maximum_upload_request_body_bytes = PHP_INT_MAX;
124 + /** @var int */
125 + private $upload_request_body_bytes_read = 0;
126 +
127 + /**
128 + * Normalizes one push session's policy and derives its private paths.
129 + *
130 + * Factory methods canonicalize the reprint directory and document root before they
131 + * reach this constructor. The constructor then establishes the invariant
132 + * shared by every push-session handle: excluded paths are valid
133 + * document-root-relative paths in sorted, unique order, and a reprint
134 + * directory below the document root protects itself from push.
135 + * No filesystem state is read or changed here.
136 + *
137 + * @param list<string> $excluded_paths Document-root-relative paths which a push
138 + * must never receive, delete, or replace.
139 + */
140 + private function __construct(string $reprint_directory, string $docroot, string $push_session_id, array $excluded_paths) {
141 + $this->reprint_directory = trim_right_slash($reprint_directory);
142 + $this->docroot = trim_right_slash($docroot);
143 + $this->push_session_id = $push_session_id;
144 + if ($reprint_directory === $this->docroot) {
145 + throw new InvalidArgumentException('The reprint directory must not be the document root itself.');
146 + }
147 + $relative_reprint_directory = relative_path_under($reprint_directory, $this->docroot);
148 + if ($relative_reprint_directory !== null && $relative_reprint_directory !== '') {
149 + $excluded_paths[] = $relative_reprint_directory;
150 + }
151 + $this->excluded_paths = normalize_excluded_paths($excluded_paths);
152 + $push_sessions_directory = wp_join_unix_paths($this->reprint_directory, '.reprint', 'push');
153 + $this->commit_state_path = wp_join_unix_paths($push_sessions_directory, 'commit-state');
154 + $this->commit_state_lock_path = wp_join_unix_paths($push_sessions_directory, 'commit-state.lock');
155 + $this->push_directory = wp_join_unix_paths($push_sessions_directory, $push_session_id);
156 + $this->push_json_path = wp_join_unix_paths($this->push_directory, 'push.json');
157 + $this->commit_json_path = wp_join_unix_paths($this->push_directory, 'commit.json');
158 + $this->push_lock_path = wp_join_unix_paths($this->push_directory, 'push.lock');
159 + $this->work_dir = wp_join_unix_paths($this->push_directory, 'work');
160 + $this->work_files_directory = wp_join_unix_paths($this->work_dir, 'files');
161 + $this->work_inflight_path = wp_join_unix_paths($this->work_dir, 'inflight.json');
162 + $this->work_inflight_data_path = wp_join_unix_paths($this->work_dir, 'inflight.data');
163 + $this->work_deletes_path = wp_join_unix_paths($this->work_dir, 'deletes');
164 + $this->maintenance_copy_path = wp_join_unix_paths($this->work_dir, 'maintenance.php');
165 + }
166 +
167 + /**
168 + * Creates or idempotently reopens one private push session.
169 + *
170 + * The empty work tree is created before its device is compared with the
171 + * document root. A mismatch removes the new push session before any multipart
172 + * bytes can be accepted. That device check necessarily stats the new tree;
173 + * successful creation and metadata writes are otherwise trusted instead
174 + * of being followed by a complete layout scan.
175 + *
176 + * Replaying the same push session ID validates the existing directory's
177 + * durable layout, immutable metadata, and same-filesystem relationship
178 + * under its push lock before returning the handle. The create/remove lock
179 + * remains held during that validation, so remove cannot rename the directory
180 + * between the existing-directory check and the push-lock acquisition.
181 + *
182 + * @param string $reprint_directory Durable private reprint directory on the document-root filesystem.
183 + * @param string $docroot Document-root directory receiving committed values.
184 + * @param list<string> $excluded_paths Document-root-relative paths which a push must preserve.
185 + * @param string $push_session_id Stable lowercase hexadecimal push session ID.
186 + * @return self New or existing push-session handle.
187 + */
188 + public static function create(string $reprint_directory, string $docroot, array $excluded_paths, string $push_session_id): self {
189 + self::require_push_session_id($push_session_id);
190 + $reprint_directory = self::require_directory($reprint_directory, 'reprint directory', true);
191 + $docroot = self::require_directory($docroot, 'document root', false);
192 + $push_session = new self($reprint_directory, $docroot, $push_session_id, $excluded_paths);
193 + $push_sessions_directory = self::create_push_sessions_directory($reprint_directory);
194 + $create_remove_lock = self::acquire_create_remove_lock($push_sessions_directory, 'create');
195 + try {
196 + $push_session->with_commit_state_lock(function () use ($push_session): void {
197 + $active_owner = $push_session->read_commit_owner();
198 + if ($active_owner !== null && $active_owner !== $push_session->push_session_id) {
199 + throw new Site_Export_Push_Exception(
200 + self::ERROR_COMMIT_REQUIRED,
201 + 'Push session ' . $active_owner . ' must finish committing this document root before another push session can start.',
202 + ['blocking_push_session_id' => $active_owner]
203 + );
204 + }
205 + });
206 + $removing_push_directory = wp_join_unix_paths(
207 + $push_sessions_directory,
208 + '.removing-' . $push_session_id
209 + );
210 + if (file_exists($removing_push_directory) || is_link($removing_push_directory)) {
211 + throw new Site_Export_Push_Exception(
212 + self::ERROR_LOCK_ACQUISITION_FAILURE,
213 + 'Push session removal is incomplete. Retry create after remove finishes.'
214 + );
215 + }
216 + if (file_exists($push_session->push_directory) || is_link($push_session->push_directory)) {
217 + // Lock acquisition checks the durable layout; with_push_lock()
218 + // then checks immutable configuration before this callback.
219 + $push_session->with_push_lock(static function (): void {});
220 + return $push_session;
221 + }
222 + if (!@mkdir($push_session->work_files_directory, 0700, true)) {
223 + self::remove_tree($push_session->push_directory);
224 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create the push work directories.');
225 + }
226 + if (@file_put_contents($push_session->push_lock_path, '') === false || @file_put_contents($push_session->work_deletes_path, '') === false) {
227 + self::remove_tree($push_session->push_directory);
228 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create push session control files.');
229 + }
230 + try {
231 + $push_session->require_same_device($push_session->work_files_directory, $push_session->docroot, 'receive', '');
232 + } catch (Throwable $exception) {
233 + self::remove_tree($push_session->push_directory);
234 + throw $exception;
235 + }
236 + $push_session->write_json($push_session->push_json_path, [
237 + 'push_session_id' => $push_session_id,
238 + 'docroot_b64' => base64_encode($docroot),
239 + 'excluded_paths_b64' => array_map('base64_encode', $push_session->excluded_paths),
240 + 'work_deletes_complete' => false,
241 + ]);
242 + return $push_session;
243 + } finally {
244 + flock($create_remove_lock, LOCK_UN);
245 + fclose($create_remove_lock);
246 + }
247 + }
248 +
249 + /**
250 + * Creates a push-session handle which will be validated when it is used.
251 + *
252 + * This method canonicalizes the configured roots but deliberately does not
253 + * inspect the push directory. Upload, status, and commit acquire the
254 + * push lock and then validate its complete layout, immutable metadata,
255 + * and same-filesystem relationship exactly once for that operation.
256 + *
257 + * @param string $reprint_directory Durable private reprint directory.
258 + * @param string $docroot Document-root directory.
259 + * @param string $push_session_id Lowercase hexadecimal push session ID.
260 + * @param list<string> $excluded_paths Document-root-relative paths which a push must preserve.
261 + * @return self Push-session handle; the push session may prove missing or invalid
262 + * when its first operation acquires the lock.
263 + */
264 + public static function open(string $reprint_directory, string $docroot, string $push_session_id, array $excluded_paths): self {
265 + self::require_push_session_id($push_session_id);
266 + $reprint_directory = self::require_directory($reprint_directory, 'reprint directory', false);
267 + $docroot = self::require_directory($docroot, 'document root', false);
268 + return new self($reprint_directory, $docroot, $push_session_id, $excluded_paths);
269 + }
270 +
271 + /**
272 + * Removes private push work without requiring the old document-root configuration.
273 + *
274 + * Remove validates the push directory under its push lock, but it does not
275 + * require current excluded paths or the document root to match immutable
276 + * push metadata. That exception is intentional: operators must still be
277 + * able to remove abandoned private work after configuration changes.
278 + *
279 + * @param string $reprint_directory Durable private reprint directory.
280 + * @param string $docroot Currently configured document-root directory.
281 + * @param string $push_session_id Lowercase hexadecimal push session ID.
282 + * @param list<string> $excluded_paths Currently configured excluded paths.
283 + * @return bool True when the push directory and any remove tombstone are gone.
284 + */
285 + public static function remove(string $reprint_directory, string $docroot, string $push_session_id, array $excluded_paths): bool {
286 + self::require_push_session_id($push_session_id);
287 + $reprint_directory = self::require_directory($reprint_directory, 'reprint directory', false);
288 + $docroot = self::require_directory($docroot, 'document root', false);
289 + return ( new self($reprint_directory, $docroot, $push_session_id, $excluded_paths) )->remove_push_directory();
290 + }
291 +
292 + /**
293 + * Returns the immutable identity assigned to this push session.
294 + *
295 + * The push session ID is the caller-provided lowercase hexadecimal token used
296 + * in upload, status, commit, and remove endpoints. It is not re-read from
297 + * disk here; operations that depend on durable state validate the matching
298 + * metadata while holding the push lock.
299 + *
300 + * @return string Push session ID used in public protocol responses and paths.
301 + */
302 + public function get_push_session_id(): string {
303 + return $this->push_session_id;
304 + }
305 +
306 + /**
307 + * Returns the private push directory derived for this push session.
308 + *
309 + * This is an implementation path under the configured reprint directory.
310 + * The method is used by tests and endpoint code that need to inspect or
311 + * remove the private push directory; it does not imply that the
312 + * directory currently exists or has passed layout validation.
313 + *
314 + * @return string Absolute path to the push session's private directory.
315 + */
316 + public function get_push_directory(): string {
317 + return $this->push_directory;
318 + }
319 +
320 + /**
321 + * Opens one caller-driven multipart request without reading its body.
322 + *
323 + * The push lock remains held until finish_upload() is called, so no
324 + * status, commit, remove, or second upload can observe a partly processed
325 + * MIME part. The supplied processor owns the request boundary and parser
326 + * state. The two byte limits remain independent: one applies to each part's
327 + * declared Content-Length, and one applies to all decoded request-body bytes
328 + * read from the supplied stream.
329 + *
330 + * A push session which has started commit is closed to further uploads. This
331 + * method validates that condition before any bytes are read from $input.
332 + *
333 + * @param resource $input Blocking stream containing one multipart request.
334 + * @param Site_Export_Multipart_Processor $processor Parser configured with
335 + * the request boundary.
336 + * @param int $maximum_part_bytes Largest Content-Length accepted for one part.
337 + * @param int $maximum_request_body_bytes Largest decoded request body accepted.
338 + * Defaults to unlimited for direct callers.
339 + *
340 + * @throws LogicException If another upload is already open on this object.
341 + * @throws InvalidArgumentException If the stream or either byte limit is invalid.
342 + * @throws Site_Export_Push_Exception If the push session is busy,
343 + * malformed, unavailable, already committing, or the decoded request
344 + * body exceeds its byte limit.
345 + */
346 + public function accept_upload(
347 + $input,
348 + Site_Export_Multipart_Processor $processor,
349 + int $maximum_part_bytes = PHP_INT_MAX,
350 + int $maximum_request_body_bytes = PHP_INT_MAX
351 + ): void {
352 + if ($this->upload_lock !== null) {
353 + throw new LogicException('A push upload is already open; call finish_upload() first.');
354 + }
355 + if (!is_resource($input)) {
356 + throw new InvalidArgumentException('Push multipart input must be a readable stream resource; received ' . gettype($input) . '.');
357 + }
358 + if ($maximum_part_bytes <= 0) {
359 + throw new InvalidArgumentException('Multipart part byte limit must be greater than zero.');
360 + }
361 + if ($maximum_request_body_bytes <= 0) {
362 + throw new InvalidArgumentException(
363 + 'Multipart request-body byte limit must be greater than zero; received '
364 + . $maximum_request_body_bytes . '.'
365 + );
366 + }
367 + $lock = $this->acquire_push_lock();
368 + try {
369 + $this->assert_push_configuration();
370 + if (is_file($this->commit_json_path)) {
371 + throw new Site_Export_Push_Exception(self::ERROR_COMMIT_REQUIRED, 'Uploads are closed because this push session is committing.');
372 + }
373 + $this->upload_lock = $lock;
374 + $this->upload_input = $input;
375 + $this->upload_processor = $processor;
376 + $this->current_upload_part_ended = false;
377 + $this->current_change = null;
378 + $this->maximum_upload_part_bytes = $maximum_part_bytes;
379 + $this->maximum_upload_request_body_bytes = $maximum_request_body_bytes;
380 + $this->upload_request_body_bytes_read = 0;
381 + } catch (Throwable $exception) {
382 + flock($lock, LOCK_UN);
383 + fclose($lock);
384 + throw $exception;
385 + }
386 + }
387 +
388 + /**
389 + * Reads and records the next change from the active multipart upload.
390 + *
391 + * Each MIME part describes one file chunk, directory, symlink, or segment
392 + * of the raw delete stream. File bodies pass through the multipart
393 + * processor in bounded pieces instead of being collected in memory. One
394 + * call interprets exactly one complete part and does not begin interpreting
395 + * the following part before returning.
396 + *
397 + * Returning true means the complete part has been accepted into the work
398 + * directory and get_current_change() describes the resulting work state.
399 + * A file part may leave the current value in flight, so true does not mean
400 + * the logical file or the complete multipart request is finished.
401 + *
402 + * Returning false means the closing multipart boundary was consumed. EOF
403 + * in a header, body, or boundary throws instead, so truncation is never
404 + * reported as normal completion.
405 + *
406 + * accept_upload() must be called first. The caller must eventually call
407 + * finish_upload(), including after an exception, to release the push
408 + * lock and clear the request state.
409 + *
410 + * @return bool True when one complete part was accepted, false after the
411 + * multipart request closed cleanly.
412 + *
413 + * @throws LogicException If no upload is active or parser state is inconsistent.
414 + * @throws InvalidArgumentException If the part violates the push protocol.
415 + * @throws RuntimeException If the request is truncated or the work directory
416 + * cannot record the part.
417 + */
418 + public function next_change(): bool {
419 + if ($this->upload_lock === null || $this->upload_input === null || $this->upload_processor === null) {
420 + throw new LogicException('Accept an upload before reading changes.');
421 + }
422 + $this->current_change = null;
423 + $this->current_upload_part_ended = false;
424 + try {
425 + if (!$this->next_upload_token()) {
426 + return false;
427 + }
428 + if ($this->upload_processor->get_token_type() !== Site_Export_Multipart_Processor::TOKEN_PART_START) {
429 + throw new LogicException('Expected a multipart part-start token before the next change.');
430 + }
431 + $headers = $this->upload_processor->get_current_headers();
432 + $part_bytes = $this->require_non_negative_header($headers, 'content-length');
433 + if ($part_bytes > $this->maximum_upload_part_bytes) {
434 + throw new InvalidArgumentException('Multipart part Content-Length ' . $part_bytes . ' exceeds the document-root maximum of ' . $this->maximum_upload_part_bytes . ' bytes.');
435 + }
436 + $type = $headers['x-chunk-type'] ?? null;
437 + if (!is_string($type) || !in_array($type, ['file', 'directory', 'symlink', 'delete-list'], true)) {
438 + throw new InvalidArgumentException('Multipart X-Chunk-Type must be file, directory, symlink, or delete-list; observed ' . json_encode($type) . '.');
439 + }
440 + if ($type === 'file') {
441 + $this->receive_file_part($headers, $part_bytes);
442 + } elseif ($type === 'directory') {
443 + $this->receive_directory_part($headers, $part_bytes);
444 + } elseif ($type === 'symlink') {
445 + $this->receive_symlink_part($headers, $part_bytes);
446 + } else {
447 + $this->receive_delete_list_part($headers, $part_bytes);
448 + }
449 + $unread = $this->read_current_upload_body_piece();
450 + if ($unread !== null) {
451 + throw new LogicException('The multipart part handler left ' . strlen($unread) . ' body bytes unread.');
452 + }
453 + return true;
454 + } catch (Throwable $exception) {
455 + $this->upload_input = null;
456 + $this->upload_processor = null;
457 + $this->current_change = null;
458 + throw $exception;
459 + }
460 + }
461 +
462 + /**
463 + * Closes the active upload and releases its push lock.
464 + *
465 + * This method does not drain or validate the remainder of the multipart
466 + * request. A caller may therefore stop after any complete part when a
467 + * request budget is exhausted; a later request resumes from push-directory
468 + * state. It must also be called after next_change() throws.
469 + *
470 + * @throws LogicException If no upload is active.
471 + */
472 + public function finish_upload(): void {
473 + if ($this->upload_lock === null) {
474 + throw new LogicException('No push upload is open; call accept_upload() first.');
475 + }
476 + $lock = $this->upload_lock;
477 + $this->upload_lock = null;
478 + $this->upload_input = null;
479 + $this->upload_processor = null;
480 + $this->current_upload_part_ended = false;
481 + $this->current_change = null;
482 + $this->maximum_upload_part_bytes = PHP_INT_MAX;
483 + $this->maximum_upload_request_body_bytes = PHP_INT_MAX;
484 + $this->upload_request_body_bytes_read = 0;
485 + flock($lock, LOCK_UN);
486 + fclose($lock);
487 + }
488 +
489 + /**
490 + * Returns the receiver-confirmed work state from the latest accepted MIME part.
491 + *
492 + * The value is meaningful only after next_change() returns true. Calling
493 + * next_change() again clears the previous value before processing, and
494 + * finish_upload() clears it when the request closes.
495 + *
496 + * @return array|null {
497 + * Accepted work state, or null when no result is current.
498 + *
499 + * @type string $path_b64 Base64-encoded work path. Present for file,
500 + * directory, and symlink changes; absent for the
501 + * delete list.
502 + * @type string $state Whether the part left partial or complete work.
503 + * Directory and symlink parts are always complete.
504 + * @type string $type One of `file`, `directory`, `symlink`, or
505 + * `delete-list`.
506 + * @type int $accepted_bytes Receiver-confirmed file or delete-list
507 + * bytes. Always zero for directories and
508 + * symlinks.
509 + * }
510 + * @phpstan-return CurrentChange|null
511 + */
512 + public function get_current_change(): ?array {
513 + return $this->current_change;
514 + }
515 +
516 + /**
517 + * Reports work-confirmed push-session progress and selected path cursors.
518 + *
519 + * Senders use this snapshot after a lost response or process restart. It
520 + * derives every cursor from the work directory rather than echoing a
521 + * sender's claimed offset. Calling it without a path returns only push-session
522 + * progress; it never enumerates the complete work-files tree.
523 + *
524 + * The optional path is the in-flight work whose upload response was lost.
525 + * Delete-list resume does not need a path; use work_deletes_bytes from
526 + * the push-session result. The path status is encoded as path_b64 so arbitrary
527 + * filesystem bytes remain representable. It is reported as one of:
528 + *
529 + * - missing, with an accepted_bytes cursor of zero;
530 + * - partial, with its type and the regular file's actual stored byte size,
531 + * or zero for a directory or symlink; or
532 + * - complete, with its file, directory, or symlink type and a file-size
533 + * cursor where applicable.
534 + *
535 + * The push-session result contains the push session ID, the current receiving_work,
536 + * deleting_files, installing_files, or complete phase, the actual delete-stream byte
537 + * size, whether its completion was explicitly declared, and a path status
538 + * when a path was requested. The complete snapshot is read while holding
539 + * the push lock.
540 + *
541 + * @param string|null $path Raw document-root-relative path byte string to inspect.
542 + * @return array {
543 + * Work-confirmed push-session and optional path progress.
544 + *
545 + * @type string $push_session_id Push session ID.
546 + * @type string $phase One of `receiving_work`, `deleting_files`,
547 + * `installing_files`, or `complete`.
548 + * @type int $work_deletes_bytes Receiver-confirmed delete-list bytes.
549 + * @type bool $work_deletes_complete Whether the delete-list upload was
550 + * explicitly completed.
551 + * @type array|null $path Selected path status, or null when no path was
552 + * requested. A status contains `path_b64`, `state`,
553 + * and `accepted_bytes`; `type` is present unless
554 + * `state` is `missing`. `accepted_bytes` is the
555 + * stored file size and zero for missing paths,
556 + * directories, and symlinks.
557 + * }
558 + * @phpstan-return array{
559 + * push_session_id:string,
560 + * phase:'receiving_work'|'deleting_files'|'installing_files'|'complete',
561 + * work_deletes_bytes:int,
562 + * work_deletes_complete:bool,
563 + * path:PathStatus|null
564 + * }
565 + *
566 + * @throws InvalidArgumentException If the requested path is reserved or
567 + * overlaps an excluded path.
568 + * @throws Site_Export_Push_Exception If the push session is busy,
569 + * unavailable, corrupt, or no longer matches the document-root configuration.
570 + */
571 + public function get_status(?string $path = null): array {
572 + return $this->with_push_lock(function () use ($path): array {
573 + $this->finish_inflight_completion();
574 + $reported_path = null;
575 + if ($path !== null) {
576 + $this->assert_path_does_not_overlap_excluded_paths($path);
577 + $complete = wp_join_unix_paths($this->work_files_directory, $path);
578 + $this->ensure_private_parent($complete, false);
579 + $inflight = $this->read_inflight();
580 + if ($inflight !== null && base64_decode($inflight['path_b64'], true) === $path) {
581 + $reported_path = [
582 + 'path_b64' => base64_encode($path),
583 + 'state' => 'partial',
584 + 'type' => $inflight['type'],
585 + 'accepted_bytes' => $inflight['type'] === 'file' && $inflight['phase'] === 'receiving' ? $this->file_size($this->work_inflight_data_path) : 0,
586 + ];
587 + } elseif (($complete_identity = $this->lstat_path($complete)) !== null) {
588 + $reported_path = [
589 + 'path_b64' => base64_encode($path),
590 + 'state' => 'complete',
591 + 'type' => $complete_identity['type'],
592 + 'accepted_bytes' => $complete_identity['type'] === 'file' ? $complete_identity['size'] : 0,
593 + ];
594 + } else {
595 + $reported_path = ['path_b64' => base64_encode($path), 'state' => 'missing', 'accepted_bytes' => 0];
596 + }
597 + }
598 + $commit_state = $this->read_json($this->commit_json_path);
599 + return [
600 + 'push_session_id' => $this->push_session_id,
601 + 'phase' => $commit_state === null ? 'receiving_work' : $commit_state['phase'],
602 + 'work_deletes_bytes' => $this->file_size($this->work_deletes_path),
603 + 'work_deletes_complete' => $this->work_deletes_are_complete(),
604 + 'path' => $reported_path,
605 + ];
606 + });
607 + }
608 +
609 + /**
610 + * Advances a bounded amount of document-root mutation for this push session.
611 + *
612 + * Commit starts only after the delete upload has been explicitly closed and
613 + * no work remains in flight. The first call creates a durable checkpoint
614 + * and claims the document root so no other push session can mutate it.
615 + * Subsequent calls resume from that checkpoint, refresh the WordPress
616 + * maintenance marker, and perform at most $maximum_entries units of delete or
617 + * install work before returning.
618 + *
619 + * Document-root drift and cross-device destinations are non-recoverable for
620 + * the push session: the failure is written into the commit checkpoint and replayed on
621 + * later calls. Recoverable I/O failures do not persist a failure, so a later call can
622 + * retry the same bounded step from the durable state.
623 + *
624 + * @param int $maximum_entries Maximum bounded commit entries to process in this call.
625 + * @param string|null $commit_start_denial_detail When present, commit may
626 + * resume a durable checkpoint but may not create one. The string
627 + * describes why starting commit is denied.
628 + * @return array {
629 + * Current bounded commit result.
630 + *
631 + * @type string $phase Current `deleting_files`, `installing_files`, or
632 + * `complete` phase.
633 + * @type bool $send_next_request Whether another commit request is needed.
634 + * @type int $entries_processed Entries processed by this call.
635 + * }
636 + * @phpstan-return array{phase:'deleting_files'|'installing_files'|'complete',send_next_request:bool,entries_processed:int}
637 + */
638 + public function commit(int $maximum_entries = 1, ?string $commit_start_denial_detail = null): array {
639 + if ($maximum_entries <= 0) {
640 + throw new InvalidArgumentException('The commit entry limit must be greater than zero.');
641 + }
642 + if ($commit_start_denial_detail === '') {
643 + throw new InvalidArgumentException('The commit start denial detail must be a non-empty string.');
644 + }
645 + return $this->with_push_lock(function () use ($maximum_entries, $commit_start_denial_detail): array {
646 + $commit_state = $this->read_json($this->commit_json_path);
647 + if ($commit_state === null) {
648 + // The authorization decision and checkpoint creation share the
649 + // push lock so a denied request cannot race another lifecycle
650 + // operation into starting a new commit.
651 + if ($commit_start_denial_detail !== null) {
652 + throw new Site_Export_Push_Exception(
653 + self::ERROR_PUSH_DISABLED,
654 + $commit_start_denial_detail
655 + );
656 + }
657 + if (!$this->work_deletes_are_complete()) {
658 + throw new InvalidArgumentException('Commit requires an explicit completed delete upload declaration.');
659 + }
660 + $work_deletes_bytes = $this->file_size($this->work_deletes_path);
661 + if ($work_deletes_bytes > 0) {
662 + $handle = @fopen($this->work_deletes_path, 'rb');
663 + if ($handle === false || fseek($handle, -1, SEEK_END) !== 0) {
664 + if (is_resource($handle)) {
665 + fclose($handle);
666 + }
667 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not inspect the final work delete byte.');
668 + }
669 + $last_byte = fread($handle, 1);
670 + fclose($handle);
671 + if ($last_byte !== "\0") {
672 + throw new InvalidArgumentException('A nonempty delete stream must end in NUL before commit; the final record is unterminated.');
673 + }
674 + }
675 + $this->finish_inflight_completion();
676 + if ($this->read_inflight() !== null) {
677 + throw new InvalidArgumentException('Commit cannot begin while work remains in flight.');
678 + }
679 + $commit_state = [
680 + 'phase' => 'deleting_files',
681 + 'work_deletes_byte_offset' => 0,
682 + 'current_delete_path' => null,
683 + 'current_work_files_descendant' => null,
684 + 'commit_cursor' => [],
685 + ];
686 + $this->write_json($this->commit_json_path, $commit_state);
687 + }
688 + if (isset($commit_state['non_recoverable_commit_failure'])) {
689 + throw new Site_Export_Push_Exception(
690 + $commit_state['non_recoverable_commit_failure']['reason'],
691 + $commit_state['non_recoverable_commit_failure']['detail'],
692 + $commit_state['non_recoverable_commit_failure']['context']
693 + );
694 + }
695 + if ($commit_state['phase'] === 'complete') {
696 + // The complete checkpoint is durable before commit ownership is released.
697 + // A retry must finish that release without replaying document-root work.
698 + $this->release_commit_state();
699 + return [
700 + 'phase' => $commit_state['phase'],
701 + 'send_next_request' => false,
702 + 'entries_processed' => 0,
703 + ];
704 + }
705 + $this->with_commit_state_lock(function (): void {
706 + $active_owner = $this->read_commit_owner();
707 + if ($active_owner !== null && $active_owner !== $this->push_session_id) {
708 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'Another push session is already committing this document root: ' . $active_owner . '.');
709 + }
710 + $this->write_atomic_file($this->commit_state_path, $this->push_session_id . "\n", 0600);
711 + });
712 + $maintenance_docroot_path = $this->docroot_path('.maintenance');
713 + $maintenance_identity = $this->lstat_path($maintenance_docroot_path);
714 + if ($maintenance_identity !== null && !$this->maintenance_marker_is_owned($maintenance_docroot_path, $this->push_session_id)) {
715 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'A foreign WordPress maintenance marker already exists. Retry after its owner removes it.');
716 + }
717 + $maintenance_contents = "<?php\n"
718 + . "\$reprint_push_request = (isset(\$_GET['reprint-api']) || isset(\$_GET['site-export-api']))\n"
719 + . " && isset(\$_GET['endpoint']) && is_string(\$_GET['endpoint'])\n"
720 + . " && strpos(\$_GET['endpoint'], 'push_') === 0;\n"
721 + . "if (!\$reprint_push_request) {\n"
722 + . " \$upgrading = " . time() . ";\n"
723 + . "}\n"
724 + . "unset(\$reprint_push_request);\n"
725 + . "// reprint-push-session:" . $this->push_session_id . "\n";
726 + $this->write_atomic_file($this->maintenance_copy_path, $maintenance_contents, 0600);
727 + $this->write_atomic_file($maintenance_docroot_path, $maintenance_contents, 0644);
728 + try {
729 + for ($entries_processed = 0; $entries_processed < $maximum_entries && $commit_state['phase'] !== 'complete'; ++$entries_processed) {
730 + if ($commit_state['phase'] === 'deleting_files') {
731 + $this->advance_delete($commit_state);
732 + } else {
733 + $this->advance_installing_files($commit_state);
734 + }
735 + }
736 + } catch (Site_Export_Push_Exception $exception) {
737 + if (in_array($exception->get_error_code(), [self::ERROR_UNEXPECTED_DOCROOT_MUTATION, self::ERROR_SAME_DEVICE], true)) {
738 + $commit_state['non_recoverable_commit_failure'] = [
739 + 'reason' => $exception->get_error_code(),
740 + 'detail' => $exception->getMessage(),
741 + 'context' => $exception->get_context(),
742 + ];
743 + $this->write_json($this->commit_json_path, $commit_state);
744 + }
745 + throw $exception;
746 + }
747 + return [
748 + 'phase' => $commit_state['phase'],
749 + 'send_next_request' => $commit_state['phase'] !== 'complete',
750 + 'entries_processed' => $entries_processed,
751 + ];
752 + });
753 + }
754 +
755 + /**
756 + * Advances bounded cleanup of an upload-only or completed push directory.
757 + *
758 + * A push session which has begun an incomplete commit remains recovery state and
759 + * cannot be removed. An eligible push session is atomically renamed to a
760 + * private tombstone before entries are removed, so a lost response or later
761 + * request resumes cleanup without making the old push session addressable again.
762 + *
763 + * @return bool True when cleanup is complete, false when the bounded entry
764 + * limit left tombstone work for another call.
765 + */
766 + public function remove_push_directory(): bool {
767 + $push_sessions_directory = self::create_push_sessions_directory($this->reprint_directory);
768 + $removing_push_directory = wp_join_unix_paths(
769 + $push_sessions_directory,
770 + '.removing-' . $this->push_session_id
771 + );
772 + $create_remove_lock = self::acquire_create_remove_lock($push_sessions_directory, 'remove');
773 + try {
774 + if ($this->lstat_path($this->push_directory) === null) {
775 + return $this->remove_tombstone($removing_push_directory);
776 + }
777 + $lock = $this->acquire_push_lock();
778 + try {
779 + $commit_state = $this->read_json($this->commit_json_path);
780 + if ($commit_state !== null && $commit_state['phase'] !== 'complete') {
781 + throw new Site_Export_Push_Exception(self::ERROR_COMMIT_REQUIRED, 'Document-root mutation has begun. Resume commit instead of removing this push session.');
782 + }
783 + if (file_exists($removing_push_directory) || is_link($removing_push_directory)) {
784 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'A remove tombstone already exists for push session ' . $this->push_session_id . '.');
785 + }
786 + if (!@rename($this->push_directory, $removing_push_directory)) {
787 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not move the push directory to its removal tombstone.');
788 + }
789 + } finally {
790 + flock($lock, LOCK_UN);
791 + fclose($lock);
792 + }
793 + return $this->remove_tombstone($removing_push_directory);
794 + } finally {
795 + flock($create_remove_lock, LOCK_UN);
796 + fclose($create_remove_lock);
797 + }
798 + }
799 +
800 + /**
801 + * Returns the next body fragment for the current multipart part.
802 + *
803 + * The multipart processor may expose a body in several bounded fragments,
804 + * followed by a PART_END token. This method hides that token transition
805 + * from the part-specific work code: a string means bytes still belong to
806 + * the current part, and null means the declared Content-Length has been
807 + * satisfied. It never reads into the next part.
808 + *
809 + * @return string|null Current body bytes, or null after the part end.
810 + */
811 + private function read_current_upload_body_piece(): ?string {
812 + if ($this->current_upload_part_ended) {
813 + return null;
814 + }
815 + if (!$this->next_upload_token()) {
816 + throw new LogicException('Multipart input closed before the current part-end token.');
817 + }
818 + $type = $this->upload_processor->get_token_type();
819 + if ($type === Site_Export_Multipart_Processor::TOKEN_BODY) {
820 + return $this->upload_processor->get_current_body_piece();
821 + }
822 + if ($type === Site_Export_Multipart_Processor::TOKEN_PART_END) {
823 + $this->current_upload_part_ended = true;
824 + return null;
825 + }
826 + throw new LogicException('Expected multipart body or part-end; received ' . json_encode($type) . '.');
827 + }
828 +
829 + /**
830 + * Advances the multipart processor, feeding it bounded request bytes.
831 + *
832 + * The processor is drained before each new fread(), so this method
833 + * preserves the streaming contract: at most one request fragment and one
834 + * exposed token are held at a time. Clean completion returns false; a
835 + * truncated request is reported by finish_input().
836 + *
837 + * @return bool True when a processor token is current, false after close.
838 + */
839 + private function next_upload_token(): bool {
840 + while (!$this->upload_processor->next_token()) {
841 + if ($this->upload_processor->is_complete()) {
842 + $trailing_bytes = $this->read_upload_request_fragment();
843 + if ($trailing_bytes !== '') {
844 + throw new InvalidArgumentException(
845 + 'Multipart data contains ' . strlen($trailing_bytes) . ' bytes after the closing boundary.'
846 + );
847 + }
848 + $this->upload_processor->finish_input();
849 + return false;
850 + }
851 + if (!$this->upload_processor->paused_at_incomplete_input()) {
852 + throw new LogicException('Multipart processor stopped without completing or requesting input.');
853 + }
854 + $bytes = $this->read_upload_request_fragment();
855 + if ($bytes === '') {
856 + $this->upload_processor->finish_input();
857 + return false;
858 + }
859 + $this->upload_processor->append_bytes($bytes);
860 + }
861 + return true;
862 + }
863 +
864 + /**
865 + * Reads and accounts for one bounded decoded request-body fragment.
866 + *
867 + * When a request-body limit remains, the extra byte in the read size proves
868 + * the exact observed size which crossed it without buffering another chunk.
869 + * EOF is returned as an empty string so the multipart caller can finish the
870 + * processor in both incomplete and complete parser states.
871 + *
872 + * @return string Next bounded request-body fragment, or an empty string at EOF.
873 + */
874 + private function read_upload_request_fragment(): string {
875 + $maximum_fragment_bytes = Site_Export_Multipart_Processor::MAX_INPUT_FRAGMENT_BYTES;
876 + $remaining_request_body_bytes = PHP_INT_MAX;
877 + if ($this->maximum_upload_request_body_bytes !== PHP_INT_MAX) {
878 + $remaining_request_body_bytes = $this->maximum_upload_request_body_bytes - $this->upload_request_body_bytes_read;
879 + $maximum_fragment_bytes = min($maximum_fragment_bytes, $remaining_request_body_bytes + 1);
880 + }
881 + $bytes = fread($this->upload_input, $maximum_fragment_bytes);
882 + if ($bytes === false) {
883 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not read the multipart upload request body.');
884 + }
885 + $fragment_bytes = strlen($bytes);
886 + if ($fragment_bytes > $remaining_request_body_bytes) {
887 + $observed_request_body_bytes = $this->upload_request_body_bytes_read + $fragment_bytes;
888 + throw new Site_Export_Push_Exception(
889 + self::ERROR_REQUEST_TOO_LARGE,
890 + 'The decoded request body reached ' . $observed_request_body_bytes
891 + . ' bytes, exceeding the target post_max_size of '
892 + . $this->maximum_upload_request_body_bytes . ' bytes.',
893 + ['observed_request_body_bytes' => $observed_request_body_bytes]
894 + );
895 + }
896 + $this->upload_request_body_bytes_read += $fragment_bytes;
897 + return $bytes;
898 + }
899 +
900 + /**
901 + * Reads the durable description of in-flight work.
902 + *
903 + * A push receives or completes one work value at a time. Its identity and
904 + * phase are stored in `work/inflight.json`; file bytes, when
905 + * applicable, are stored separately in `work/inflight.data`. This method
906 + * reads the record before upload, status, or commit decides what work is
907 + * safe to perform.
908 + *
909 + * The JSON record is the authority for whether work is in flight. Callers
910 + * use its type and phase to decide whether they can receive more bytes,
911 + * finish the completed value, or begin commit work. A missing record means
912 + * there is no in-flight work.
913 + *
914 + * @return array|null {
915 + * In-flight work, or null when none exists.
916 + *
917 + * @type string $phase Current `preparing`, `receiving`, or `completing`
918 + * phase. Only files use `receiving`.
919 + * @type string $path_b64 Base64-encoded work path.
920 + * @type string $type One of `file`, `directory`, or `symlink`.
921 + * @type int $total_bytes Declared file size. Present only for files.
922 + * @type string $target_b64 Base64-encoded target. Present only for symlinks.
923 + * }
924 + * @phpstan-return InFlightWork|null
925 + */
926 + private function read_inflight(): ?array {
927 + return $this->read_json($this->work_inflight_path);
928 + }
929 +
930 + /**
931 + * Finishes in-flight work which crossed its durable completion boundary.
932 + *
933 + * The `completing` phase is stored before the completed work value changes.
934 + * That ordering lets a later upload, status request, or commit distinguish a
935 + * stop before completion from one after the data-file rename. When the fixed
936 + * data file remains it is authoritative and is renamed into work/files.
937 + * When it has already been consumed, the matching work value confirms
938 + * completion. Only then is the in-flight metadata removed.
939 + *
940 + * @return void
941 + */
942 + private function finish_inflight_completion(): void {
943 + $inflight = $this->read_inflight();
944 + if ($inflight === null || $inflight['phase'] !== 'completing') {
945 + return;
946 + }
947 + $path = base64_decode($inflight['path_b64'], true);
948 + $work_path = wp_join_unix_paths($this->work_files_directory, $path);
949 + $work_identity = $this->lstat_path($work_path);
950 + if ($inflight['type'] === 'file') {
951 + $data = $this->lstat_path($this->work_inflight_data_path);
952 + if ($data !== null) {
953 + if ($data['type'] !== 'file' || $data['size'] !== $inflight['total_bytes']) {
954 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'In-flight file completion has an invalid data size.');
955 + }
956 + $this->ensure_private_parent($work_path);
957 + if ($work_identity !== null) {
958 + $this->remove_work_path($work_path);
959 + }
960 + if (!@rename($this->work_inflight_data_path, $work_path)) {
961 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not move in-flight file data to its work-file path.');
962 + }
963 + } elseif ($work_identity === null || $work_identity['type'] !== 'file' || $work_identity['size'] !== $inflight['total_bytes']) {
964 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'In-flight file completion has neither data nor a matching work file.');
965 + }
966 + } elseif ($inflight['type'] === 'directory') {
967 + if ($work_identity === null) {
968 + $this->ensure_private_parent($work_path);
969 + // The process umask filters 0777 to the document-root mode used by normal completion.
970 + // Until commit, 0700 work ancestors deny group and other traversal.
971 + if (!@mkdir($work_path, 0777)) {
972 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create the in-flight directory at its work-file path.');
973 + }
974 + } elseif ($work_identity['type'] !== 'directory') {
975 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'In-flight directory completion found an incompatible work value.');
976 + }
977 + } elseif ($work_identity === null) {
978 + $this->ensure_private_parent($work_path);
979 + if (!@symlink(base64_decode($inflight['target_b64'], true), $work_path)) {
980 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create the in-flight symlink at its work-file path.');
981 + }
982 + } elseif ($work_identity['type'] !== 'symlink' || @readlink($work_path) !== base64_decode($inflight['target_b64'], true)) {
983 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'In-flight symlink completion found an incompatible work value.');
984 + }
985 + if (!@unlink($this->work_inflight_path)) {
986 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not clear in-flight metadata after completing work.');
987 + }
988 + }
989 +
990 + /**
991 + * Accepts one file MIME part through the durable in-flight slot.
992 + *
993 + * The caller has already validated Content-Length against the document-root
994 + * part ceiling. This method validates the file-specific headers, enforces the
995 + * work-confirmed resume offset, streams the body into the in-flight data
996 + * file,
997 + * and promotes the file atomically inside the private reprint directory only when the
998 + * declared total size has been reached.
999 + *
1000 + * @param array $headers {
1001 + * Normalized file part headers.
1002 + *
1003 + * @type string $content-length Declared Content-Length header.
1004 + * @type string $content-type Optional. Content-Type header.
1005 + * @type string $x-chunk-type Chunk type header.
1006 + * @type string $x-file-path Base64-encoded file path header.
1007 + * @type string $x-file-size Declared file size header.
1008 + * @type string $x-chunk-offset Declared chunk offset header.
1009 + * }
1010 + * @phpstan-param array{
1011 + * content-length:string,
1012 + * content-type?:string,
1013 + * x-chunk-type:string,
1014 + * x-file-path:string,
1015 + * x-file-size:string,
1016 + * x-chunk-offset:string
1017 + * } $headers
1018 + * @param int $part_bytes Declared Content-Length for this file chunk.
1019 + */
1020 + private function receive_file_part(array $headers, int $part_bytes): void {
1021 + $this->require_only_headers($headers, ['content-length', 'content-type', 'x-chunk-type', 'x-file-path', 'x-file-size', 'x-chunk-offset'], 'file');
1022 + $path = $this->decode_path_header($headers, 'x-file-path');
1023 + $total_bytes = $this->require_non_negative_header($headers, 'x-file-size');
1024 + $offset = $this->require_non_negative_header($headers, 'x-chunk-offset');
1025 + if ($offset > $total_bytes || $part_bytes > $total_bytes - $offset) {
1026 + throw new InvalidArgumentException('File part for ' . base64_encode($path) . ' exceeds its declared total of ' . $total_bytes . ' bytes.');
1027 + }
1028 + $this->finish_inflight_completion();
1029 + $inflight = $this->read_inflight();
1030 + $complete_path = wp_join_unix_paths($this->work_files_directory, $path);
1031 + $complete = $this->lstat_path($complete_path);
1032 + if ($inflight === null && $complete !== null && $complete['type'] === 'file' && $complete['size'] === $total_bytes && $offset === $total_bytes && $part_bytes === 0) {
1033 + if ($this->read_current_upload_body_piece() !== null) {
1034 + throw new LogicException('Multipart processor exposed file bytes for an empty completed-file replay.');
1035 + }
1036 + $this->current_change = ['path_b64' => base64_encode($path), 'state' => 'complete', 'type' => 'file', 'accepted_bytes' => $total_bytes];
1037 + return;
1038 + }
1039 + if ($inflight === null && $offset !== 0) {
1040 + throw new Site_Export_Push_Exception(self::ERROR_OFFSET_GAP, 'File part for ' . base64_encode($path) . ' starts at offset ' . $offset . ', but no matching in-flight file exists. Start at offset 0.');
1041 + }
1042 + if ($inflight !== null && base64_decode($inflight['path_b64'], true) !== $path) {
1043 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'In-flight work already occupies the slot: ' . $inflight['path_b64'] . '.');
1044 + }
1045 + if ($inflight === null || $offset === 0) {
1046 + if ($inflight !== null && $this->lstat_path($this->work_inflight_data_path) !== null && !@unlink($this->work_inflight_data_path)) {
1047 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not discard in-flight file data for restart.');
1048 + }
1049 + $inflight = ['phase' => 'preparing', 'path_b64' => base64_encode($path), 'type' => 'file', 'total_bytes' => $total_bytes];
1050 + $this->write_json($this->work_inflight_path, $inflight);
1051 + $handle = @fopen($this->work_inflight_data_path, 'wb');
1052 + if ($handle === false) {
1053 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create in-flight file data for ' . base64_encode($path) . '.');
1054 + }
1055 + fclose($handle);
1056 + $inflight['phase'] = 'receiving';
1057 + $this->write_json($this->work_inflight_path, $inflight);
1058 + $actual_bytes = 0;
1059 + } else {
1060 + if ($inflight['type'] !== 'file' || $inflight['phase'] !== 'receiving' || $inflight['total_bytes'] !== $total_bytes) {
1061 + throw new Site_Export_Push_Exception(self::ERROR_OFFSET_GAP, 'In-flight file ' . base64_encode($path) . ' must be restarted at offset 0.');
1062 + }
1063 + $actual_bytes = $this->file_size($this->work_inflight_data_path);
1064 + if ($offset !== $actual_bytes) {
1065 + throw new Site_Export_Push_Exception(self::ERROR_OFFSET_GAP, 'File part for ' . base64_encode($path) . ' starts at offset ' . $offset . ', but in-flight data contains ' . $actual_bytes . ' bytes.');
1066 + }
1067 + }
1068 + $handle = @fopen($this->work_inflight_data_path, 'ab');
1069 + if ($handle === false) {
1070 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not open in-flight file data for ' . base64_encode($path) . '.');
1071 + }
1072 + $received = 0;
1073 + try {
1074 + while (($piece = $this->read_current_upload_body_piece()) !== null) {
1075 + $received += strlen($piece);
1076 + $this->write_all($handle, $piece, 'in-flight file data ' . base64_encode($path));
1077 + }
1078 + if (!fflush($handle)) {
1079 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not flush in-flight file data ' . base64_encode($path) . '.');
1080 + }
1081 + } finally {
1082 + fclose($handle);
1083 + }
1084 + $accepted_bytes = $actual_bytes + $received;
1085 + if ($accepted_bytes === $total_bytes) {
1086 + $inflight['phase'] = 'completing';
1087 + $this->write_json($this->work_inflight_path, $inflight);
1088 + $this->ensure_private_parent($complete_path);
1089 + if ($this->lstat_path($complete_path) !== null) {
1090 + $this->remove_work_path($complete_path);
1091 + }
1092 + if (!@rename($this->work_inflight_data_path, $complete_path)) {
1093 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not move in-flight file data to the work-file path ' . base64_encode($path) . '.');
1094 + }
1095 + if (!@unlink($this->work_inflight_path)) {
1096 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not clear in-flight metadata after completing work for ' . base64_encode($path) . '.');
1097 + }
1098 + $state = 'complete';
1099 + } else {
1100 + $state = 'partial';
1101 + }
1102 + $this->current_change = ['path_b64' => base64_encode($path), 'state' => $state, 'type' => 'file', 'accepted_bytes' => $accepted_bytes];
1103 + }
1104 +
1105 + /**
1106 + * Accepts one explicit empty-directory MIME part.
1107 + *
1108 + * Directory parts have no body. They create or refresh an empty directory in
1109 + * the completed work tree. A directory part cannot replace a non-empty
1110 + * directory because that directory contains other completed work values.
1111 + *
1112 + * @param array $headers {
1113 + * Normalized directory part headers.
1114 + *
1115 + * @type string $content-length Declared Content-Length header.
1116 + * @type string $content-type Optional. Content-Type header.
1117 + * @type string $x-chunk-type Chunk type header.
1118 + * @type string $x-directory-path Base64-encoded directory path header.
1119 + * }
1120 + * @phpstan-param array{content-length:string,content-type?:string,x-chunk-type:string,x-directory-path:string} $headers
1121 + * @param int $part_bytes Declared Content-Length, which must be zero.
1122 + */
1123 + private function receive_directory_part(array $headers, int $part_bytes): void {
1124 + $this->require_only_headers($headers, ['content-length', 'content-type', 'x-chunk-type', 'x-directory-path'], 'directory');
1125 + if ($part_bytes !== 0 || $this->read_current_upload_body_piece() !== null) {
1126 + throw new InvalidArgumentException('Multipart directory part must have Content-Length 0.');
1127 + }
1128 + $path = $this->decode_path_header($headers, 'x-directory-path');
1129 + $target = wp_join_unix_paths($this->work_files_directory, $path);
1130 + $this->finish_inflight_completion();
1131 + $inflight = $this->read_inflight();
1132 + if ($inflight !== null && base64_decode($inflight['path_b64'], true) !== $path) {
1133 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'In-flight work already occupies the slot: ' . $inflight['path_b64'] . '.');
1134 + }
1135 + $identity = $this->lstat_path($target);
1136 + if ($identity !== null && $identity['type'] === 'directory' && $this->first_directory_entry($target) !== null) {
1137 + throw new InvalidArgumentException('Explicit empty directory ' . base64_encode($path) . ' conflicts with completed work descendants.');
1138 + }
1139 + if ($inflight === null && $identity !== null && $identity['type'] === 'directory') {
1140 + $this->current_change = ['path_b64' => base64_encode($path), 'state' => 'complete', 'type' => 'directory', 'accepted_bytes' => 0];
1141 + return;
1142 + }
1143 + $inflight = ['phase' => 'preparing', 'path_b64' => base64_encode($path), 'type' => 'directory'];
1144 + $this->write_json($this->work_inflight_path, $inflight);
1145 + if ($this->lstat_path($this->work_inflight_data_path) !== null && !@unlink($this->work_inflight_data_path)) {
1146 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not discard stale in-flight file data.');
1147 + }
1148 + if ($identity !== null) {
1149 + $this->remove_work_path($target);
1150 + }
1151 + $inflight['phase'] = 'completing';
1152 + $this->write_json($this->work_inflight_path, $inflight);
1153 + $this->ensure_private_parent($target);
1154 + if (!is_dir($target) && !@mkdir($target, 0777)) {
1155 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not stage explicit empty directory ' . base64_encode($path) . '.');
1156 + }
1157 + if (!@unlink($this->work_inflight_path)) {
1158 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not clear in-flight metadata after completing work for ' . base64_encode($path) . '.');
1159 + }
1160 + $this->current_change = ['path_b64' => base64_encode($path), 'state' => 'complete', 'type' => 'directory', 'accepted_bytes' => 0];
1161 + }
1162 +
1163 + /**
1164 + * Accepts one symlink MIME part.
1165 + *
1166 + * Symlink parts carry their target in a base64 header and have an empty
1167 + * body. The completed work value replaces any previous leaf at the same
1168 + * private path and rejects directory conflicts that would orphan completed
1169 + * work descendants.
1170 + *
1171 + * @param array $headers {
1172 + * Normalized symlink part headers.
1173 + *
1174 + * @type string $content-length Declared Content-Length header.
1175 + * @type string $content-type Optional. Content-Type header.
1176 + * @type string $x-chunk-type Chunk type header.
1177 + * @type string $x-symlink-path Base64-encoded symlink path header.
1178 + * @type string $x-symlink-target Base64-encoded symlink target header.
1179 + * }
1180 + * @phpstan-param array{
1181 + * content-length:string,
1182 + * content-type?:string,
1183 + * x-chunk-type:string,
1184 + * x-symlink-path:string,
1185 + * x-symlink-target:string
1186 + * } $headers
1187 + * @param int $part_bytes Declared Content-Length, which must be zero.
1188 + */
1189 + private function receive_symlink_part(array $headers, int $part_bytes): void {
1190 + $this->require_only_headers($headers, ['content-length', 'content-type', 'x-chunk-type', 'x-symlink-path', 'x-symlink-target'], 'symlink');
1191 + if ($part_bytes !== 0 || $this->read_current_upload_body_piece() !== null) {
1192 + throw new InvalidArgumentException('Multipart symlink part must have Content-Length 0.');
1193 + }
1194 + $path = $this->decode_path_header($headers, 'x-symlink-path');
1195 + $target_value = $this->decode_path_header($headers, 'x-symlink-target', false);
1196 + if ($target_value === '' || strlen($target_value) > self::MAX_PATH_BYTES || strpos($target_value, "\0") !== false) {
1197 + throw new InvalidArgumentException('Symlink target must contain between 1 and ' . self::MAX_PATH_BYTES . ' bytes without NUL.');
1198 + }
1199 + $target = wp_join_unix_paths($this->work_files_directory, $path);
1200 + $this->finish_inflight_completion();
1201 + $inflight = $this->read_inflight();
1202 + if ($inflight !== null && base64_decode($inflight['path_b64'], true) !== $path) {
1203 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'In-flight work already occupies the slot: ' . $inflight['path_b64'] . '.');
1204 + }
1205 + $identity = $this->lstat_path($target);
1206 + if ($identity !== null && $identity['type'] === 'directory' && $this->first_directory_entry($target) !== null) {
1207 + throw new InvalidArgumentException('Work symlink ' . base64_encode($path) . ' conflicts with completed work descendants.');
1208 + }
1209 + if ($inflight === null && $identity !== null && $identity['type'] === 'symlink' && @readlink($target) === $target_value) {
1210 + $this->current_change = ['path_b64' => base64_encode($path), 'state' => 'complete', 'type' => 'symlink', 'accepted_bytes' => 0];
1211 + return;
1212 + }
1213 + $inflight = ['phase' => 'preparing', 'path_b64' => base64_encode($path), 'type' => 'symlink', 'target_b64' => base64_encode($target_value)];
1214 + $this->write_json($this->work_inflight_path, $inflight);
1215 + if ($this->lstat_path($this->work_inflight_data_path) !== null && !@unlink($this->work_inflight_data_path)) {
1216 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not discard stale in-flight file data.');
1217 + }
1218 + if ($identity !== null) {
1219 + $this->remove_work_path($target);
1220 + }
1221 + $inflight['phase'] = 'completing';
1222 + $this->write_json($this->work_inflight_path, $inflight);
1223 + $this->ensure_private_parent($target);
1224 + if (!@symlink($target_value, $target)) {
1225 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not stage symlink ' . base64_encode($path) . '.');
1226 + }
1227 + if (!@unlink($this->work_inflight_path)) {
1228 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not clear in-flight metadata after completing work for ' . base64_encode($path) . '.');
1229 + }
1230 + $this->current_change = ['path_b64' => base64_encode($path), 'state' => 'complete', 'type' => 'symlink', 'accepted_bytes' => 0];
1231 + }
1232 +
1233 + /**
1234 + * Accepts one segment of the raw NUL-delimited delete stream.
1235 + *
1236 + * The delete stream is append-only, but lost responses may cause callers to
1237 + * replay bytes already stored by the target. Overlapping bytes must match
1238 + * exactly; new bytes are validated record-by-record before they are flushed.
1239 + * A completion declaration records that no more delete bytes may be added.
1240 + *
1241 + * @param array $headers {
1242 + * Normalized delete-list part headers.
1243 + *
1244 + * @type string $content-length Declared Content-Length header.
1245 + * @type string $content-type Optional. Content-Type header.
1246 + * @type string $x-chunk-type Chunk type header.
1247 + * @type string $x-delete-offset Declared delete-list offset header.
1248 + * @type string $x-delete-complete Optional. Delete-list completion
1249 + * declaration header.
1250 + * }
1251 + * @phpstan-param array{
1252 + * content-length:string,
1253 + * content-type?:string,
1254 + * x-chunk-type:string,
1255 + * x-delete-offset:string,
1256 + * x-delete-complete?:string
1257 + * } $headers
1258 + * @param int $part_bytes Declared Content-Length for this delete segment.
1259 + */
1260 + private function receive_delete_list_part(array $headers, int $part_bytes): void {
1261 + $this->require_only_headers($headers, ['content-length', 'content-type', 'x-chunk-type', 'x-delete-offset', 'x-delete-complete'], 'delete-list');
1262 + $offset = $this->require_non_negative_header($headers, 'x-delete-offset');
1263 + $complete = ( $headers['x-delete-complete'] ?? null ) === '1';
1264 + if (isset($headers['x-delete-complete']) && !$complete) {
1265 + throw new InvalidArgumentException('Multipart X-Delete-Complete must be 1 when present.');
1266 + }
1267 + if ($this->work_deletes_are_complete() && ( !$complete || $offset !== $this->file_size($this->work_deletes_path) || $part_bytes !== 0 )) {
1268 + throw new InvalidArgumentException('Delete upload is already complete; only its empty completion declaration may be replayed.');
1269 + }
1270 + $handle = @fopen($this->work_deletes_path, 'r+b');
1271 + if ($handle === false) {
1272 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not open the raw work delete stream.');
1273 + }
1274 + try {
1275 + $delete_stat = fstat($handle);
1276 + if (!is_array($delete_stat) || !isset($delete_stat['size'])) {
1277 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not determine the actual size of work delete stream.');
1278 + }
1279 + $stored_bytes = (int) $delete_stat['size'];
1280 + if ($offset > $stored_bytes) {
1281 + throw new Site_Export_Push_Exception(
1282 + self::ERROR_OFFSET_GAP,
1283 + 'Delete-list part starts at offset ' . $offset . ', but the work delete stream has stored ' . $stored_bytes . ' bytes.'
1284 + );
1285 + }
1286 + $position = $offset;
1287 + if ($stored_bytes === 0) {
1288 + $trailing_path = '';
1289 + } else {
1290 + $suffix_bytes = min($stored_bytes, self::MAX_PATH_BYTES + 1);
1291 + if (fseek($handle, $stored_bytes - $suffix_bytes) !== 0) {
1292 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not inspect the work delete-stream suffix.');
1293 + }
1294 + $suffix = $this->read_exact($handle, $suffix_bytes, 'work delete-stream suffix');
1295 + $last_nul = strrpos($suffix, "\0");
1296 + $trailing_path = $last_nul === false ? $suffix : substr($suffix, $last_nul + 1);
1297 + if ($last_nul === false && $stored_bytes > self::MAX_PATH_BYTES) {
1298 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'The incomplete work delete path already exceeds ' . self::MAX_PATH_BYTES . ' bytes.');
1299 + }
1300 + }
1301 + while (true) {
1302 + $piece = $this->read_current_upload_body_piece();
1303 + if ($piece === null) {
1304 + break;
1305 + }
1306 + $piece_offset = 0;
1307 + $overlap = min(strlen($piece), max(0, $stored_bytes - $position));
1308 + if ($overlap > 0) {
1309 + if (fseek($handle, $position) !== 0) {
1310 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not seek within the work delete stream for replay validation.');
1311 + }
1312 + $stored = $this->read_exact($handle, $overlap, 'work delete replay');
1313 + if ($stored !== substr($piece, 0, $overlap)) {
1314 + throw new InvalidArgumentException('Delete-list replay differs from bytes already stored at offset ' . $position . '.');
1315 + }
1316 + $position += $overlap;
1317 + $piece_offset = $overlap;
1318 + }
1319 + if ($piece_offset < strlen($piece)) {
1320 + $append = substr($piece, $piece_offset);
1321 + $append_length = strlen($append);
1322 + for ($index = 0; $index < $append_length; ++$index) {
1323 + if ($append[$index] === "\0") {
1324 + if ($trailing_path === '') {
1325 + throw new InvalidArgumentException('Delete-list parts may not contain an empty delete record.');
1326 + }
1327 + $this->assert_path_does_not_overlap_excluded_paths($trailing_path);
1328 + $trailing_path = '';
1329 + continue;
1330 + }
1331 + $trailing_path .= $append[$index];
1332 + if (strlen($trailing_path) > self::MAX_PATH_BYTES) {
1333 + throw new InvalidArgumentException('Delete-list path exceeds the maximum of ' . self::MAX_PATH_BYTES . ' bytes.');
1334 + }
1335 + }
1336 + if (fseek($handle, 0, SEEK_END) !== 0) {
1337 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not seek to the work delete stream end.');
1338 + }
1339 + $this->write_all($handle, $append, 'work delete stream');
1340 + $stored_bytes += strlen($append);
1341 + $position += strlen($append);
1342 + if (!fflush($handle)) {
1343 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not flush the work delete stream.');
1344 + }
1345 + }
1346 + }
1347 + if ($complete && $position !== $stored_bytes) {
1348 + throw new InvalidArgumentException('Delete completion must be declared at the actual stored size of ' . $stored_bytes . ' bytes.');
1349 + }
1350 + } finally {
1351 + fclose($handle);
1352 + }
1353 + if ($complete) {
1354 + $push_metadata = $this->read_json($this->push_json_path);
1355 + if (!is_array($push_metadata)) {
1356 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Push metadata is missing while completing the delete upload.');
1357 + }
1358 + $push_metadata['work_deletes_complete'] = true;
1359 + $this->write_json($this->push_json_path, $push_metadata);
1360 + }
1361 + $this->current_change = ['state' => $complete ? 'complete' : 'partial', 'type' => 'delete-list', 'accepted_bytes' => $stored_bytes];
1362 + }
1363 +
1364 + /**
1365 + * Performs one bounded delete step from the durable commit checkpoint.
1366 + *
1367 + * The first call for a record copies the next NUL-delimited path from the
1368 + * raw delete stream into `current_delete_path`. A later call removes at
1369 + * most one leaf or empty directory beneath that root and advances the byte
1370 + * cursor only after the document-root path is confirmed absent.
1371 + *
1372 + * @param array $commit_state {
1373 + * Commit checkpoint, mutated in place.
1374 + *
1375 + * @type string $phase Current commit phase.
1376 + * @type int $work_deletes_byte_offset Confirmed delete-list cursor.
1377 + * @type string|null $current_delete_path Delete path currently being consumed.
1378 + * @type array|null $current_work_files_descendant Work value currently being installed,
1379 + * with `path_b64` and `expected_type` keys.
1380 + * @type array $commit_cursor Path components for the bounded tree walk.
1381 + * @type array $non_recoverable_commit_failure Persisted failure reason, detail, and
1382 + * context. Present only after a
1383 + * non-recoverable failure.
1384 + * }
1385 + * @phpstan-param CommitState $commit_state
1386 + */
1387 + private function advance_delete(array &$commit_state): void {
1388 + if ($commit_state['current_delete_path'] === null) {
1389 + $work_deletes_byte_offset = (int) $commit_state['work_deletes_byte_offset'];
1390 + $delete_size = $this->file_size($this->work_deletes_path);
1391 + if ($work_deletes_byte_offset === $delete_size) {
1392 + $commit_state['phase'] = 'installing_files';
1393 + $this->write_json($this->commit_json_path, $commit_state);
1394 + return;
1395 + }
1396 + if ($work_deletes_byte_offset < 0 || $work_deletes_byte_offset > $delete_size) {
1397 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Delete-consumption offset ' . $work_deletes_byte_offset . ' is outside the ' . $delete_size . '-byte stream.');
1398 + }
1399 + $handle = @fopen($this->work_deletes_path, 'rb');
1400 + if ($handle === false || fseek($handle, $work_deletes_byte_offset) !== 0) {
1401 + if (is_resource($handle)) {
1402 + fclose($handle);
1403 + }
1404 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not seek to the confirmed delete-consumption offset.');
1405 + }
1406 + $path = '';
1407 + $path_bytes = 0;
1408 + try {
1409 + while ($path_bytes <= self::MAX_PATH_BYTES) {
1410 + $byte = fread($handle, 1);
1411 + if ($byte === false) {
1412 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not read the work delete stream.');
1413 + }
1414 + if ($byte === '') {
1415 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'The work delete stream ended before its NUL record terminator.');
1416 + }
1417 + if ($byte === "\0") {
1418 + if ($path === '') {
1419 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'The work delete stream contains an empty record at offset ' . $work_deletes_byte_offset . '.');
1420 + }
1421 + $this->assert_path_does_not_overlap_excluded_paths($path);
1422 + $commit_state['current_delete_path'] = base64_encode($path);
1423 + $this->write_json($this->commit_json_path, $commit_state);
1424 + return;
1425 + }
1426 + $path .= $byte;
1427 + ++$path_bytes;
1428 + }
1429 + } finally {
1430 + fclose($handle);
1431 + }
1432 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'A work delete path exceeds ' . self::MAX_PATH_BYTES . ' bytes.');
1433 + }
1434 +
1435 + $path = $this->decode_commit_path($commit_state['current_delete_path'], 'current delete');
1436 + $this->assert_path_does_not_overlap_excluded_paths($path);
1437 + $parent_device = $this->require_docroot_ancestors($path, 'delete');
1438 + if ($parent_device !== null) {
1439 + $docroot_value_path = $this->docroot_path($path);
1440 + $identity = $this->lstat_path($docroot_value_path);
1441 + if ($identity !== null) {
1442 + if (!in_array($identity['type'], ['file', 'directory', 'symlink'], true)) {
1443 + $this->throw_unexpected_docroot_mutation('delete', $path, $path, null, ['absent', 'file', 'directory', 'symlink'], $identity);
1444 + }
1445 + if ($identity['dev'] !== $parent_device) {
1446 + $this->throw_same_device('delete', $path, $this->work_device(), $identity['dev']);
1447 + }
1448 + $this->remove_docroot_entry($docroot_value_path, $path, $path, $parent_device);
1449 + }
1450 + if ($this->lstat_path($docroot_value_path) !== null) {
1451 + return;
1452 + }
1453 + }
1454 + $commit_state['work_deletes_byte_offset'] += strlen($path) + 1;
1455 + $commit_state['current_delete_path'] = null;
1456 + $this->write_json($this->commit_json_path, $commit_state);
1457 + }
1458 +
1459 + /**
1460 + * Removes at most one leaf or empty directory below one planned root.
1461 + *
1462 + * Directories are drained depth-first so each commit step is bounded and
1463 + * recoverable. The requested root is kept separate from the recursive
1464 + * relative path so drift responses can name both the user-requested delete
1465 + * and the nested path that actually conflicted.
1466 + *
1467 + * @param string $absolute_path Current document-root filesystem path to inspect.
1468 + * @param string $relative_path Document-root-relative path matching $absolute_path.
1469 + * @param string $requested_path Original delete root used in conflicts.
1470 + * @param int $parent_device Device id expected for the current entry.
1471 + */
1472 + private function remove_docroot_entry(string $absolute_path, string $relative_path, string $requested_path, int $parent_device): void {
1473 + $identity = $this->lstat_path($absolute_path);
1474 + if ($identity === null) {
1475 + return;
1476 + }
1477 + if ($identity['dev'] !== $parent_device) {
1478 + $this->throw_same_device('delete', $relative_path, $this->work_device(), $identity['dev']);
1479 + }
1480 + if ($identity['type'] === 'file' || $identity['type'] === 'symlink') {
1481 + if (!@unlink($absolute_path)) {
1482 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove document-root ' . $identity['type'] . ' ' . base64_encode($relative_path) . '.');
1483 + }
1484 + return;
1485 + }
1486 + if ($identity['type'] !== 'directory') {
1487 + $this->throw_unexpected_docroot_mutation('delete', $requested_path, $relative_path, null, ['file', 'directory', 'symlink'], $identity);
1488 + }
1489 + $entry = $this->first_directory_entry($absolute_path);
1490 + if ($entry === null) {
1491 + if (!@rmdir($absolute_path)) {
1492 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove empty document-root directory ' . base64_encode($relative_path) . '.');
1493 + }
1494 + return;
1495 + }
1496 + $child_relative = wp_join_unix_paths($relative_path, $entry);
1497 + $this->remove_docroot_entry(
1498 + wp_join_unix_paths($absolute_path, $entry),
1499 + $child_relative,
1500 + $requested_path,
1501 + $identity['dev']
1502 + );
1503 + }
1504 +
1505 + /**
1506 + * Performs one bounded installing_files or commit-cursor step.
1507 + *
1508 + * The completed work tree is its own queue. This method walks it
1509 + * depth-first, creating document-root ancestor directories before their
1510 + * children, installing one leaf value per step, and consuming empty work
1511 + * ancestor directories after their descendants have been committed.
1512 + *
1513 + * @param array $commit_state {
1514 + * Commit checkpoint, mutated in place.
1515 + *
1516 + * @type string $phase Current commit phase.
1517 + * @type int $work_deletes_byte_offset Confirmed delete-list cursor.
1518 + * @type string|null $current_delete_path Delete path currently being consumed.
1519 + * @type array|null $current_work_files_descendant Work value currently being installed,
1520 + * with `path_b64` and `expected_type` keys.
1521 + * @type array $commit_cursor Path components for the bounded tree walk.
1522 + * @type array $non_recoverable_commit_failure Persisted failure reason, detail, and
1523 + * context. Present only after a
1524 + * non-recoverable failure.
1525 + * }
1526 + * @phpstan-param CommitState $commit_state
1527 + */
1528 + private function advance_installing_files(array &$commit_state): void {
1529 + if ($commit_state['current_work_files_descendant'] !== null) {
1530 + /*
1531 + * A checkpoint may survive either side of a rename or work ancestor
1532 + * directory cleanup. The work value may still be present and need
1533 + * retrying, or it may already be consumed and require verification
1534 + * in the document root. Resolve that checkpoint before selecting
1535 + * any new work.
1536 + */
1537 + $current_work_files_descendant = $commit_state['current_work_files_descendant'];
1538 + $path = $this->decode_commit_path($current_work_files_descendant['path_b64'], 'current installing_files');
1539 + $expected_type = $current_work_files_descendant['expected_type'];
1540 + $stack_size = count($commit_state['commit_cursor']);
1541 + $work_ancestor_directory_cleanup = false;
1542 + if ($stack_size > 0) {
1543 + $work_ancestor_directory_cleanup = $this->commit_cursor_path($commit_state['commit_cursor']) === $path;
1544 + }
1545 + $work_path = wp_join_unix_paths($this->work_files_directory, $path);
1546 + $work_identity = $this->lstat_path($work_path);
1547 +
1548 + if ($work_ancestor_directory_cleanup) {
1549 + $this->assert_path_is_not_excluded($path);
1550 + $this->require_docroot_ancestors($path, 'install', 'directory');
1551 + $docroot_identity = $this->lstat_path($this->docroot_path($path));
1552 + if ($work_identity !== null) {
1553 + if ($work_identity['type'] !== 'directory' || $this->first_directory_entry($work_path) !== null) {
1554 + $this->throw_unexpected_docroot_mutation('install', $path, $path, 'directory', ['directory'], $docroot_identity);
1555 + }
1556 + if ($docroot_identity === null || $docroot_identity['type'] !== 'directory') {
1557 + $this->throw_unexpected_docroot_mutation('install', $path, $path, 'directory', ['directory'], $docroot_identity);
1558 + }
1559 + if (!@rmdir($work_path)) {
1560 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not finish work ancestor directory cleanup for ' . base64_encode($path) . '.');
1561 + }
1562 + } elseif ($docroot_identity === null || $docroot_identity['type'] !== 'directory') {
1563 + $this->throw_unexpected_docroot_mutation('install', $path, $path, 'directory', ['directory'], $docroot_identity);
1564 + }
1565 + $commit_state['current_work_files_descendant'] = null;
1566 + array_pop($commit_state['commit_cursor']);
1567 + $this->write_json($this->commit_json_path, $commit_state);
1568 + return;
1569 + }
1570 +
1571 + $this->assert_path_does_not_overlap_excluded_paths($path);
1572 + if ($work_identity !== null) {
1573 + $this->install_work_value($commit_state, $path, $expected_type, true);
1574 + return;
1575 + }
1576 + $this->require_docroot_ancestors($path, 'install', $expected_type);
1577 + $docroot_identity = $this->lstat_path($this->docroot_path($path));
1578 + if ($docroot_identity === null || $docroot_identity['type'] !== $expected_type) {
1579 + $this->throw_unexpected_docroot_mutation('install', $path, $path, $expected_type, [$expected_type], $docroot_identity);
1580 + }
1581 + $commit_state['current_work_files_descendant'] = null;
1582 + $this->write_json($this->commit_json_path, $commit_state);
1583 +
1584 + return;
1585 + }
1586 +
1587 + $stack_size = count($commit_state['commit_cursor']);
1588 + if ($stack_size === 0) {
1589 + $parent_path = '';
1590 + $work_directory_path = $this->work_files_directory;
1591 + } else {
1592 + $parent_path = $this->commit_cursor_path($commit_state['commit_cursor']);
1593 + $work_directory_path = wp_join_unix_paths($this->work_files_directory, $parent_path);
1594 + }
1595 + $entry = $this->first_directory_entry($work_directory_path);
1596 + if ($entry === null) {
1597 + if ($stack_size === 0) {
1598 + if ($commit_state['current_delete_path'] !== null || $commit_state['commit_cursor'] !== []) {
1599 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Commit reached completion with active bounded work state.');
1600 + }
1601 + if ( (int) $commit_state['work_deletes_byte_offset'] !== $this->file_size($this->work_deletes_path)) {
1602 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Commit reached completion before consuming the complete delete stream.');
1603 + }
1604 + if ($this->first_directory_entry($this->work_files_directory) !== null) {
1605 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Commit reached completion while work/files still contains pending values.');
1606 + }
1607 + $maintenance_docroot_path = $this->docroot_path('.maintenance');
1608 + $maintenance_identity = $this->lstat_path($maintenance_docroot_path);
1609 + if ($maintenance_identity !== null) {
1610 + if (!$this->maintenance_marker_is_owned($maintenance_docroot_path, $this->push_session_id)) {
1611 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'The push-session-owned maintenance marker was replaced by another owner.');
1612 + }
1613 + if (!@unlink($maintenance_docroot_path)) {
1614 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove the push-session-owned WordPress maintenance marker.');
1615 + }
1616 + }
1617 + if ($this->lstat_path($this->maintenance_copy_path) !== null && !@unlink($this->maintenance_copy_path)) {
1618 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove the private maintenance ownership marker.');
1619 + }
1620 + $commit_state['phase'] = 'complete';
1621 + $this->write_json($this->commit_json_path, $commit_state);
1622 + $this->release_commit_state();
1623 + return;
1624 + }
1625 + $this->require_docroot_ancestors($parent_path, 'install', 'directory');
1626 + $docroot_identity = $this->lstat_path($this->docroot_path($parent_path));
1627 + if ($docroot_identity === null || $docroot_identity['type'] !== 'directory') {
1628 + $this->throw_unexpected_docroot_mutation('install', $parent_path, $parent_path, 'directory', ['directory'], $docroot_identity);
1629 + }
1630 + $commit_state['current_work_files_descendant'] = ['path_b64' => base64_encode($parent_path), 'expected_type' => 'directory'];
1631 + $this->write_json($this->commit_json_path, $commit_state);
1632 + if (!@rmdir($work_directory_path)) {
1633 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not consume empty work ancestor directory ' . base64_encode($parent_path) . '.');
1634 + }
1635 + $commit_state['current_work_files_descendant'] = null;
1636 + array_pop($commit_state['commit_cursor']);
1637 + $this->write_json($this->commit_json_path, $commit_state);
1638 + return;
1639 + }
1640 +
1641 + $path = wp_join_unix_paths($parent_path, $entry);
1642 + $this->assert_path_not_reserved($path);
1643 + $work_path = wp_join_unix_paths($this->work_files_directory, $path);
1644 + $identity = $this->lstat_path($work_path);
1645 + if ($identity === null) {
1646 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Selected work path disappeared before installing_files: ' . base64_encode($path) . '.');
1647 + }
1648 + if ($identity['type'] === 'directory' && $this->first_directory_entry($work_path) !== null) {
1649 + $this->assert_path_is_not_excluded($path);
1650 + $commit_state['commit_cursor'][] = ['component_b64' => base64_encode($entry)];
1651 + $this->write_json($this->commit_json_path, $commit_state);
1652 + $requested_path = $this->first_work_files_descendant_path($work_path, $path);
1653 + $parent_device = $this->require_docroot_ancestors($path, 'install', 'directory');
1654 + $docroot_value_path = $this->docroot_path($path);
1655 + $docroot_identity = $this->lstat_path($docroot_value_path);
1656 + if ($docroot_identity === null) {
1657 + if (!@mkdir($docroot_value_path, 0777) && !is_dir($docroot_value_path)) {
1658 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create document-root ancestor directory ' . base64_encode($path) . '.');
1659 + }
1660 + $docroot_identity = $this->lstat_path($docroot_value_path);
1661 + }
1662 + if ($docroot_identity === null || $docroot_identity['type'] !== 'directory') {
1663 + $this->throw_unexpected_docroot_mutation('install', $requested_path, $path, 'directory', ['absent', 'directory'], $docroot_identity);
1664 + }
1665 + if ($docroot_identity['dev'] !== $parent_device || $docroot_identity['dev'] !== $this->work_device()) {
1666 + $this->throw_same_device('install', $path, $this->work_device(), $docroot_identity['dev']);
1667 + }
1668 + return;
1669 + }
1670 + $this->assert_path_does_not_overlap_excluded_paths($path);
1671 + if (!in_array($identity['type'], ['file', 'directory', 'symlink'], true)) {
1672 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Work path ' . base64_encode($path) . ' has unsupported type ' . $identity['type'] . '.');
1673 + }
1674 + $this->install_work_value($commit_state, $path, $identity['type'], false);
1675 + }
1676 +
1677 + /**
1678 + * Renames one completed work value into the document root.
1679 + *
1680 + * Before rename, the checkpoint records the exact path and expected type so
1681 + * recovery can tell whether the work value still needs installing_files or
1682 + * the document root already contains the committed value. Only same-filesystem
1683 + * renames are allowed; copy fallback would break the direct-install model.
1684 + *
1685 + * An existing empty directory at a directory destination is accepted:
1686 + * rename() replaces it atomically, so re-pushing after an interrupted
1687 + * commit already created the directory succeeds instead of reporting the
1688 + * commit's own leftover as drift. A non-empty directory still conflicts.
1689 + *
1690 + * @param array $commit_state {
1691 + * Commit checkpoint, mutated in place.
1692 + *
1693 + * @type string $phase Current commit phase.
1694 + * @type int $work_deletes_byte_offset Confirmed delete-list cursor.
1695 + * @type string|null $current_delete_path Delete path currently being consumed.
1696 + * @type array|null $current_work_files_descendant Work value currently being installed,
1697 + * with `path_b64` and `expected_type` keys.
1698 + * @type array $commit_cursor Path components for the bounded tree walk.
1699 + * @type array $non_recoverable_commit_failure Persisted failure reason, detail, and
1700 + * context. Present only after a
1701 + * non-recoverable failure.
1702 + * }
1703 + * @phpstan-param CommitState $commit_state
1704 + * @param string $path Document-root-relative value path.
1705 + * @param string $expected_type Work type expected at $path.
1706 + * @param bool $recovering Whether current_work_files_descendant is already durable.
1707 + */
1708 + private function install_work_value(array &$commit_state, string $path, string $expected_type, bool $recovering): void {
1709 + $work_path = wp_join_unix_paths($this->work_files_directory, $path);
1710 + $work_identity = $this->lstat_path($work_path);
1711 + if ($work_identity === null || $work_identity['type'] !== $expected_type) {
1712 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Work ' . $expected_type . ' ' . base64_encode($path) . ' is not present for installing_files.');
1713 + }
1714 + $parent_device = $this->require_docroot_ancestors($path, 'install', $expected_type);
1715 + $docroot_value_path = $this->docroot_path($path);
1716 + $docroot_identity = $this->lstat_path($docroot_value_path);
1717 + $expected_docroot_types = $expected_type === 'directory' ? ['absent', 'directory'] : ['absent', 'file', 'symlink'];
1718 + $observed_type = $docroot_identity === null ? 'absent' : $docroot_identity['type'];
1719 + if (!in_array($observed_type, $expected_docroot_types, true)) {
1720 + $this->throw_unexpected_docroot_mutation('install', $path, $path, $expected_type, $expected_docroot_types, $docroot_identity);
1721 + }
1722 + if ($expected_type === 'directory' && $observed_type === 'directory' && $this->first_directory_entry($docroot_value_path) !== null) {
1723 + $this->throw_unexpected_docroot_mutation('install', $path, $path, $expected_type, ['absent'], $docroot_identity);
1724 + }
1725 + if ($parent_device !== $work_identity['dev']) {
1726 + $this->throw_same_device('install', $path, $work_identity['dev'], $parent_device);
1727 + }
1728 + if (!$recovering) {
1729 + $commit_state['current_work_files_descendant'] = ['path_b64' => base64_encode($path), 'expected_type' => $expected_type];
1730 + $this->write_json($this->commit_json_path, $commit_state);
1731 + }
1732 + error_clear_last();
1733 + if (!@rename($work_path, $docroot_value_path)) {
1734 + $last_error = error_get_last();
1735 + $message = is_array($last_error) ? $last_error['message'] : '';
1736 + $observed_docroot_identity = $this->lstat_path($docroot_value_path);
1737 + if ($observed_docroot_identity !== null && $observed_docroot_identity['dev'] !== $work_identity['dev']) {
1738 + $this->throw_same_device('install', $path, $work_identity['dev'], $observed_docroot_identity['dev']);
1739 + }
1740 + if (stripos($message, 'cross-device') !== false || stripos($message, 'exdev') !== false) {
1741 + $this->throw_same_device('install', $path, $work_identity['dev'], $parent_device);
1742 + }
1743 + throw new Site_Export_Push_Exception(
1744 + self::ERROR_FILESYSTEM,
1745 + 'Could not rename work ' . base64_encode($path) . ' directly into the document root'
1746 + . ( $message === '' ? '.' : ': ' . $message )
1747 + );
1748 + }
1749 + $commit_state['current_work_files_descendant'] = null;
1750 + $this->write_json($this->commit_json_path, $commit_state);
1751 + }
1752 +
1753 +
1754 + /**
1755 + * Validates existing document-root ancestors without following a symlink.
1756 + *
1757 + * @return int|null Device of the nearest real parent, or null when a
1758 + * delete root is already absent below a missing parent.
1759 + */
1760 + private function require_docroot_ancestors(string $path, string $operation, ?string $work_identity_type = null): ?int {
1761 + $root = $this->lstat_path($this->docroot);
1762 + if ($root === null || $root['type'] !== 'directory') {
1763 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'The document root is no longer a real directory.');
1764 + }
1765 + $work_device = $this->work_device();
1766 + if ($root['dev'] !== $work_device) {
1767 + $this->throw_same_device($operation, $path, $work_device, $root['dev']);
1768 + }
1769 + $device = $root['dev'];
1770 + $absolute = $this->docroot;
1771 + $relative = '';
1772 + $segments = explode('/', $path);
1773 + array_pop($segments);
1774 + foreach ($segments as $segment) {
1775 + $relative = wp_join_unix_paths($relative, $segment);
1776 + $absolute = wp_join_unix_paths($absolute, $segment);
1777 + $identity = $this->lstat_path($absolute);
1778 + if ($identity === null) {
1779 + if ($operation === 'delete') {
1780 + return null;
1781 + }
1782 + $this->throw_unexpected_docroot_mutation($operation, $path, $relative, $work_identity_type, ['directory'], null);
1783 + }
1784 + if ($identity['type'] !== 'directory') {
1785 + $this->throw_unexpected_docroot_mutation($operation, $path, $relative, $work_identity_type, ['directory'], $identity);
1786 + }
1787 + if ($identity['dev'] !== $device) {
1788 + $this->throw_same_device($operation, $relative, $work_device, $identity['dev']);
1789 + }
1790 + $device = $identity['dev'];
1791 + }
1792 + return $device;
1793 + }
1794 +
1795 + /**
1796 + * @param list<string> $expected_docroot_types Document-root identity types accepted at
1797 + * the conflicting path.
1798 + * @param array|null $observed_identity {
1799 + * Observed document-root filesystem identity, or null when absent.
1800 + *
1801 + * @type string $type Path type.
1802 + * @type int $dev Device number.
1803 + * @type int $ino Inode number.
1804 + * @type int $size Size in bytes.
1805 + * @type int $ctime Change time.
1806 + * }
1807 + * @phpstan-param array{type:string,dev:int,ino:int,size:int,ctime:int}|null $observed_identity
1808 + */
1809 + private function throw_unexpected_docroot_mutation(
1810 + string $operation,
1811 + string $path,
1812 + string $conflict_path,
1813 + ?string $work_identity_type,
1814 + array $expected_docroot_types,
1815 + ?array $observed_identity
1816 + ): void {
1817 + $detail = 'Refusing the operation because the observed document-root filesystem state is incompatible. The conflicting path was left untouched.';
1818 + $context = [
1819 + 'operation' => $operation,
1820 + 'path_b64' => base64_encode($path),
1821 + 'conflict_path_b64' => base64_encode($conflict_path),
1822 + 'expected_docroot_types' => $expected_docroot_types,
1823 + 'observed_docroot_identity' => $observed_identity === null ? ['type' => 'absent'] : $observed_identity,
1824 + ];
1825 + if ($work_identity_type !== null) {
1826 + $context['work_type'] = $work_identity_type;
1827 + }
1828 + throw new Site_Export_Push_Exception(self::ERROR_UNEXPECTED_DOCROOT_MUTATION, $detail, $context);
1829 + }
1830 +
1831 + /**
1832 + * Raises the non-recoverable same-filesystem violation used by push commit.
1833 + *
1834 + * Work commit intentionally has no copy fallback. Copying would turn a
1835 + * bounded rename step into an unbounded transfer and could leave partially
1836 + * copied document-root files after interruption, so any device mismatch becomes
1837 + * a classified non-recoverable error.
1838 + *
1839 + * @param string $operation Receive, delete, or install operation being checked.
1840 + * @param string $path Document-root-relative path associated with the mismatch.
1841 + * @param int $work_device Device id of the private work filesystem.
1842 + * @param int $docroot_device Device id observed in the document root.
1843 + */
1844 + private function throw_same_device(string $operation, string $path, int $work_device, int $docroot_device): void {
1845 + $detail = 'The work value and document-root destination are on different filesystems. This push requires same-filesystem rename and has no copy fallback.';
1846 + throw new Site_Export_Push_Exception(self::ERROR_SAME_DEVICE, $detail, [
1847 + 'operation' => $operation,
1848 + 'path_b64' => base64_encode($path),
1849 + 'work_device' => $work_device,
1850 + 'docroot_device' => $docroot_device,
1851 + ]);
1852 + }
1853 +
1854 + /**
1855 + * Verifies that two concrete paths are on the same device.
1856 + *
1857 + * This is used when creating or opening a push session, where both paths must
1858 + * already exist and lstat() can supply device ids directly. Later per-path
1859 + * checks use the document-root ancestor walkers because the final destination may
1860 + * not exist yet.
1861 + *
1862 + * @param string $work_path Existing private work path.
1863 + * @param string $docroot_value_path Existing document-root path.
1864 + * @param string $operation Operation name to report on failure.
1865 + * @param string $relative_path Document-root-relative path to report on failure.
1866 + */
1867 + private function require_same_device(string $work_path, string $docroot_value_path, string $operation, string $relative_path): void {
1868 + $work = $this->lstat_path($work_path);
1869 + $docroot_identity = $this->lstat_path($docroot_value_path);
1870 + if ($work === null || $docroot_identity === null) {
1871 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not determine the work and document-root filesystem devices.');
1872 + }
1873 + if ($work['dev'] !== $docroot_identity['dev']) {
1874 + $this->throw_same_device($operation, $relative_path, $work['dev'], $docroot_identity['dev']);
1875 + }
1876 + }
1877 +
1878 + /**
1879 + * Returns the device id of the completed work tree root.
1880 + *
1881 + * All direct installs must remain on this device. Reading it from work/files
1882 + * rather than cached constructor state keeps recovery honest if the private
1883 + * push directory was moved or corrupted between requests.
1884 + *
1885 + * @return int Device id reported by lstat().
1886 + */
1887 + private function work_device(): int {
1888 + $identity = $this->lstat_path($this->work_files_directory);
1889 + if ($identity === null || $identity['type'] !== 'directory') {
1890 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'work/files is not a real work directory.');
1891 + }
1892 + return $identity['dev'];
1893 + }
1894 +
1895 + /**
1896 + * Reads an exact number of bytes from a stream or reports a precise short read.
1897 + *
1898 + * Delete replay validation and suffix inspection rely on exact byte counts.
1899 + * Returning partial data would corrupt offset accounting, so short reads
1900 + * are reported as filesystem errors naming the observed length.
1901 + *
1902 + * @param resource $handle Open stream positioned at the first byte to read.
1903 + * @param int $bytes Number of bytes required.
1904 + * @param string $description Human-readable stream description for errors.
1905 + * @return string Bytes read from the stream.
1906 + */
1907 + private function read_exact($handle, int $bytes, string $description): string {
1908 + $result = '';
1909 + $result_bytes = 0;
1910 + while ($result_bytes < $bytes) {
1911 + $piece = fread($handle, $bytes - $result_bytes);
1912 + if ($piece === false || $piece === '') {
1913 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not read complete ' . $description . '; expected ' . $bytes . ' bytes and observed ' . $result_bytes . '.');
1914 + }
1915 + $result .= $piece;
1916 + $result_bytes += strlen($piece);
1917 + }
1918 + return $result;
1919 + }
1920 +
1921 + /**
1922 + * Returns the first child name in a directory without following children.
1923 + *
1924 + * The method is used only to distinguish empty directories from ones with
1925 + * descendants. It returns the raw directory entry name so callers can build
1926 + * their own private or document-root path without allocating a full listing.
1927 + *
1928 + * @param string $directory Absolute directory path.
1929 + * @return string|null First child name, or null when the directory is empty.
1930 + */
1931 + private function first_directory_entry(string $directory): ?string {
1932 + $handle = @opendir($directory);
1933 + if ($handle === false) {
1934 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not read directory ' . $directory . '.');
1935 + }
1936 + try {
1937 + while (true) {
1938 + $entry = readdir($handle);
1939 + if ($entry === false) {
1940 + break;
1941 + }
1942 + if ($entry !== '.' && $entry !== '..') {
1943 + return $entry;
1944 + }
1945 + }
1946 + } finally {
1947 + closedir($handle);
1948 + }
1949 + return null;
1950 + }
1951 +
1952 + /**
1953 + * Returns a work leaf path below a work ancestor directory.
1954 + *
1955 + * When a document-root ancestor directory conflicts, reporting only the
1956 + * ancestor can hide which work value required it. This walks to one
1957 + * descendant so the error can name requested work rather than only the
1958 + * commit-cursor directory.
1959 + *
1960 + * @param string $directory Absolute work directory being traversed.
1961 + * @param string $relative_path Document-root-relative path for that directory.
1962 + * @return string Document-root-relative descendant or the original path if empty.
1963 + */
1964 + private function first_work_files_descendant_path(string $directory, string $relative_path): string {
1965 + $entry = $this->first_directory_entry($directory);
1966 + if ($entry === null) {
1967 + return $relative_path;
1968 + }
1969 + $child_path = wp_join_unix_paths($relative_path, $entry);
1970 + $entry_path = wp_join_unix_paths($directory, $entry);
1971 + $identity = $this->lstat_path($entry_path);
1972 + if ($identity !== null && $identity['type'] === 'directory') {
1973 + return $this->first_work_files_descendant_path($entry_path, $child_path);
1974 + }
1975 + return $child_path;
1976 + }
1977 +
1978 + /**
1979 + * Checks whether a document-root .maintenance file belongs to this push session ID.
1980 + *
1981 + * The marker may be a normal WordPress maintenance file created by another
1982 + * process. Only files containing this push session's ownership comment are safe
1983 + * to refresh or remove; foreign markers keep the document root busy.
1984 + *
1985 + * @param string $path Absolute document-root .maintenance path.
1986 + * @param string $push_session_id Push session ID recorded in the marker.
1987 + * @return bool Whether the marker contains this push session's ownership line.
1988 + */
1989 + private function maintenance_marker_is_owned(string $path, string $push_session_id): bool {
1990 + $contents = @file_get_contents($path, false, null, 0, 512);
1991 + return is_string($contents)
1992 + && strpos($contents, '// reprint-push-session:' . $push_session_id . "\n") !== false;
1993 + }
1994 +
1995 + /**
1996 + * Releases this push session's document-root-wide commit claim if it still owns it.
1997 + *
1998 + * The active marker is advisory state excluded by the commit-state lock. A
1999 + * missing marker or another session's valid claim is left untouched so cleanup
2000 + * cannot erase document-root ownership which changed after this commit.
2001 + */
2002 + private function release_commit_state(): void {
2003 + $this->with_commit_state_lock(function (): void {
2004 + $active_owner = $this->read_commit_owner();
2005 + if ($active_owner !== $this->push_session_id) {
2006 + return;
2007 + }
2008 + if (!@unlink($this->commit_state_path)) {
2009 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not release the commit-state owner.');
2010 + }
2011 + });
2012 + }
2013 +
2014 + /**
2015 + * Runs a callback while holding the document-root-wide commit-state lock.
2016 + *
2017 + * This lock serializes the small `commit-state` file shared by all push
2018 + * sessions committing one reprint directory. It is intentionally separate from a
2019 + * push lock so a committing push session can block other committers without
2020 + * blocking their upload/status cleanup paths.
2021 + *
2022 + * @param callable $callback Critical section to execute while locked.
2023 + */
2024 + private function with_commit_state_lock(callable $callback): void {
2025 + $lock = @fopen($this->commit_state_lock_path, 'c+b');
2026 + if ($lock === false) {
2027 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not open the commit-state lock.');
2028 + }
2029 + try {
2030 + if (!flock($lock, LOCK_EX | LOCK_NB)) {
2031 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'The commit-state owner is busy. Retry the request.');
2032 + }
2033 + $callback();
2034 + } finally {
2035 + flock($lock, LOCK_UN);
2036 + fclose($lock);
2037 + }
2038 + }
2039 +
2040 + /**
2041 + * Reads the validated push session ID which owns the document-root commit.
2042 + *
2043 + * This method is called only while the commit-state lock is held. A missing
2044 + * marker means no commit owns the document root. Existing state must be a
2045 + * readable regular file containing one valid push session ID.
2046 + */
2047 + private function read_commit_owner(): ?string {
2048 + $identity = $this->lstat_path($this->commit_state_path);
2049 + if ($identity === null) {
2050 + return null;
2051 + }
2052 + if ($identity['type'] !== 'file') {
2053 + throw new Site_Export_Push_Exception(
2054 + self::ERROR_CORRUPTED_PUSH_STATE,
2055 + 'Reprint cannot identify the active push commit because its commit-state marker is not a regular file.'
2056 + );
2057 + }
2058 + $active_owner = @file_get_contents($this->commit_state_path);
2059 + if (!is_string($active_owner)) {
2060 + throw new Site_Export_Push_Exception(
2061 + self::ERROR_FILESYSTEM,
2062 + 'Reprint could not read the active push commit from its commit-state marker.'
2063 + );
2064 + }
2065 + $active_owner = trim($active_owner);
2066 + try {
2067 + self::require_push_session_id($active_owner);
2068 + } catch (InvalidArgumentException $exception) {
2069 + throw new Site_Export_Push_Exception(
2070 + self::ERROR_CORRUPTED_PUSH_STATE,
2071 + 'Reprint cannot identify the active push commit because its commit-state marker is malformed.'
2072 + );
2073 + }
2074 + return $active_owner;
2075 + }
2076 +
2077 + /**
2078 + * Runs one callback against a validated push session while holding its lock.
2079 + *
2080 + * The push-directory layout is checked by acquire_push_lock(). Immutable
2081 + * push session ID and the same-filesystem requirement are then checked
2082 + * before the callback can read or mutate push state.
2083 + *
2084 + * @return mixed Callback result.
2085 + */
2086 + private function with_push_lock(callable $callback) {
2087 + $lock = $this->acquire_push_lock();
2088 + try {
2089 + $this->assert_push_configuration();
2090 + return $callback();
2091 + } finally {
2092 + flock($lock, LOCK_UN);
2093 + fclose($lock);
2094 + }
2095 + }
2096 +
2097 + /**
2098 + * Locks one existing push session after checking only the paths needed to do so safely.
2099 + *
2100 + * The complete durable push directory is validated after the lock is held. This
2101 + * avoids trusting a pre-lock snapshot while also rejecting an already
2102 + * malformed push session or lock path before fopen() is called.
2103 + *
2104 + * @return resource Exclusive push lock owned by the caller.
2105 + */
2106 + private function acquire_push_lock() {
2107 + $push_session_identity = $this->lstat_path($this->push_directory);
2108 + if ($push_session_identity === null) {
2109 + throw new Site_Export_Push_Exception(self::ERROR_PUSH_NOT_FOUND, 'The push session does not exist: ' . $this->push_session_id . '.');
2110 + }
2111 + if ($push_session_identity['type'] !== 'directory') {
2112 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'The push session path is not a real directory: ' . $this->push_directory . '.');
2113 + }
2114 + $lock_identity = $this->lstat_path($this->push_lock_path);
2115 + if ($lock_identity === null || $lock_identity['type'] !== 'file') {
2116 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'The push lock is missing or not regular: ' . $this->push_lock_path . '.');
2117 + }
2118 +
2119 + $lock = @fopen($this->push_lock_path, 'r+b');
2120 + if ($lock === false) {
2121 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not open the push lock.');
2122 + }
2123 + if (!flock($lock, LOCK_EX | LOCK_NB)) {
2124 + fclose($lock);
2125 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'Push session ' . $this->push_session_id . ' is busy. Retry the request.');
2126 + }
2127 + try {
2128 + foreach ([$this->push_directory, $this->work_dir, $this->work_files_directory] as $directory) {
2129 + $identity = $this->lstat_path($directory);
2130 + if ($identity === null || $identity['type'] !== 'directory') {
2131 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Required push directory is missing or not real: ' . $directory . '.');
2132 + }
2133 + }
2134 + foreach ([$this->push_json_path, $this->push_lock_path, $this->work_deletes_path] as $file) {
2135 + $identity = $this->lstat_path($file);
2136 + if ($identity === null || $identity['type'] !== 'file') {
2137 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Required push file is missing or not regular: ' . $file . '.');
2138 + }
2139 + }
2140 + foreach ([$this->commit_json_path, $this->maintenance_copy_path, $this->work_inflight_path, $this->work_inflight_data_path] as $optional_file) {
2141 + $identity = $this->lstat_path($optional_file);
2142 + if ($identity !== null && $identity['type'] !== 'file') {
2143 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Optional push file has an unsupported type: ' . $optional_file . '.');
2144 + }
2145 + }
2146 + } catch (Throwable $exception) {
2147 + flock($lock, LOCK_UN);
2148 + fclose($lock);
2149 + throw $exception;
2150 + }
2151 + return $lock;
2152 + }
2153 +
2154 + /**
2155 + * Verifies that durable push session identity still matches this server configuration.
2156 + *
2157 + * Remove deliberately omits this check: private work may need cleanup
2158 + * after the document-root or excluded-path configuration has changed.
2159 + * Create, upload, status, and commit must agree with the immutable push metadata
2160 + * and retain the same-device guarantee under which the push was made.
2161 + */
2162 + private function assert_push_configuration(): void {
2163 + $push_metadata = $this->read_json($this->push_json_path);
2164 + if (!is_array($push_metadata) || ( $push_metadata['push_session_id'] ?? null ) !== $this->push_session_id
2165 + || !is_bool($push_metadata['work_deletes_complete'] ?? null)) {
2166 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Push metadata has an invalid push session ID or work-deletes completion state.');
2167 + }
2168 + if (!is_string($push_metadata['docroot_b64'] ?? null) || !is_array($push_metadata['excluded_paths_b64'] ?? null)) {
2169 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Push metadata does not contain the configured document root and excluded paths.');
2170 + }
2171 + $docroot = base64_decode($push_metadata['docroot_b64'], true);
2172 + $excluded = [];
2173 + foreach ($push_metadata['excluded_paths_b64'] as $encoded) {
2174 + $decoded = is_string($encoded) ? base64_decode($encoded, true) : false;
2175 + if (!is_string($decoded)) {
2176 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Push metadata contains an invalid excluded path.');
2177 + }
2178 + $excluded[] = $decoded;
2179 + }
2180 + if ($docroot !== $this->docroot || $excluded !== $this->excluded_paths) {
2181 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Push metadata does not match the current push configuration.');
2182 + }
2183 + $this->require_same_device($this->work_files_directory, $this->docroot, 'receive', '');
2184 + }
2185 +
2186 + /**
2187 + * Reads a bounded JSON object from private push metadata.
2188 + *
2189 + * Missing files return null so callers can distinguish optional checkpoints
2190 + * from malformed ones. Existing files must be regular, within the metadata
2191 + * size ceiling, and decode to a JSON object.
2192 + *
2193 + * @param string $path Absolute metadata file path.
2194 + * @return array<string,mixed>|null Decoded caller-specific object, or null
2195 + * if absent.
2196 + */
2197 + private function read_json(string $path): ?array {
2198 + $identity = $this->lstat_path($path);
2199 + if ($identity === null) {
2200 + return null;
2201 + }
2202 + if ($identity['type'] !== 'file' || $identity['size'] > self::MAX_METADATA_BYTES) {
2203 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Metadata file ' . $path . ' is not a bounded regular file.');
2204 + }
2205 + $contents = @file_get_contents($path);
2206 + if (!is_string($contents)) {
2207 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not read metadata file ' . $path . '.');
2208 + }
2209 + $decoded = json_decode($contents, true);
2210 + if (!is_array($decoded)) {
2211 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Metadata file ' . $path . ' does not contain a JSON object.');
2212 + }
2213 + return $decoded;
2214 + }
2215 +
2216 + /**
2217 + * Atomically writes one bounded JSON metadata object.
2218 + *
2219 + * JSON is encoded without slash escaping because metadata contains many
2220 + * filesystem paths already excluded by base64 where necessary. The encoded
2221 + * object must fit the same ceiling enforced by read_json().
2222 + *
2223 + * @param string $path Absolute metadata file path.
2224 + * @param array $value {
2225 + * Push metadata, in-flight work, or a commit checkpoint.
2226 + *
2227 + * @type string $push_session_id Push session ID. Present only in push metadata.
2228 + * @type string $docroot_b64 Base64-encoded document root. Present only in push metadata.
2229 + * @type string[] $excluded_paths_b64 Base64-encoded excluded paths. Present only in push metadata.
2230 + * @type bool $work_deletes_complete Delete-list completion. Present only in push metadata.
2231 + * @type string $phase In-flight or commit phase. Absent from push metadata.
2232 + * @type string $path_b64 In-flight work path. Present only in in-flight work.
2233 + * @type string $type In-flight work type. Present only in in-flight work.
2234 + * @type int $total_bytes Declared file size. Present only for an in-flight file.
2235 + * @type string $target_b64 Base64-encoded symlink target. Present only for an in-flight symlink.
2236 + * @type int $work_deletes_byte_offset Confirmed delete-list cursor. Present only in a commit checkpoint.
2237 + * @type string|null $current_delete_path Current delete path. Present only in a commit checkpoint.
2238 + * @type array|null $current_work_files_descendant Current installation. Present only in a commit checkpoint.
2239 + * @type array $commit_cursor Bounded tree cursor. Present only in a commit checkpoint.
2240 + * @type array $non_recoverable_commit_failure Persisted failure. Present only after a non-recoverable commit failure.
2241 + * }
2242 + * @phpstan-param array{push_session_id:string,docroot_b64:string,excluded_paths_b64:list<string>,work_deletes_complete:bool}|InFlightWork|CommitState $value
2243 + */
2244 + private function write_json(string $path, array $value): void {
2245 + $contents = json_encode($value, JSON_UNESCAPED_SLASHES);
2246 + if (!is_string($contents)) {
2247 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Could not encode bounded push metadata.');
2248 + }
2249 + if (strlen($contents) > self::MAX_METADATA_BYTES) {
2250 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Encoded push metadata exceeds the maximum of ' . self::MAX_METADATA_BYTES . ' bytes.');
2251 + }
2252 + $this->write_atomic_file($path, $contents, 0600);
2253 + }
2254 +
2255 + /**
2256 + * Writes a private file through a push-session-specific temporary path and rename.
2257 + *
2258 + * The temporary name includes the push session ID so concurrent push sessions updating
2259 + * shared control files do not collide before the commit-state lock serializes
2260 + * the final rename. Permissions are applied to the temporary file before
2261 + * that rename.
2262 + *
2263 + * @param string $path Absolute destination path.
2264 + * @param string $contents Complete file contents to write.
2265 + * @param int $permissions File mode applied to the temporary file.
2266 + */
2267 + private function write_atomic_file(string $path, string $contents, int $permissions): void {
2268 + $temporary = $path . '.tmp-' . $this->push_session_id;
2269 + if ($this->lstat_path($temporary) !== null && !@unlink($temporary)) {
2270 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not clear temporary metadata file ' . $temporary . '.');
2271 + }
2272 + $handle = @fopen($temporary, 'xb');
2273 + if ($handle === false) {
2274 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create temporary metadata file ' . $temporary . '.');
2275 + }
2276 + try {
2277 + $this->write_all($handle, $contents, 'metadata file ' . $path);
2278 + if (!fflush($handle)) {
2279 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not flush temporary metadata file ' . $temporary . '.');
2280 + }
2281 + } finally {
2282 + fclose($handle);
2283 + }
2284 + @chmod($temporary, $permissions);
2285 + if (!@rename($temporary, $path)) {
2286 + @unlink($temporary);
2287 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not replace metadata file ' . $path . '.');
2288 + }
2289 + }
2290 +
2291 + /**
2292 + * Writes every byte of a string to an already opened stream.
2293 + *
2294 + * fwrite() may accept only part of a string. This loops until all bytes are
2295 + * written and reports the exact completed count if the stream stops making
2296 + * progress, preventing silent truncation of work payloads or metadata.
2297 + *
2298 + * @param resource $handle Writable stream.
2299 + * @param string $contents Bytes to write.
2300 + * @param string $description Human-readable destination for errors.
2301 + */
2302 + private function write_all($handle, string $contents, string $description): void {
2303 + $offset = 0;
2304 + $length = strlen($contents);
2305 + while ($offset < $length) {
2306 + $written = fwrite($handle, substr($contents, $offset));
2307 + if (!is_int($written) || $written <= 0) {
2308 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not finish writing ' . $description . '; wrote ' . $offset . ' of ' . $length . ' bytes.');
2309 + }
2310 + $offset += $written;
2311 + }
2312 + }
2313 +
2314 + /**
2315 + * Decodes and validates a base64 path stored in a commit checkpoint.
2316 + *
2317 + * Checkpoints store arbitrary filesystem bytes as base64 to remain valid
2318 + * JSON. This method rejects missing, malformed, or receiver-reserved path
2319 + * forms. Its caller applies the requested-value or work-ancestor-directory
2320 + * excluded-path policy before any document-root mutation.
2321 + *
2322 + * @param mixed $encoded Candidate base64 value from metadata.
2323 + * @param string $description Field name used in error messages.
2324 + * @return string Decoded document-root-relative path.
2325 + */
2326 + private function decode_commit_path($encoded, string $description): string {
2327 + if (!is_string($encoded)) {
2328 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Commit ' . $description . ' path is not base64 text.');
2329 + }
2330 + $path = base64_decode($encoded, true);
2331 + if (!is_string($path)) {
2332 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Commit ' . $description . ' path is not valid base64.');
2333 + }
2334 + $this->assert_path_not_reserved($path);
2335 + return $path;
2336 + }
2337 +
2338 + /**
2339 + * Reconstructs the document-root-relative path from commit cursor frames.
2340 + *
2341 + * Each frame stores exactly one base64 path component. The method validates
2342 + * each component independently, rebuilds the slash-separated path, and then
2343 + * applies the implicit work-ancestor-directory path rules to the result.
2344 + *
2345 + * @param array $stack {
2346 + * Commit cursor frames.
2347 + *
2348 + * @type string $component_b64 Base64-encoded path component.
2349 + * }
2350 + * @phpstan-param list<array{component_b64:string}> $stack
2351 + * @return string Document-root-relative path for the current commit cursor directory.
2352 + */
2353 + private function commit_cursor_path(array $stack): string {
2354 + $path = '';
2355 + foreach ($stack as $frame) {
2356 + $encoded = $frame['component_b64'] ?? null;
2357 + $component = is_string($encoded) ? base64_decode($encoded, true) : false;
2358 + if (!is_string($component) || $component === '' || strpos($component, '/') !== false) {
2359 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Commit cursor frame does not contain one valid base64 path component.');
2360 + }
2361 + $path = wp_join_unix_paths($path, $component);
2362 + if (strlen($path) > self::MAX_PATH_BYTES) {
2363 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Commit cursor path exceeds the maximum of ' . self::MAX_PATH_BYTES . ' bytes.');
2364 + }
2365 + }
2366 + if ($path !== '') {
2367 + $this->assert_path_is_not_excluded($path);
2368 + }
2369 + return $path;
2370 + }
2371 +
2372 + /**
2373 + * Reads whether the sender explicitly closed the delete stream.
2374 + *
2375 + * A zero-byte or currently stored delete stream is not enough to commit:
2376 + * the sender must declare completion so the receiver knows no later request
2377 + * will append more delete records.
2378 + *
2379 + * @return bool True once a delete-list part declared completion.
2380 + */
2381 + private function work_deletes_are_complete(): bool {
2382 + $push_metadata = $this->read_json($this->push_json_path);
2383 + if (!is_array($push_metadata) || !is_bool($push_metadata['work_deletes_complete'] ?? null)) {
2384 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Push metadata has no valid work-deletes completion state.');
2385 + }
2386 + return $push_metadata['work_deletes_complete'];
2387 + }
2388 +
2389 + /**
2390 + * Rejects a requested value which would overlap an excluded path.
2391 + *
2392 + * Requested files, directories, symlinks, and delete roots may not equal,
2393 + * descend from, or contain an excluded path. Work ancestor directories use
2394 + * assert_path_is_not_excluded() because an unrelated sibling may still need
2395 + * to traverse an ancestor of an excluded path.
2396 + *
2397 + * @param string $path Document-root-relative raw path bytes.
2398 + */
2399 + private function assert_path_does_not_overlap_excluded_paths(string $path): void {
2400 + $this->assert_path_is_not_excluded($path);
2401 + foreach ($this->excluded_paths as $excluded_path) {
2402 + if (path_remainder_under($excluded_path, $path) !== null) {
2403 + throw new InvalidArgumentException(
2404 + 'Excluded document-root-relative path ' . base64_encode($excluded_path)
2405 + . ' is contained by the requested path, which cannot be changed: '
2406 + . base64_encode($path) . '.'
2407 + );
2408 + }
2409 + }
2410 + }
2411 +
2412 + /**
2413 + * Rejects a path equal to or below an excluded path.
2414 + *
2415 + * A work ancestor directory is traversed only to reach requested descendant
2416 + * work. It may be an ancestor of an excluded path when the work lies in an
2417 + * unrelated sibling, but it must never equal or descend from an excluded
2418 + * path itself.
2419 + *
2420 + * @param string $path Document-root-relative path.
2421 + */
2422 + private function assert_path_is_not_excluded(string $path): void {
2423 + $this->assert_path_not_reserved($path);
2424 + foreach ($this->excluded_paths as $excluded_path) {
2425 + if ($path === $excluded_path) {
2426 + throw new InvalidArgumentException(
2427 + 'Excluded document-root-relative path cannot be changed: '
2428 + . base64_encode($path) . '.'
2429 + );
2430 + }
2431 + if (path_remainder_under($path, $excluded_path) !== null) {
2432 + throw new InvalidArgumentException(
2433 + 'Excluded document-root-relative path ' . base64_encode($excluded_path)
2434 + . ' contains the requested descendant, which cannot be changed: '
2435 + . base64_encode($path) . '.'
2436 + );
2437 + }
2438 + }
2439 + }
2440 +
2441 + /**
2442 + * Rejects path forms reserved by the receiver.
2443 + *
2444 + * Paths are arbitrary byte strings carried as base64 on the wire, but the
2445 + * receiver reserves forms which are empty, exceed the bounded path length,
2446 + * are absolute, contain NUL or backslash bytes, contain empty or dot path
2447 + * components, or address the WordPress maintenance marker.
2448 + *
2449 + * @param string $path Document-root-relative raw path bytes.
2450 + */
2451 + private function assert_path_not_reserved(string $path): void {
2452 + $path_bytes = strlen($path);
2453 + if ($path_bytes > self::MAX_PATH_BYTES) {
2454 + throw new InvalidArgumentException(
2455 + 'Document-root-relative path exceeds the maximum of '
2456 + . self::MAX_PATH_BYTES . ' bytes; observed ' . $path_bytes . '.'
2457 + );
2458 + }
2459 + assert_valid_relative_path($path, 'Document-root-relative path');
2460 + if (path_is_same_as_or_descendant_of($path, '.maintenance')) {
2461 + throw new InvalidArgumentException('The WordPress maintenance marker path is reserved: ' . base64_encode($path) . '.');
2462 + }
2463 + }
2464 +
2465 + /**
2466 + * Decodes a base64 path header from one multipart part.
2467 + *
2468 + * Document-root paths are validated immediately because they select private and
2469 + * document-root filesystem locations. Symlink destination values can be arbitrary
2470 + * relative strings, so callers can disable document-root-path validation and enforce
2471 + * their own symlink-target rules instead.
2472 + *
2473 + * @param array<string,string> $headers Normalized part headers keyed by lowercase header name.
2474 + * @param string $header Header name to read.
2475 + * @param bool $is_docroot_path Whether to validate a document-root path.
2476 + * @return string Decoded header bytes.
2477 + */
2478 + private function decode_path_header(array $headers, string $header, bool $is_docroot_path = true): string {
2479 + $encoded = $headers[$header] ?? null;
2480 + if (!is_string($encoded) || $encoded === '') {
2481 + throw new InvalidArgumentException('Multipart part requires a non-empty ' . $header . ' header.');
2482 + }
2483 + $decoded = base64_decode($encoded, true);
2484 + if (!is_string($decoded)) {
2485 + throw new InvalidArgumentException('Multipart header ' . $header . ' is not valid base64.');
2486 + }
2487 + if ($is_docroot_path) {
2488 + $this->assert_path_does_not_overlap_excluded_paths($decoded);
2489 + }
2490 + return $decoded;
2491 + }
2492 +
2493 + /**
2494 + * Rejects unexpected headers for a multipart part type.
2495 + *
2496 + * The push protocol is deliberately narrow. Extra headers are not
2497 + * ignored because a misspelled required header or a future unsupported
2498 + * option should fail at the boundary instead of silently changing meaning.
2499 + *
2500 + * @param array<string,string> $headers Normalized headers to inspect, keyed
2501 + * by lowercase header name.
2502 + * @param list<string> $allowed Lowercase header names allowed for this part.
2503 + * @param string $type Human-readable part type for errors.
2504 + */
2505 + private function require_only_headers(array $headers, array $allowed, string $type): void {
2506 + foreach (array_keys($headers) as $name) {
2507 + if (!in_array($name, $allowed, true)) {
2508 + throw new InvalidArgumentException('Multipart ' . $type . ' part does not allow header ' . json_encode($name) . '.');
2509 + }
2510 + }
2511 + }
2512 +
2513 + /**
2514 + * Reads a non-negative decimal integer header.
2515 + *
2516 + * Header values arrive as strings. This validates the decimal grammar and
2517 + * rejects values that overflow PHP's integer range rather than silently
2518 + * wrapping offsets, sizes, or Content-Length values.
2519 + *
2520 + * @param array<string,string> $headers Normalized headers to inspect, keyed
2521 + * by lowercase header name.
2522 + * @param string $header Header name to read.
2523 + * @return int Parsed non-negative integer.
2524 + */
2525 + private function require_non_negative_header(array $headers, string $header): int {
2526 + $value = $headers[$header] ?? null;
2527 + if (!is_string($value) || $value === '' || preg_match('/^[0-9]+$/D', $value) !== 1) {
2528 + throw new InvalidArgumentException('Multipart header ' . $header . ' must be a non-negative decimal integer; observed ' . json_encode($value) . '.');
2529 + }
2530 + $integer = (int) $value;
2531 + if ($integer < 0 || ( (string) $integer !== ltrim($value, '0') && !preg_match('/^0+$/D', $value) )) {
2532 + throw new InvalidArgumentException('Multipart header ' . $header . ' exceeds the supported integer range; observed ' . json_encode($value) . '.');
2533 + }
2534 + return $integer;
2535 + }
2536 +
2537 + /**
2538 + * Joins the document root with one document-root-relative path.
2539 + *
2540 + * The caller applies the appropriate requested-value or work-ancestor-path
2541 + * validation where the value originates. This method only preserves correct
2542 + * slash handling for both `/` and normal directory roots.
2543 + *
2544 + * @param string $relative_path Document-root-relative path.
2545 + * @return string Absolute path in the document root.
2546 + */
2547 + private function docroot_path(string $relative_path): string {
2548 + return wp_join_unix_paths($this->docroot, $relative_path);
2549 + }
2550 +
2551 + /**
2552 + * Creates or validates private work ancestor directories for a work path.
2553 + *
2554 + * Only work/files paths are accepted. Missing parents are
2555 + * created when requested; existing parents must be real directories so a
2556 + * work leaf, link, or external path cannot become a container for another
2557 + * value.
2558 + *
2559 + * @param string $path Absolute private path whose parent is required.
2560 + * @param bool $create_missing Whether absent parent directories are created.
2561 + */
2562 + private function ensure_private_parent(string $path, bool $create_missing = true): void {
2563 + $parent = dirname($path);
2564 + $relative = relative_path_under($parent, $this->work_files_directory);
2565 + if ($relative === null) {
2566 + throw new LogicException('Private work path escaped work/files.');
2567 + }
2568 + if ($relative === '') {
2569 + return;
2570 + }
2571 + $current = $this->work_files_directory;
2572 + foreach (explode('/', $relative) as $segment) {
2573 + $current = wp_join_unix_paths($current, $segment);
2574 + $identity = $this->lstat_path($current);
2575 + if ($identity === null) {
2576 + if (!$create_missing) {
2577 + return;
2578 + }
2579 + if (!@mkdir($current, 0700)) {
2580 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create private work ancestor directory ' . $current . '.');
2581 + }
2582 + continue;
2583 + }
2584 + if ($identity['type'] !== 'directory') {
2585 + throw new InvalidArgumentException('A work ' . $identity['type'] . ' cannot be used as the parent of another path.');
2586 + }
2587 + }
2588 + }
2589 +
2590 + /**
2591 + * Returns the lstat identity of one filesystem path.
2592 + *
2593 + * lstat() is used deliberately so symlinks are classified as symlinks
2594 + * rather than followed. Keeping the syscall and mode classification here
2595 + * gives status, recovery, and drift reporting the same view of a path.
2596 + *
2597 + * @param string $path Absolute path to inspect.
2598 + * @return array|null {
2599 + * Filesystem identity, or null if absent.
2600 + *
2601 + * @type string $type Path type.
2602 + * @type int $dev Device number.
2603 + * @type int $ino Inode number.
2604 + * @type int $size Size in bytes.
2605 + * @type int $ctime Change time.
2606 + * }
2607 + * @phpstan-return array{type:string,dev:int,ino:int,size:int,ctime:int}|null
2608 + */
2609 + private function lstat_path(string $path): ?array {
2610 + clearstatcache(true, $path);
2611 + $stat = @lstat($path);
2612 + if (!is_array($stat)) {
2613 + return null;
2614 + }
2615 + $type_bits = ( (int) ( $stat['mode'] ?? 0 ) ) & 0170000;
2616 + if ($type_bits === 0100000) {
2617 + $type = 'file';
2618 + } elseif ($type_bits === 0040000) {
2619 + $type = 'directory';
2620 + } elseif ($type_bits === 0120000) {
2621 + $type = 'symlink';
2622 + } else {
2623 + $type = 'other';
2624 + }
2625 + return [
2626 + 'type' => $type,
2627 + 'dev' => (int) ( $stat['dev'] ?? 0 ),
2628 + 'ino' => (int) ( $stat['ino'] ?? 0 ),
2629 + 'size' => (int) ( $stat['size'] ?? 0 ),
2630 + 'ctime' => (int) ( $stat['ctime'] ?? 0 ),
2631 + ];
2632 + }
2633 +
2634 + /**
2635 + * Removes one work private leaf or empty directory.
2636 + *
2637 + * A directory with descendants is a work ancestor directory for other paths
2638 + * and cannot be replaced by a different logical value. Files, symlinks, and
2639 + * other leaf-like entries are unlinked without following them.
2640 + *
2641 + * @param string $path Absolute private work path.
2642 + */
2643 + private function remove_work_path(string $path): void {
2644 + $identity = $this->lstat_path($path);
2645 + if ($identity === null) {
2646 + return;
2647 + }
2648 + if ($identity['type'] === 'directory') {
2649 + if ($this->first_directory_entry($path) !== null) {
2650 + throw new InvalidArgumentException('A work directory with descendants cannot be replaced by another logical value.');
2651 + }
2652 + if (!@rmdir($path)) {
2653 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove an empty private work directory.');
2654 + }
2655 + return;
2656 + }
2657 + if (!@unlink($path)) {
2658 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove a private work ' . $identity['type'] . '.');
2659 + }
2660 + }
2661 +
2662 + /**
2663 + * Returns the current size of a required regular file.
2664 + *
2665 + * The size is read through lstat_path() so the path is lstat() checked
2666 + * and symlinks are not followed. Missing files or non-files indicate corrupt
2667 + * push state.
2668 + *
2669 + * @param string $path Absolute file path.
2670 + * @return int Current byte size.
2671 + */
2672 + private function file_size(string $path): int {
2673 + $identity = $this->lstat_path($path);
2674 + if ($identity === null || $identity['type'] !== 'file') {
2675 + throw new Site_Export_Push_Exception(self::ERROR_CORRUPTED_PUSH_STATE, 'Expected a regular file at ' . $path . '.');
2676 + }
2677 + return $identity['size'];
2678 + }
2679 +
2680 + /**
2681 + * Creates or validates the directory shared by every push session.
2682 + *
2683 + * Create and remove both establish this directory before acquiring their
2684 + * shared lock. This lets an idempotent remove coordinate with a create even
2685 + * when no push session or tombstone currently exists.
2686 + *
2687 + * @param string $reprint_directory Canonical private reprint directory.
2688 + * @return string Canonical push sessions directory.
2689 + */
2690 + private static function create_push_sessions_directory(string $reprint_directory): string {
2691 + $push_sessions_directory = wp_join_unix_paths($reprint_directory, '.reprint', 'push');
2692 + if (!@mkdir($push_sessions_directory, 0700, true) && !is_dir($push_sessions_directory)) {
2693 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create push sessions directory ' . $push_sessions_directory . '.');
2694 + }
2695 + return self::require_directory($push_sessions_directory, 'push sessions', false);
2696 + }
2697 +
2698 + /**
2699 + * Acquires the cross-session lock for one create or bounded remove call.
2700 + *
2701 + * The lock covers creation and every bounded removal step so create cannot
2702 + * race a live-directory rename or an unfinished removal tombstone.
2703 + *
2704 + * @param string $push_sessions_directory Canonical push sessions directory.
2705 + * @param string $operation Current `create` or `remove` operation.
2706 + * @return resource Exclusively locked create/remove handle.
2707 + */
2708 + private static function acquire_create_remove_lock(string $push_sessions_directory, string $operation) {
2709 + $create_remove_lock = @fopen(wp_join_unix_paths($push_sessions_directory, 'create-remove.lock'), 'c+b');
2710 + if ($create_remove_lock === false) {
2711 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not open create-remove.lock for the ' . $operation . ' request.');
2712 + }
2713 + if (!flock($create_remove_lock, LOCK_EX | LOCK_NB)) {
2714 + fclose($create_remove_lock);
2715 + throw new Site_Export_Push_Exception(
2716 + self::ERROR_LOCK_ACQUISITION_FAILURE,
2717 + 'Another create or remove request holds create-remove.lock. Retry the ' . $operation . ' request.'
2718 + );
2719 + }
2720 + return $create_remove_lock;
2721 + }
2722 +
2723 + /**
2724 + * Advances bounded cleanup of a renamed remove tombstone.
2725 + *
2726 + * Remove first renames a push session so it is no longer addressable by its
2727 + * public ID. This method then removes at most REMOVE_ENTRY_LIMIT entries
2728 + * while holding the tombstone's own lock. Commit ownership is released from
2729 + * this resumable side of the rename before any push state is deleted.
2730 + *
2731 + * @param string $tombstone Absolute tombstone directory path.
2732 + * @return bool True when the tombstone is gone, false when work remains.
2733 + */
2734 + private function remove_tombstone(string $tombstone): bool {
2735 + if (!is_dir($tombstone)) {
2736 + return true;
2737 + }
2738 + $push_lock_path = wp_join_unix_paths($tombstone, 'push.lock');
2739 + $lock = @fopen($push_lock_path, 'r+b');
2740 + if ($lock === false) {
2741 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not open the push removal tombstone lock.');
2742 + }
2743 + try {
2744 + if (!flock($lock, LOCK_EX | LOCK_NB)) {
2745 + throw new Site_Export_Push_Exception(self::ERROR_LOCK_ACQUISITION_FAILURE, 'Push removal cleanup is busy. Retry remove.');
2746 + }
2747 + // The push directory rename is durable before commit ownership is released.
2748 + // Retry that release while the tombstone still preserves push state.
2749 + $this->release_commit_state();
2750 + $remaining_entries = self::REMOVE_ENTRY_LIMIT;
2751 + $empty = self::remove_directory_entries($tombstone, $remaining_entries, true);
2752 + if (!$empty) {
2753 + return false;
2754 + }
2755 + } finally {
2756 + flock($lock, LOCK_UN);
2757 + fclose($lock);
2758 + }
2759 + if (!@unlink($push_lock_path) || !@rmdir($tombstone)) {
2760 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove the completed push removal tombstone.');
2761 + }
2762 + return true;
2763 + }
2764 +
2765 + /**
2766 + * Returns one configured directory as a canonical real path.
2767 + *
2768 + * A newly created reprint directory uses mode 0700 deliberately. PHP's default
2769 + * 0777 mode, even after a typical umask, can expose work site contents to
2770 + * other system accounts. Existing configured directories keep their mode.
2771 + *
2772 + * @param string $path Absolute directory path from configuration.
2773 + * @param string $description Human-readable name for validation errors.
2774 + * @param bool $create Whether the directory may be created if missing.
2775 + * @return string Canonical absolute directory path without trailing slash.
2776 + */
2777 + private static function require_directory(string $path, string $description, bool $create): string {
2778 + if ($path === '' || $path[0] !== '/') {
2779 + throw new InvalidArgumentException('The ' . $description . ' must be an absolute directory; observed ' . json_encode($path) . '.');
2780 + }
2781 + if ($create && !is_dir($path) && !@mkdir($path, 0700, true) && !is_dir($path)) {
2782 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not create ' . $description . ' directory ' . $path . '.');
2783 + }
2784 + $real_path = realpath($path);
2785 + if ($real_path === false || !is_dir($real_path) || is_link($path)) {
2786 + throw new InvalidArgumentException('The ' . $description . ' is not a real directory: ' . $path . '.');
2787 + }
2788 + return trim_right_slash($real_path);
2789 + }
2790 +
2791 + /**
2792 + * Validates the public push session ID grammar.
2793 + *
2794 + * Push session IDs are used in URLs, directory names, lock files, and ownership
2795 + * comments. Restricting them to lowercase hexadecimal keeps those contexts
2796 + * unambiguous and avoids any path normalization concerns.
2797 + *
2798 + * @param string $push_session_id Caller-provided push session ID.
2799 + */
2800 + private static function require_push_session_id(string $push_session_id): void {
2801 + if (preg_match('/^[a-f0-9]{32}$/D', $push_session_id) !== 1) {
2802 + throw new InvalidArgumentException('Push session ID must be a 32-character lowercase hexadecimal string.');
2803 + }
2804 + }
2805 +
2806 + /**
2807 + * Removes a bounded number of entries from a remove directory tree.
2808 + *
2809 + * The counter is shared through recursive calls so one remove request has a
2810 + * hard work limit no matter how deeply nested the tombstone is. The top
2811 + * level may preserve its lock file until all other entries are gone.
2812 + *
2813 + * @param string $directory_path Absolute directory currently being drained.
2814 + * @param int $remaining_entries Remaining unlink/rmdir operations allowed.
2815 + * @param bool $preserve_lock Whether to keep a child named `lock`.
2816 + * @return bool True when this directory is empty enough to remove.
2817 + */
2818 + private static function remove_directory_entries(string $directory_path, int &$remaining_entries, bool $preserve_lock = false): bool {
2819 + $handle = @opendir($directory_path);
2820 + if ($handle === false) {
2821 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not read push removal directory: ' . $directory_path . '.');
2822 + }
2823 + try {
2824 + while (true) {
2825 + $entry = readdir($handle);
2826 + if ($entry === false) {
2827 + break;
2828 + }
2829 + if ($entry === '.' || $entry === '..' || ( $preserve_lock && $entry === 'push.lock' )) {
2830 + continue;
2831 + }
2832 + if ($remaining_entries === 0) {
2833 + return false;
2834 + }
2835 + $entry_path = wp_join_unix_paths($directory_path, $entry);
2836 + clearstatcache(true, $entry_path);
2837 + $stat = @lstat($entry_path);
2838 + if (!is_array($stat)) {
2839 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Work commit remove entry disappeared during cleanup: ' . $entry_path . '.');
2840 + }
2841 + $type = ( (int) ( $stat['mode'] ?? 0 ) ) & 0170000;
2842 + if ($type === 0040000) {
2843 + if (!self::remove_directory_entries($entry_path, $remaining_entries)) {
2844 + return false;
2845 + }
2846 + if ($remaining_entries === 0) {
2847 + return false;
2848 + }
2849 + if (!@rmdir($entry_path)) {
2850 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove push removal directory: ' . $entry_path . '.');
2851 + }
2852 + } elseif (!@unlink($entry_path)) {
2853 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove push removal entry: ' . $entry_path . '.');
2854 + }
2855 + --$remaining_entries;
2856 + }
2857 + } finally {
2858 + closedir($handle);
2859 + }
2860 + return true;
2861 + }
2862 +
2863 + /**
2864 + * Recursively removes a newly created private tree after setup failure.
2865 + *
2866 + * This is used only before a push session becomes usable, when cleanup should be
2867 + * immediate rather than bounded by remove semantics. It uses lstat() and
2868 + * unlink/rmdir so symlinks are removed as links and never traversed.
2869 + *
2870 + * @param string $path Absolute private path to remove if it exists.
2871 + */
2872 + private static function remove_tree(string $path): void {
2873 + clearstatcache(true, $path);
2874 + $stat = @lstat($path);
2875 + if (!is_array($stat)) {
2876 + return;
2877 + }
2878 + $type = ( (int) ( $stat['mode'] ?? 0 ) ) & 0170000;
2879 + if ($type === 0040000) {
2880 + $handle = @opendir($path);
2881 + if ($handle === false) {
2882 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not read push directory for removal: ' . $path . '.');
2883 + }
2884 + try {
2885 + while (true) {
2886 + $entry = readdir($handle);
2887 + if ($entry === false) {
2888 + break;
2889 + }
2890 + if ($entry !== '.' && $entry !== '..') {
2891 + self::remove_tree(wp_join_unix_paths($path, $entry));
2892 + }
2893 + }
2894 + } finally {
2895 + closedir($handle);
2896 + }
2897 + if (!@rmdir($path)) {
2898 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove push directory ' . $path . '.');
2899 + }
2900 + return;
2901 + }
2902 + if (!@unlink($path)) {
2903 + throw new Site_Export_Push_Exception(self::ERROR_FILESYSTEM, 'Could not remove push entry ' . $path . '.');
2904 + }
2905 + }
2906 +}