PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
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 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 All 503 releases
jetpack / vendor / wp-php-toolkit / reprint-server / src / class-push-session.php

class-push-session.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at vendor/wp-php-toolkit/reprint-server/src/class-push-session.php

2,907 lines 150.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
2907