PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
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 13.7.2 13.8.3 All 506 releases
jetpack / vendor / wp-php-toolkit / reprint-server / src / class-file-tree-producer.php

class-file-tree-producer.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at vendor/wp-php-toolkit/reprint-server/src/class-file-tree-producer.php

675 lines 22.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
675