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