PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | vendor/wp-php-toolkit/reprint-server/src/class-file-tree-producer.php +674 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,674 @@
1 +<?php
2 +
3 +namespace WordPress\Reprint\Server;
4 +
5 +require_once __DIR__ . '/utils.php';
6 +
7 +use InvalidArgumentException;
8 +
9 +/**
10 + * Streams a provided list of filesystem paths in sorted order with
11 + * cursor-based resumption. The caller must pass the same paths array
12 + * on each request when resuming from a cursor. Callers are responsible
13 + * for encoding cursors for transport (e.g. base64 for HTTP headers).
14 + */
15 +class FileTreeProducer
16 +{
17 + const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
18 +
19 + const PHASE_STREAMING = "streaming";
20 + const PHASE_FINISHED = "finished";
21 +
22 + /** @var array */
23 + private $directories;
24 + /** @var int */
25 + private $chunk_size;
26 + /** @var bool */
27 + private $index_only;
28 + /** @var string|null */
29 + private $filesystem_root;
30 +
31 + /** @var string */
32 + private $phase;
33 + /** @var array|null */
34 + private $current_chunk = null;
35 +
36 + /** Explicit list of paths to stream, sorted on first use. */
37 + /** @var array */
38 + private $paths;
39 + /** @var bool */
40 + private $paths_sorted = false;
41 + /** @var bool */
42 + private $paths_positioned = false;
43 + /** Ephemeral index into $paths; NOT stored in cursor. */
44 + /** @var int */
45 + private $paths_position = 0;
46 +
47 + /** State for the file currently being streamed in chunks. */
48 + private $streaming_file_handle = null;
49 +
50 + public function __destruct()
51 + {
52 + if ($this->streaming_file_handle !== null) {
53 + fclose($this->streaming_file_handle);
54 + $this->streaming_file_handle = null;
55 + }
56 + }
57 + /** @var int */
58 + private $streaming_file_offset = 0;
59 + /** @var array|null */
60 + private $current_file_meta = null;
61 +
62 + /** Tracks the last emitted path for cursor generation. */
63 + /** @var string|null */
64 + private $last_emitted_path = null;
65 + /** @var int|null */
66 + private $last_emitted_ctime = null;
67 +
68 + /**
69 + * @param string|array $directories Root directories to scan.
70 + * @param array $options {
71 + * @type int $chunk_size Bytes per file chunk (default 5MB).
72 + * @type bool $index_only Emit index entries instead of file contents.
73 + * @type string $cursor JSON cursor string for resumption.
74 + * @type array $paths Paths to stream (required).
75 + * }
76 + */
77 + public function __construct($directories, array $options = [])
78 + {
79 + $this->directories = $this->normalize_directories($directories);
80 + $this->chunk_size = $options["chunk_size"] ?? self::DEFAULT_CHUNK_SIZE;
81 + $this->index_only = $options["index_only"] ?? false;
82 +
83 + if (!isset($options["paths"]) || !is_array($options["paths"])) {
84 + throw new InvalidArgumentException(
85 + "The 'paths' option is required and must be an array"
86 + );
87 + }
88 + $this->paths = $options["paths"];
89 +
90 + if (isset($options["cursor"])) {
91 + $this->initialize_from_cursor($options["cursor"]);
92 + } else {
93 + $this->initialize_new();
94 + }
95 + }
96 +
97 + /**
98 + * Sets up a fresh traversal from the beginning.
99 + */
100 + private function initialize_new(): void
101 + {
102 + $this->phase = self::PHASE_STREAMING;
103 + $dirs = $this->directories;
104 + sort($dirs, SORT_STRING);
105 + $this->filesystem_root = $dirs[0] ?? "/";
106 +
107 + $this->current_chunk = null;
108 + $this->streaming_file_handle = null;
109 + $this->streaming_file_offset = 0;
110 + $this->current_file_meta = null;
111 + $this->last_emitted_path = null;
112 + $this->last_emitted_ctime = null;
113 + $this->paths_sorted = false;
114 + $this->paths_positioned = false;
115 + $this->paths_position = 0;
116 + }
117 +
118 + /**
119 + * Restores producer state from a JSON cursor string.
120 + *
121 + * Cursor format is minimal: (path, ctime, byte_offset)
122 + * - path: the file/dir/symlink we were processing or just finished
123 + * - ctime: the ctime of the file when we started (for change detection)
124 + * - bytes: byte offset within the file (0 if finished or non-file)
125 + *
126 + * On resume, position within the paths array is determined by binary
127 + * search based on the path. This ensures correctness even when the
128 + * paths array changes between requests.
129 + */
130 + private function initialize_from_cursor(string $cursor_json): void
131 + {
132 + $cursor = json_decode($cursor_json, true);
133 + if ($cursor === null && json_last_error() !== JSON_ERROR_NONE) {
134 + throw new InvalidArgumentException(
135 + "Invalid cursor format: " . json_last_error_msg()
136 + );
137 + }
138 +
139 + $this->phase = $cursor["phase"] ?? self::PHASE_STREAMING;
140 + $this->filesystem_root = isset($cursor["root"])
141 + ? base64_decode($cursor["root"])
142 + : ($this->directories[0] ?? "/");
143 + $this->current_chunk = null;
144 + $this->streaming_file_handle = null;
145 + $this->paths_sorted = false;
146 + $this->paths_positioned = false;
147 + $this->paths_position = 0;
148 +
149 + if ($this->phase !== self::PHASE_STREAMING) {
150 + $this->phase = self::PHASE_FINISHED;
151 + return;
152 + }
153 +
154 + $path = isset($cursor["path"]) ? base64_decode($cursor["path"]) : null;
155 + $ctime = $cursor["ctime"] ?? null;
156 + $byte_offset = $cursor["bytes"] ?? 0;
157 +
158 + $this->last_emitted_path = null;
159 + $this->last_emitted_ctime = null;
160 +
161 + if ($path !== null && $byte_offset > 0) {
162 + // Resuming mid-file.
163 + clearstatcache(true, $path);
164 + $size = @filesize($path);
165 + if ($size === false) {
166 + // File disappeared; treat as completed.
167 + $this->current_file_meta = null;
168 + $this->streaming_file_offset = 0;
169 + $this->last_emitted_path = $path;
170 + } else {
171 + $this->current_file_meta = [
172 + "path" => $path,
173 + "ctime" => $ctime,
174 + "size" => $size,
175 + ];
176 + $this->streaming_file_offset = $byte_offset;
177 + $this->last_emitted_path = $path;
178 + }
179 + } else {
180 + $this->current_file_meta = null;
181 + $this->streaming_file_offset = 0;
182 + $this->last_emitted_path = $path;
183 + }
184 + // Position within paths array will be resolved by binary search
185 + // when get_next_path_entry() is first called
186 + }
187 +
188 + /** @param string|array $directories */
189 + private function normalize_directories($directories): array
190 + {
191 + if (is_string($directories)) {
192 + return [trim_right_slash($directories)];
193 + }
194 + return array_map(function ($d) {
195 + return trim_right_slash($d);
196 + }, $directories);
197 + }
198 +
199 + /**
200 + * Advances to the next chunk. Returns false when finished.
201 + */
202 + public function next_chunk(): bool
203 + {
204 + if ($this->phase === self::PHASE_FINISHED) {
205 + return false;
206 + }
207 +
208 + $this->stream_step();
209 + return $this->phase !== self::PHASE_FINISHED;
210 + }
211 +
212 + /**
213 + * Produces the next chunk: file data, index entry, directory, or symlink.
214 + */
215 + private function stream_step(): void
216 + {
217 + if ($this->current_file_meta !== null) {
218 + $this->stream_file_chunk($this->current_file_meta);
219 + return;
220 + }
221 +
222 + while (true) {
223 + // get_next_server_file() may set current_chunk directly for
224 + // symlinks and directories, so clear it before each iteration.
225 + $this->current_chunk = null;
226 +
227 + $server_file = $this->get_next_server_file();
228 +
229 + if ($this->current_chunk !== null) {
230 + return;
231 + }
232 +
233 + if ($server_file === null) {
234 + $this->phase = self::PHASE_FINISHED;
235 + $this->current_chunk = null;
236 + return;
237 + }
238 +
239 + if ($this->index_only) {
240 + $this->emit_index_chunk($server_file);
241 + return;
242 + }
243 +
244 + $this->stream_file_chunk($server_file);
245 + return;
246 + }
247 + }
248 +
249 + /**
250 + * Emits an index entry chunk without streaming file contents.
251 + */
252 + private function emit_index_chunk(array $file): void
253 + {
254 + $this->current_chunk = [
255 + "type" => "index",
256 + "path" => $file["path"],
257 + "ctime" => $file["ctime"],
258 + "size" => $file["size"],
259 + ];
260 + $this->last_emitted_path = $file["path"];
261 + $this->last_emitted_ctime = $file["ctime"];
262 + $this->current_file_meta = null;
263 + }
264 +
265 + /**
266 + * Returns the next file entry, or sets current_chunk for non-file entries
267 + * (symlinks, directories, missing paths) and returns null.
268 + */
269 + private function get_next_server_file(): ?array
270 + {
271 + return $this->get_next_path_entry();
272 + }
273 +
274 + /**
275 + * Returns the next entry from the paths array.
276 + *
277 + * Paths are sorted on first access. Position is determined by binary
278 + * search based on last_emitted_path, not by a stored index. This ensures
279 + * correctness even when the paths array changes between requests.
280 + */
281 + private function get_next_path_entry(): ?array
282 + {
283 + if (!$this->paths_sorted) {
284 + sort($this->paths, SORT_STRING);
285 + $this->paths_sorted = true;
286 + }
287 +
288 + if (!$this->paths_positioned) {
289 + if ($this->last_emitted_path !== null) {
290 + $this->paths_position = $this->binary_search_next(
291 + $this->paths,
292 + $this->last_emitted_path
293 + );
294 + } else {
295 + $this->paths_position = 0;
296 + }
297 + $this->paths_positioned = true;
298 + }
299 +
300 + while ($this->paths_position < count($this->paths)) {
301 + $path = $this->paths[$this->paths_position];
302 + $this->paths_position++;
303 +
304 + $resolved_path = $this->resolve_path($path);
305 + if ($resolved_path === null) {
306 + // Path doesn't exist or isn't accessible, emit as missing
307 + $this->last_emitted_path = $path;
308 + $this->last_emitted_ctime = null;
309 + $this->current_chunk = [
310 + "type" => "missing",
311 + "path" => $path,
312 + ];
313 + return null;
314 + }
315 +
316 + $info = $this->lstat_path($resolved_path);
317 + if ($info === null) {
318 + continue;
319 + }
320 +
321 + if ($info["type"] === "link") {
322 + $target = @readlink($resolved_path);
323 + $this->last_emitted_path = $resolved_path;
324 + $this->last_emitted_ctime = $info["ctime"];
325 + $this->current_chunk = [
326 + "type" => "symlink",
327 + "path" => $resolved_path,
328 + "target" => $target !== false ? $target : "",
329 + "ctime" => $info["ctime"] ?? 0,
330 + ];
331 + return null;
332 + }
333 +
334 + if ($info["type"] === "dir") {
335 + $this->last_emitted_path = $resolved_path;
336 + $this->last_emitted_ctime = $info["ctime"] ?? null;
337 + $this->current_chunk = [
338 + "type" => "directory",
339 + "path" => $resolved_path,
340 + "ctime" => $info["ctime"] ?? 0,
341 + ];
342 + return null;
343 + }
344 +
345 + if ($info["type"] === "file") {
346 + $ctime = $info["ctime"];
347 + $size = $info["size"];
348 + if ($ctime === null || $size === null) {
349 + continue;
350 + }
351 + $this->current_file_meta = [
352 + "path" => $resolved_path,
353 + "ctime" => $ctime,
354 + "size" => $size,
355 + ];
356 + $this->streaming_file_offset = 0;
357 + return $this->current_file_meta;
358 + }
359 + }
360 +
361 + return null;
362 + }
363 +
364 + /**
365 + * Resolves a path that might be relative to one of the root directories.
366 + *
367 + * Uses both file_exists() and is_link() because file_exists() follows
368 + * symlinks and returns false for broken symlinks, but the symlink
369 + * itself is still a valid filesystem entry we want to stream.
370 + */
371 + private function resolve_path(string $path): ?string
372 + {
373 + if ($path === "") {
374 + return null;
375 + }
376 +
377 + clearstatcache(true, $path);
378 + if ($path[0] === "/" && (file_exists($path) || is_link($path))) {
379 + return $path;
380 + }
381 +
382 + foreach ($this->directories as $dir) {
383 + $candidate = wp_join_unix_paths($dir, $path);
384 + clearstatcache(true, $candidate);
385 + if (file_exists($candidate) || is_link($candidate)) {
386 + return $candidate;
387 + }
388 + }
389 +
390 + if ($path[0] === "/") {
391 + return null;
392 + }
393 +
394 + return null;
395 + }
396 +
397 + /**
398 + * Reads the next chunk from the current file and performs post-read
399 + * change detection via ctime comparison.
400 + */
401 + private function stream_file_chunk(array $file): void
402 + {
403 + if ($this->streaming_file_handle === null) {
404 + clearstatcache(true, $file["path"]);
405 + $pre_stat = @lstat($file["path"]);
406 + if ($pre_stat === false || (($pre_stat["mode"] & 0170000) !== 0100000)) {
407 + $this->streaming_file_handle = null;
408 + $this->current_file_meta = null;
409 + $this->current_chunk = [
410 + "type" => "error",
411 + "error_type" => $pre_stat === false ? "file_missing" : "file_changed",
412 + "path" => $file["path"],
413 + "message" => $pre_stat === false
414 + ? "File disappeared before read"
415 + : "Path is no longer a regular file",
416 + ];
417 + $this->last_emitted_path = $file["path"];
418 + $this->last_emitted_ctime = $file["ctime"];
419 + return;
420 + }
421 +
422 + $this->streaming_file_handle = @fopen($file["path"], "r");
423 + if (!$this->streaming_file_handle) {
424 + $this->streaming_file_handle = null;
425 + $this->current_file_meta = null;
426 + $this->current_chunk = [
427 + "type" => "error",
428 + "error_type" => "file_open",
429 + "path" => $file["path"],
430 + "message" => "Failed to open file",
431 + ];
432 + $this->last_emitted_path = $file["path"];
433 + $this->last_emitted_ctime = $file["ctime"];
434 + return;
435 + }
436 + if ($this->streaming_file_offset > 0) {
437 + $seek_result = fseek(
438 + $this->streaming_file_handle,
439 + $this->streaming_file_offset
440 + );
441 + if ($seek_result === -1) {
442 + fclose($this->streaming_file_handle);
443 + $this->streaming_file_handle = null;
444 + $this->current_file_meta = null;
445 + $this->current_chunk = [
446 + "type" => "error",
447 + "error_type" => "file_seek",
448 + "path" => $file["path"],
449 + "message" => "Failed to seek to offset {$this->streaming_file_offset}",
450 + ];
451 + $this->last_emitted_path = $file["path"];
452 + $this->last_emitted_ctime = $file["ctime"];
453 + $this->streaming_file_offset = 0;
454 + return;
455 + }
456 + }
457 + }
458 +
459 + $data = fread($this->streaming_file_handle, $this->chunk_size);
460 + if (false === $data || ("" === $data && $file["size"] !== 0)) {
461 + fclose($this->streaming_file_handle);
462 + $this->streaming_file_handle = null;
463 + $this->streaming_file_offset = 0;
464 + $this->last_emitted_path = $file["path"];
465 + $this->last_emitted_ctime = $file["ctime"];
466 + $this->current_file_meta = null;
467 + $this->current_chunk = [
468 + "type" => "error",
469 + "error_type" => "file_read",
470 + "path" => $file["path"],
471 + "message" => "Failed to read file",
472 + ];
473 + return;
474 + }
475 +
476 + $offset = $this->streaming_file_offset;
477 + $this->streaming_file_offset += strlen($data);
478 +
479 + $is_first = $offset === 0;
480 + $is_last = feof($this->streaming_file_handle);
481 +
482 + $changed = false;
483 + $change_ctime = null;
484 + $change_size = null;
485 + $error_type = "file_changed";
486 +
487 + // Detect whether the file changed while we were reading it.
488 + clearstatcache(true, $file["path"]);
489 + $stat = @stat($file["path"]);
490 + if ($stat === false) {
491 + $changed = true;
492 + $error_type = "file_missing";
493 + } else {
494 + $now_ctime = $stat["ctime"];
495 + if ($now_ctime !== $file["ctime"]) {
496 + $changed = true;
497 + $change_ctime = $now_ctime;
498 + }
499 + }
500 +
501 + if ($changed) {
502 + fclose($this->streaming_file_handle);
503 + $this->streaming_file_handle = null;
504 + $this->streaming_file_offset = 0;
505 + $this->last_emitted_path = $file["path"];
506 + $this->last_emitted_ctime = $file["ctime"];
507 + $this->current_file_meta = null;
508 + $this->current_chunk = [
509 + "type" => "error",
510 + "error_type" => $error_type,
511 + "path" => $file["path"],
512 + "message" =>
513 + $error_type === "file_missing"
514 + ? "File disappeared during stream"
515 + : "File changed during stream",
516 + "expected_ctime" => $file["ctime"],
517 + "actual_ctime" => $change_ctime,
518 + ];
519 + return;
520 + }
521 +
522 + $this->current_chunk = [
523 + "type" => "file",
524 + "path" => $file["path"],
525 + "data" => $data,
526 + "size" => $file["size"],
527 + "ctime" => $file["ctime"],
528 + "offset" => $offset,
529 + "is_first_chunk" => $is_first,
530 + "is_last_chunk" => $is_last,
531 + "file_changed" => $changed,
532 + "change_ctime" => $change_ctime,
533 + "change_size" => $change_size,
534 + ];
535 +
536 + if ($is_last) {
537 + fclose($this->streaming_file_handle);
538 + $this->streaming_file_handle = null;
539 + $this->streaming_file_offset = 0;
540 + $this->last_emitted_path = $file["path"];
541 + $this->last_emitted_ctime = $file["ctime"];
542 + $this->current_file_meta = null;
543 + }
544 + }
545 +
546 + /**
547 + * Returns the chunk produced by the last call to next_chunk().
548 + */
549 + public function get_current_chunk(): ?array
550 + {
551 + return $this->current_chunk;
552 + }
553 +
554 + /**
555 + * Serializes state into a JSON cursor string.
556 + *
557 + * Cursor format is minimal: (path, ctime, byte_offset)
558 + * - path: last emitted path, or current file being streamed
559 + * - ctime: ctime of file when we started reading (for change detection)
560 + * - bytes: byte offset within the current file (0 if not mid-file)
561 + *
562 + * No traversal stack or list indices are stored. On resume, position is
563 + * determined by binary search based on the path. This ensures correctness
564 + * even when the filesystem changes between requests.
565 + */
566 + public function get_reentrancy_cursor(): string
567 + {
568 + if ($this->phase === self::PHASE_FINISHED) {
569 + return json_encode([
570 + "phase" => self::PHASE_FINISHED,
571 + "root" => base64_encode($this->filesystem_root),
572 + ]);
573 + }
574 +
575 + $cursor = [
576 + "phase" => $this->phase,
577 + "root" => base64_encode($this->filesystem_root),
578 + ];
579 +
580 + if ($this->current_file_meta !== null) {
581 + $cursor["path"] = base64_encode($this->current_file_meta["path"]);
582 + $cursor["ctime"] = $this->current_file_meta["ctime"];
583 + $cursor["bytes"] = $this->streaming_file_offset;
584 + } else if ($this->last_emitted_path !== null) {
585 + $cursor["path"] = base64_encode($this->last_emitted_path);
586 + $cursor["ctime"] = $this->last_emitted_ctime;
587 + $cursor["bytes"] = 0;
588 + }
589 + return json_encode($cursor);
590 + }
591 +
592 + /**
593 + * Returns progress metadata for logging and UI updates.
594 + */
595 + public function get_progress(): array
596 + {
597 + $progress = [
598 + "phase" => $this->phase,
599 + ];
600 +
601 + if ($this->phase === self::PHASE_STREAMING) {
602 + if ($this->last_emitted_path !== null) {
603 + $progress["last_path"] = base64_encode($this->last_emitted_path);
604 + }
605 + if ($this->current_file_meta) {
606 + $file = $this->current_file_meta;
607 + $progress["current_file"] = [
608 + "path" => base64_encode($file["path"]),
609 + "size" => $file["size"],
610 + "bytes_read" => $this->streaming_file_offset,
611 + ];
612 + }
613 + }
614 +
615 + return $progress;
616 + }
617 +
618 + /**
619 + * Returns the filesystem root path.
620 + */
621 + public function get_filesystem_root(): ?string
622 + {
623 + return $this->filesystem_root;
624 + }
625 +
626 + /**
627 + * Returns the index of the first entry strictly greater than $last
628 + * in a sorted array, using binary search.
629 + */
630 + private function binary_search_next(array $entries, string $last): int
631 + {
632 + $low = 0;
633 + $high = count($entries);
634 + while ($low < $high) {
635 + $mid = integer_divide($low + $high, 2);
636 + if (strcmp($entries[$mid], $last) <= 0) {
637 + $low = $mid + 1;
638 + } else {
639 + $high = $mid;
640 + }
641 + }
642 + return $low;
643 + }
644 +
645 + /**
646 + * Classifies a path as file, dir, link, or other via a single lstat() call.
647 + *
648 + * @return array|null {type: 'file'|'dir'|'link'|'other', ctime: ?int, size: ?int}
649 + */
650 + private function lstat_path(string $path): ?array
651 + {
652 + clearstatcache(true, $path);
653 + $stat = @lstat($path);
654 + if ($stat === false) {
655 + return null;
656 + }
657 +
658 + $mode = $stat["mode"] & 0170000;
659 + $type = "other";
660 + if ($mode === 0120000) {
661 + $type = "link";
662 + } elseif ($mode === 0040000) {
663 + $type = "dir";
664 + } elseif ($mode === 0100000) {
665 + $type = "file";
666 + }
667 +
668 + return [
669 + "type" => $type,
670 + "ctime" => isset($stat["ctime"]) ? (int) $stat["ctime"] : null,
671 + "size" => isset($stat["size"]) ? (int) $stat["size"] : null,
672 + ];
673 + }
674 +}