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