PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 All 503 releases
jetpack / vendor / wp-php-toolkit / reprint-server / src / class-file-index-processor.php

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

1,150 lines 46.3 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 use LogicException;
9
10 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Traversal failures become API or CLI values, never HTML output.
11
12 /**
13 * Walks filesystem paths for the file index one resumable step at a time.
14 *
15 * The processor owns directory traversal and path inspection. Callers own
16 * what happens to the resulting index entries: the HTTP endpoint batches and
17 * encodes them, while local push indexing will write them to its JSONL file.
18 *
19 * A cursor contains the active directory stack and the last settled name in
20 * each directory. Reopening from that cursor rescans only the active
21 * directories and continues after those names. Directory names remain sorted
22 * exactly as they were in endpoint_file_index() before this extraction.
23 *
24 * One step either produces zero or more index entries for one filesystem path,
25 * skips one path, reports one directory failure, or finishes one directory.
26 * Following a symlink may produce additional intermediate-symlink entries in
27 * the same step because they share the path's cursor boundary.
28 *
29 * Directory names are still read with scandir(). This deliberately preserves
30 * the endpoint's established ordering and cursor behavior. It also means one
31 * unusually wide directory is held in memory; changing that requires a
32 * separate traversal design rather than hiding it inside this extraction.
33 *
34 * @phpstan-type FileIndexRoot (
35 * array{requested_path:string,resolved_path:string,type:'directory'|'file'|'symlink'}
36 * | array{requested_path:string,resolved_path:null,type:'missing'}
37 * )
38 */
39 final class FileIndexProcessor {
40
41 const STATUS_INDEXED = "indexed";
42 const STATUS_SKIPPED = "skipped";
43 const STATUS_PATH_UNAVAILABLE = "path_unavailable";
44 const STATUS_DIRECTORY_COMPLETE = "directory_complete";
45 const STATUS_DIRECTORY_ERROR = "directory_error";
46
47 const STAT_TYPE_MASK = 0170000;
48 const STAT_TYPE_LINK = 0120000;
49 const STAT_TYPE_FILE = 0100000;
50 const STAT_TYPE_DIR = 0040000;
51
52 /** @var array[] Configured file-index roots, in requested order. */
53 private $roots;
54
55 /** @var string[] Canonical directories selected by the request. */
56 private $configured_directories;
57
58 /** @var bool Whether directory symlinks may lead outside the allowed directories. */
59 private $follow_symlinks;
60
61 /** @var bool Whether generated caches and development files are included. */
62 private $include_caches;
63
64 /** @var string Canonical Reprint storage path omitted from the index, or an empty string. */
65 private $storage_path;
66
67 /** @var array[] Active directory stack, from scheduled roots to the current directory. */
68 private $directory_stack;
69
70 /** @var string Directory reported as X-Index-Dir for this traversal. */
71 private $index_directory;
72
73 /** @var array[] Intermediate symlinks emitted before a new traversal begins. */
74 private $initial_index_entries;
75
76 /** @var string[] Requested named roots still to index, one per step. */
77 private $pending_named_roots = [];
78
79 /** @var string[]|null Sorted names in the current directory. */
80 private $current_directory_names = null;
81
82 /** @var int Position of the next name in $current_directory_names. */
83 private $current_directory_position = 0;
84
85 /** @var string|null Current directory used for endpoint exception reporting. */
86 private $current_directory = null;
87
88 /** @var string|null Result of the most recent step. */
89 private $step_status = null;
90
91 /** @var array[] Entries produced by the most recent indexed step. */
92 private $index_entries = [];
93
94 /** @var array|null Directory failure produced by the most recent step. */
95 private $directory_error = null;
96
97 /** @var bool Whether close() has been called. */
98 private $closed = false;
99
100 /**
101 * Starts a traversal at the requested root and schedules the other roots.
102 *
103 * @param FileIndexRoot[] $roots Structured roots scheduled for this index.
104 * @param FileIndexRoot $start_root Root scheduled first. It may be an
105 * external directory reached by a followed link.
106 * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories.
107 * @param bool $include_caches Whether generated caches and development files are included.
108 * @param string $storage_path Reprint storage path omitted from the index, or an empty string.
109 * @return self New file-index processor.
110 */
111 public static function start(
112 array $roots,
113 array $start_root,
114 bool $follow_symlinks,
115 bool $include_caches,
116 string $storage_path
117 ): self {
118 $roots = self::validate_roots($roots);
119 $start_root = self::validate_root($start_root);
120 $start_root_is_configured = false;
121 foreach ($roots as $root) {
122 if ($root["requested_path"] === $start_root["requested_path"]) {
123 $start_root_is_configured = true;
124 break;
125 }
126 }
127 if (!$start_root_is_configured && $start_root["type"] !== "directory") {
128 throw new InvalidArgumentException(
129 "File-index start root must be a configured root or a directory: {$start_root["requested_path"]}"
130 );
131 }
132
133 $configured_directories = self::resolved_directory_roots($roots, $follow_symlinks);
134
135 // Visit the requested directory first, followed by every other root in
136 // stable byte order. Stable ordering makes a cursor independent of the
137 // order in which configuration discovered the additional roots.
138 $ordered_roots = [$start_root];
139 $extra_roots = [];
140 foreach ($roots as $root) {
141 if ($root["requested_path"] !== $start_root["requested_path"]) {
142 $extra_roots[] = $root;
143 }
144 }
145 usort($extra_roots, static function (array $left, array $right): int {
146 return strcmp($left["requested_path"], $right["requested_path"]);
147 });
148 $ordered_roots = array_merge($ordered_roots, $extra_roots);
149
150 // A selected directory symlink has two responsibilities: emit its
151 // requested link entry and traverse its resolved target. Keep the
152 // two work lists separate so each follows its own coordinate.
153 $traversal_directories = self::resolved_directory_roots($ordered_roots, $follow_symlinks);
154 $pending_named_roots = [];
155 foreach ($ordered_roots as $root) {
156 if ($root["type"] !== "directory") {
157 $pending_named_roots[] = $root["requested_path"];
158 }
159 }
160
161 // The last stack element is visited next, so reverse the desired order
162 // while constructing the depth-first traversal stack.
163 $directory_stack = [];
164 for ($i = count($traversal_directories) - 1; $i >= 0; $i--) {
165 $directory_stack[] = [
166 "dir" => $traversal_directories[$i],
167 "after" => null,
168 ];
169 }
170
171 // Keep parent-link discovery as the first traversal event. This
172 // preserves the endpoint's established ordering: any link entries
173 // found here must precede ordinary directory entries.
174 $initial_index_entries = [];
175 if ($follow_symlinks) {
176 foreach ($ordered_roots as $root) {
177 if ($root["type"] === "directory") {
178 $initial_index_entries = array_merge(
179 $initial_index_entries,
180 self::find_parent_symlinks($root["requested_path"])
181 );
182 }
183 }
184 }
185
186 // X-Index-Dir names a directory, so a named path reports its parent.
187 $reported_index_directory = $start_root["type"] === "directory"
188 ? $start_root["resolved_path"]
189 : dirname($start_root["requested_path"]);
190
191 return new self(
192 $roots,
193 $configured_directories,
194 $follow_symlinks,
195 $include_caches,
196 $storage_path,
197 $directory_stack,
198 $reported_index_directory,
199 $initial_index_entries,
200 $pending_named_roots
201 );
202 }
203
204 /**
205 * Resumes traversal from a cursor returned by get_cursor().
206 *
207 * @param FileIndexRoot[] $roots Structured roots scheduled for this index.
208 * @param string $cursor_json JSON cursor returned by the preceding request.
209 * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories.
210 * @param bool $include_caches Whether generated caches and development files are included.
211 * @param string $storage_path Reprint storage path omitted from the index, or an empty string.
212 * @return self Resumed file-index processor.
213 */
214 public static function resume(
215 array $roots,
216 string $cursor_json,
217 bool $follow_symlinks,
218 bool $include_caches,
219 string $storage_path
220 ): self {
221 $roots = self::validate_roots($roots);
222 $configured_directories = self::resolved_directory_roots($roots, $follow_symlinks);
223
224 // A cursor is caller-held continuation state. Reject malformed JSON or
225 // a missing stack before any filesystem work begins.
226 $cursor = json_decode($cursor_json, true);
227 if (!is_array($cursor)) {
228 throw new InvalidArgumentException("Invalid index cursor format");
229 }
230 if (!isset($cursor["stack"]) || !is_array($cursor["stack"])) {
231 throw new InvalidArgumentException("Index cursor missing stack");
232 }
233
234 // Paths are base64 text because filesystem names are arbitrary bytes
235 // while JSON strings must be valid UTF-8. Decode each frame back into
236 // the in-memory stack used by traversal.
237 $directory_stack = [];
238 foreach ($cursor["stack"] as $frame) {
239 if (!is_array($frame)) {
240 throw new InvalidArgumentException("Invalid index cursor frame");
241 }
242 $encoded_directory = isset($frame["dir"]) ? $frame["dir"] : null;
243 if (!is_string($encoded_directory) || $encoded_directory === "") {
244 throw new InvalidArgumentException("Index cursor frame missing dir");
245 }
246 $directory = base64_decode($encoded_directory, true);
247 if ($directory === false || $directory === "") {
248 throw new InvalidArgumentException("Index cursor frame has invalid dir encoding");
249 }
250
251 $encoded_after = array_key_exists("after", $frame) ? $frame["after"] : null;
252 if ($encoded_after !== null && !is_string($encoded_after)) {
253 throw new InvalidArgumentException("Index cursor frame invalid after");
254 }
255 $after = null;
256 if ($encoded_after !== null) {
257 $after = base64_decode($encoded_after, true);
258 if ($after === false) {
259 throw new InvalidArgumentException("Index cursor frame has invalid after encoding");
260 }
261 }
262
263 $directory_stack[] = [
264 "dir" => $directory,
265 "after" => $after,
266 ];
267 }
268
269 // Absent from cursors written before this field existed.
270 $pending_named_roots = [];
271 $encoded_path_roots = isset($cursor["paths"]) ? $cursor["paths"] : [];
272 if (!is_array($encoded_path_roots)) {
273 throw new InvalidArgumentException("Index cursor paths must be an array");
274 }
275 foreach ($encoded_path_roots as $encoded_path_root) {
276 if (!is_string($encoded_path_root) || $encoded_path_root === "") {
277 throw new InvalidArgumentException("Index cursor path entry must be a non-empty string");
278 }
279 $path_root = base64_decode($encoded_path_root, true);
280 if ($path_root === false || $path_root === "") {
281 throw new InvalidArgumentException("Index cursor path entry has invalid encoding");
282 }
283 $pending_named_roots[] = $path_root;
284 }
285
286 // During continuation, the active directory is the best description
287 // of what this request is indexing. A completed cursor has no active
288 // directory, so it falls back to the first configured root.
289 $index_directory = !empty($directory_stack)
290 ? $directory_stack[count($directory_stack) - 1]["dir"]
291 : ( isset($configured_directories[0]) ? $configured_directories[0] : "/" );
292
293 return new self(
294 $roots,
295 $configured_directories,
296 $follow_symlinks,
297 $include_caches,
298 $storage_path,
299 $directory_stack,
300 $index_directory,
301 [],
302 $pending_named_roots
303 );
304 }
305
306 /**
307 * Performs one traversal step.
308 *
309 * @return bool Whether a current step is available. False means traversal is complete.
310 */
311 public function next_index_step(): bool
312 {
313 // A closed processor has discarded its retained directory names and
314 // cannot safely take another step.
315 if ($this->closed) {
316 throw new LogicException("Cannot take a file-index step after close().");
317 }
318
319 // Step accessors describe only the current call. Clear the preceding
320 // result before deciding which traversal event comes next.
321 $this->step_status = null;
322 $this->index_entries = [];
323 $this->directory_error = null;
324
325 // Emit the parent links discovered during start() before descendants.
326 // They share one cursor boundary because traversal has not begun yet.
327 if (!empty($this->initial_index_entries)) {
328 $this->step_status = self::STATUS_INDEXED;
329 $this->index_entries = $this->initial_index_entries;
330 $this->initial_index_entries = [];
331 return true;
332 }
333
334 // Index one selected named root before walking directories. This keeps
335 // each step bounded and makes its cursor boundary unambiguous.
336 if (!empty($this->pending_named_roots)) {
337 $this->index_next_named_root();
338 return true;
339 }
340
341 // Load the directory at the top of the stack only when no sorted name
342 // list is retained. A directory failure is itself a step; an empty
343 // stack means traversal has no further event.
344 if ($this->current_directory_names === null) {
345 if (!$this->open_current_directory()) {
346 return $this->step_status !== null;
347 }
348 }
349
350 // Finishing a directory is observable so callers may stop at this
351 // exact cursor before the processor returns to its parent directory.
352 if ($this->current_directory_position >= count($this->current_directory_names)) {
353 array_pop($this->directory_stack);
354 $this->forget_current_directory_names();
355 $this->step_status = self::STATUS_DIRECTORY_COMPLETE;
356 return true;
357 }
358
359 // Select exactly one name for this step. Move the cursor first so every
360 // later outcome, including omission or disappearance, settles the name.
361 $frame_index = count($this->directory_stack) - 1;
362 $entry_name = $this->current_directory_names[$this->current_directory_position];
363 ++$this->current_directory_position;
364 // Set "after" before any skip or stat call. The cursor must move past
365 // a cache path or a path that disappears between scandir() and lstat(),
366 // or every resumed request would inspect that same name again.
367 $this->directory_stack[$frame_index]["after"] = $entry_name;
368 $path = wp_join_unix_paths($this->current_directory, $entry_name);
369
370 // Apply omissions before lstat() and before a directory can enter the
371 // stack. Omitted subtrees therefore cost no extra filesystem calls.
372 if (!$this->include_caches && self::path_is_default_skipped($path)) {
373 $this->step_status = self::STATUS_SKIPPED;
374 return true;
375 }
376 if (
377 $this->storage_path !== ""
378 && \WordPress\Reprint\Server\path_is_same_as_or_descendant_of($path, $this->storage_path)
379 ) {
380 $this->step_status = self::STATUS_SKIPPED;
381 return true;
382 }
383
384 // A name returned by scandir() may disappear before inspection. Its
385 // cursor is already settled, so continuation moves to the next name.
386 clearstatcache(true, $path);
387 $stat = @lstat($path);
388 if ($stat === false) {
389 $this->step_status = self::STATUS_PATH_UNAVAILABLE;
390 return true;
391 }
392
393 $inspected_path = self::index_entries_for_path($path, $stat, $this->follow_symlinks);
394 $this->index_entries = $inspected_path["entries"];
395 $type = $inspected_path["type"];
396 $this->step_status = self::STATUS_INDEXED;
397
398 // Depth-first traversal enters a new directory before returning to the
399 // remaining names in its parent. An exact scheduled root is already
400 // on the stack, and traversing an ancestor would expose paths outside
401 // the requested tree before entering that root again.
402 if ($type === "dir") {
403 $canonical_directory = realpath($path);
404 if (
405 $canonical_directory === false
406 || !\WordPress\Reprint\Server\path_is_same_as_or_descendant_of($this->configured_directories, $canonical_directory)
407 ) {
408 $this->directory_stack[] = [
409 "dir" => $path,
410 "after" => null,
411 ];
412 $this->forget_current_directory_names();
413 }
414 }
415
416 return true;
417 }
418
419 /**
420 * Returns what the most recent step did.
421 *
422 * @return string|null One STATUS_* value, or null before the first step and after completion.
423 */
424 public function get_step_status()
425 {
426 return $this->step_status;
427 }
428
429 /**
430 * Returns entries produced by the most recent indexed step.
431 *
432 * @return array[] File-index entries, normally containing exactly one entry.
433 */
434 public function get_index_entries(): array
435 {
436 return $this->index_entries;
437 }
438
439 /**
440 * Returns the directory failure produced by the most recent step.
441 *
442 * @return array|null {
443 * Directory failure, or null when the current step did not report one.
444 *
445 * @type string $error_type Protocol error type.
446 * @type string $path Filesystem path that could not be traversed.
447 * @type string $message Human-readable explanation.
448 * }
449 */
450 public function get_directory_error()
451 {
452 return $this->directory_error;
453 }
454
455 /**
456 * Returns a JSON-safe cursor for the next traversal step.
457 *
458 * @return array {
459 * File-index cursor.
460 *
461 * @type array[] $stack Active directories with base64-encoded path names.
462 * @type string[] $paths Base64-encoded named paths not yet inspected.
463 * }
464 */
465 public function get_cursor(): array
466 {
467 $encoded_stack = [];
468 foreach ($this->directory_stack as $frame) {
469 $encoded_stack[] = [
470 "dir" => base64_encode($frame["dir"]),
471 "after" => $frame["after"] !== null ? base64_encode($frame["after"]) : null,
472 ];
473 }
474 $encoded_path_roots = [];
475 foreach ($this->pending_named_roots as $path_root) {
476 $encoded_path_roots[] = base64_encode($path_root);
477 }
478 return ["stack" => $encoded_stack, "paths" => $encoded_path_roots];
479 }
480
481 /**
482 * Returns the directory reported by the endpoint as X-Index-Dir.
483 *
484 * @return string Index directory for this traversal.
485 */
486 public function get_index_directory(): string
487 {
488 return $this->index_directory;
489 }
490
491 /**
492 * Returns the directory active during the most recent step.
493 *
494 * @return string|null Current directory, or null before traversal begins.
495 */
496 public function get_current_directory()
497 {
498 return $this->current_directory;
499 }
500
501 /**
502 * Releases in-memory directory data without performing another step.
503 */
504 public function close(): void
505 {
506 if ($this->closed) {
507 return;
508 }
509 $this->closed = true;
510 $this->initial_index_entries = [];
511 $this->forget_current_directory_names();
512 }
513
514 /**
515 * Reports whether a path belongs to the established default skip set.
516 *
517 * @param string $path Filesystem path to classify.
518 * @return bool Whether the path should be omitted unless caches are included.
519 */
520 public static function path_is_default_skipped(string $path): bool
521 {
522 // Sentinel slashes make component matches independent of whether the
523 // component appears at the beginning, middle, or end of the path.
524 $path_with_boundaries = "/" . trim($path, "/") . "/";
525
526 // These generated directories are limited to wp-content. A directory
527 // named cache elsewhere may contain user files and remains included.
528 // wpcomsh-cache is wp.com Atomic's filesystem cache shadow; wflogs is
529 // Wordfence request and scan data which can grow to gigabytes.
530 static $cache_directories = [
531 "/wp-content/cache/",
532 "/wp-content/upgrade/",
533 "/wp-content/wpcomsh-cache/",
534 "/wp-content/wflogs/",
535 ];
536 foreach ($cache_directories as $directory) {
537 if (strpos($path_with_boundaries, $directory) !== false) {
538 return true;
539 }
540 }
541
542 // Version-control metadata and local development dependencies match
543 // complete path components. Similar names such as cache-control or
544 // node_modules-backup remain included.
545 static $skipped_components = [
546 ".git", ".svn", ".hg", ".bzr",
547 "node_modules",
548 ".idea", ".vscode",
549 ".cache", ".npm", ".yarn", ".pnpm-store",
550 ];
551 foreach ($skipped_components as $component) {
552 if (strpos($path_with_boundaries, "/" . $component . "/") !== false) {
553 return true;
554 }
555 }
556
557 // Operating-system metadata matches only the basename.
558 $basename = basename($path);
559 static $skipped_basenames = [
560 ".DS_Store", "._.DS_Store",
561 "Thumbs.db", "desktop.ini", "ehthumbs.db",
562 ];
563 if (in_array($basename, $skipped_basenames, true)) {
564 return true;
565 }
566 // Editor and merge scratch files: Emacs locks and autosaves, trailing
567 // tildes, Vim swaps, backups, and conflict leftovers.
568 if ($basename !== "" && $basename[0] === "." && isset($basename[1]) && $basename[1] === "#") {
569 return true;
570 }
571 if (strlen($basename) >= 3 && $basename[0] === "#" && substr($basename, -1) === "#") {
572 return true;
573 }
574 if (preg_match("/(?:~|\\.(?:swp|swo|swn|bak|orig|rej))$/", $basename) === 1) {
575 return true;
576 }
577
578 return false;
579 }
580
581 /**
582 * Initializes common traversal state.
583 *
584 * @param array[] $roots Structured file-index roots.
585 * @param string[] $configured_directories Canonical directories selected by the request.
586 * @param bool $follow_symlinks Whether directory symlinks may leave the allowed directories.
587 * @param bool $include_caches Whether generated caches and development files are included.
588 * @param string $storage_path Reprint storage path omitted from the index, or an empty string.
589 * @param array[] $directory_stack Active directory stack.
590 * @param string $index_directory Directory reported by the endpoint.
591 * @param array[] $initial_index_entries Intermediate symlinks emitted before traversal.
592 * @param string[] $pending_named_roots Requested named roots still to index, one per step.
593 */
594 private function __construct(
595 array $roots,
596 array $configured_directories,
597 bool $follow_symlinks,
598 bool $include_caches,
599 string $storage_path,
600 array $directory_stack,
601 string $index_directory,
602 array $initial_index_entries,
603 array $pending_named_roots = []
604 ) {
605 $this->roots = $roots;
606 $this->configured_directories = $configured_directories;
607 $this->follow_symlinks = $follow_symlinks;
608 $this->include_caches = $include_caches;
609 $this->storage_path = self::canonical_storage_path($storage_path);
610 $this->directory_stack = $directory_stack;
611 $this->index_directory = $index_directory;
612 $this->initial_index_entries = $initial_index_entries;
613 $this->pending_named_roots = $pending_named_roots;
614 }
615
616 /**
617 * Opens and positions the directory at the top of the traversal stack.
618 *
619 * @return bool Whether directory entries are ready for the current step.
620 */
621 private function open_current_directory(): bool
622 {
623 // An empty stack is normal completion, not a directory failure.
624 if (empty($this->directory_stack)) {
625 return false;
626 }
627
628 // The top frame names the next directory and the last name settled in
629 // it. Keep the directory available for any failure reported this step.
630 $frame_index = count($this->directory_stack) - 1;
631 $frame = $this->directory_stack[$frame_index];
632 $this->current_directory = $frame["dir"];
633
634 // A directory may disappear while it waits on the stack. Remove that
635 // frame so a later call continues with its parent or the next root.
636 clearstatcache(true, $this->current_directory);
637 $canonical_directory = realpath($this->current_directory);
638 if ($canonical_directory === false || !is_dir($canonical_directory)) {
639 array_pop($this->directory_stack);
640 $this->directory_error = [
641 "error_type" => "dir_open",
642 "path" => $this->current_directory,
643 "message" => "Directory does not exist or is not accessible",
644 ];
645 $this->step_status = self::STATUS_DIRECTORY_ERROR;
646 return false;
647 }
648
649 // When following links is disabled, every canonical directory must
650 // remain inside a configured root. Reject one that crosses that
651 // boundary, then continue with the remaining stack.
652 if (
653 !$this->follow_symlinks
654 && !\WordPress\Reprint\Server\path_is_same_as_or_descendant_of($canonical_directory, $this->configured_directories)
655 ) {
656 array_pop($this->directory_stack);
657 $this->directory_error = [
658 "error_type" => "dir_outside_root",
659 "path" => $canonical_directory,
660 "message" => "Directory is outside allowed roots",
661 ];
662 $this->step_status = self::STATUS_DIRECTORY_ERROR;
663 return false;
664 }
665
666 // Canonical paths keep split roots and followed symlinks in one
667 // namespace, matching the endpoint's previous traversal.
668 $this->directory_stack[$frame_index]["dir"] = $canonical_directory;
669 $this->current_directory = $canonical_directory;
670
671 // scandir() supplies the stable byte order on which cursor resumption
672 // depends. Failure settles this directory rather than retrying it on
673 // every subsequent request.
674 clearstatcache(true, $canonical_directory);
675 $directory_names = @scandir($canonical_directory, SCANDIR_SORT_ASCENDING);
676 if ($directory_names === false) {
677 array_pop($this->directory_stack);
678 $this->directory_error = [
679 "error_type" => "dir_open",
680 "path" => $canonical_directory,
681 "message" => "Failed to open directory",
682 ];
683 $this->step_status = self::STATUS_DIRECTORY_ERROR;
684 return false;
685 }
686
687 // Tests may change the scanned names to exercise traversal boundaries.
688 // Production traversal has no hook and uses scandir() results directly.
689 if (getenv("SITE_EXPORT_TEST_MODE") && function_exists("_e2e_call_hook")) {
690 $hook_arguments = [$canonical_directory, &$directory_names];
691 _e2e_call_hook("test_hook_during_dir_scan", $hook_arguments);
692 }
693
694 // Remove the two navigation names, then seek past the last settled name.
695 // Binary search keeps continuation cheap for unusually wide directories.
696 $this->current_directory_names = [];
697 foreach ($directory_names as $directory_name) {
698 if ($directory_name !== "." && $directory_name !== "..") {
699 $this->current_directory_names[] = $directory_name;
700 }
701 }
702 $this->current_directory_position = 0;
703 $after = isset($frame["after"]) ? $frame["after"] : null;
704 if ($after !== null && $after !== "") {
705 $this->current_directory_position = self::position_after_name(
706 $this->current_directory_names,
707 $after
708 );
709 }
710
711 return true;
712 }
713
714 /**
715 * Drops the cached names for the current directory.
716 */
717 private function forget_current_directory_names(): void
718 {
719 $this->current_directory_names = null;
720 $this->current_directory_position = 0;
721 }
722
723 /**
724 * Returns a canonical storage path when the configured path exists.
725 *
726 * @param string $storage_path Configured Reprint storage path.
727 * @return string Canonical or normalized storage path, or an empty string.
728 */
729 private static function canonical_storage_path(string $storage_path): string
730 {
731 // Reprint storage may live inside the document root on hosts that can
732 // write nowhere else. It must never enter an index or a push could
733 // copy or delete its own work while using it. Traversal canonicalizes
734 // directories with realpath(), so the comparison uses the same form.
735 // rtrim() also prevents a trailing slash from missing an exact match.
736 $storage_path = rtrim($storage_path, "/");
737 if ($storage_path === "") {
738 return "";
739 }
740 $canonical_storage_path = realpath($storage_path);
741 return $canonical_storage_path !== false ? $canonical_storage_path : $storage_path;
742 }
743
744 /**
745 * Finds the first sorted directory name after a cursor name.
746 *
747 * @param string[] $directory_names Sorted directory names.
748 * @param string $after_name Last settled directory name.
749 * @return int Position of the next directory name.
750 */
751 private static function position_after_name(array $directory_names, string $after_name): int
752 {
753 $low = 0;
754 $high = count($directory_names);
755 while ($low < $high) {
756 $middle = (int) ( ( $low + $high ) / 2 );
757 if (strcmp($directory_names[$middle], $after_name) <= 0) {
758 $low = $middle + 1;
759 } else {
760 $high = $middle;
761 }
762 }
763 return $low;
764 }
765
766 /**
767 * Indexes one requested named root using traversal's exclusions.
768 */
769 private function index_next_named_root(): void
770 {
771 // Settle the cursor first so a skipped or vanished path is not retried.
772 $requested_path = array_shift($this->pending_named_roots);
773 $root = $this->find_root($requested_path);
774 if ($root === null) {
775 throw new InvalidArgumentException("Index cursor names a root absent from this request: {$requested_path}");
776 }
777
778 if ($root["type"] === "missing") {
779 $this->step_status = self::STATUS_PATH_UNAVAILABLE;
780 return;
781 }
782
783 $path_root = $root["requested_path"];
784
785 if (!$this->include_caches && self::path_is_default_skipped($path_root)) {
786 $this->step_status = self::STATUS_SKIPPED;
787 return;
788 }
789 if (
790 $this->storage_path !== ""
791 && \WordPress\Reprint\Server\path_is_same_as_or_descendant_of($path_root, $this->storage_path)
792 ) {
793 $this->step_status = self::STATUS_SKIPPED;
794 return;
795 }
796
797 clearstatcache(true, $path_root);
798 $stat = @lstat($path_root);
799 if ($stat === false) {
800 $this->step_status = self::STATUS_PATH_UNAVAILABLE;
801 return;
802 }
803
804 $entries = [];
805 if ($this->follow_symlinks) {
806 // Record links in the requested parent path. The inspected root may
807 // add links from its own symlink target, so keep both entry sets.
808 $entries = self::find_parent_symlinks(dirname($path_root));
809 }
810 $inspected_path = self::index_entries_for_path($path_root, $stat, $this->follow_symlinks);
811 $resolved_target_was_indexed = $root["resolved_path"] !== null
812 && $this->resolved_target_was_indexed($root);
813
814 // A selected symlink always remains at its requested path. When
815 // followed, its target content is emitted in the resolved-path namespace
816 // that normal traversal already uses. Two aliases may therefore share
817 // one target entry while both link entries remain present.
818 if (!( $root["type"] === "file" && $resolved_target_was_indexed )) {
819 $entries = array_merge($entries, $inspected_path["entries"]);
820 }
821 if (
822 $this->follow_symlinks
823 && $root["type"] === "symlink"
824 && $root["resolved_path"] !== null
825 && !is_dir($root["resolved_path"])
826 && !$resolved_target_was_indexed
827 ) {
828 clearstatcache(true, $root["resolved_path"]);
829 $target_stat = @lstat($root["resolved_path"]);
830 if (is_array($target_stat)) {
831 $target = self::index_entries_for_path($root["resolved_path"], $target_stat, false);
832 $entries = array_merge($entries, $target["entries"]);
833 }
834 }
835 if (
836 $root["type"] === "file"
837 && $root["resolved_path"] !== null
838 && $root["resolved_path"] !== $root["requested_path"]
839 && !$resolved_target_was_indexed
840 ) {
841 // A regular root reached through no link normally has identical
842 // coordinates. Keep this branch for records supplied by callers
843 // which already normalized a resolved file root.
844 $entries = array_merge(
845 $entries,
846 self::index_entries_for_path($root["resolved_path"], $stat, false)["entries"]
847 );
848 }
849 $this->index_entries = $entries;
850 $this->step_status = self::STATUS_INDEXED;
851 }
852
853 /** Finds the current structured root by its requested path. */
854 private function find_root(string $requested_path): ?array
855 {
856 foreach ($this->roots as $root) {
857 if ($root["requested_path"] === $requested_path) {
858 return $root;
859 }
860 }
861 return null;
862 }
863
864 /** Whether an earlier named root already emitted this resolved target. */
865 private function resolved_target_was_indexed(array $root): bool
866 {
867 foreach ($this->roots as $candidate) {
868 if (
869 $candidate["requested_path"] !== $root["requested_path"]
870 &&
871 $candidate["resolved_path"] === $root["resolved_path"]
872 && !in_array($candidate["requested_path"], $this->pending_named_roots, true)
873 ) {
874 return true;
875 }
876 }
877 return false;
878 }
879
880 /**
881 * Validates root records produced by the endpoint resolver or local callers.
882 *
883 * @param array[] $roots File-index roots.
884 * @return FileIndexRoot[]
885 */
886 private static function validate_roots(array $roots): array
887 {
888 $validated_roots = [];
889 foreach ($roots as $root) {
890 $validated_roots[] = self::validate_root($root);
891 }
892 return $validated_roots;
893 }
894
895 /**
896 * @param mixed $root Candidate file-index root.
897 * @return FileIndexRoot Validated file-index root.
898 */
899 private static function validate_root($root): array
900 {
901 if (!is_array($root) || !isset($root["requested_path"], $root["type"])) {
902 throw new InvalidArgumentException("File-index roots must contain requested_path and type");
903 }
904 if (!is_string($root["requested_path"]) || !is_string($root["type"])) {
905 throw new InvalidArgumentException("File-index root fields have invalid types");
906 }
907 $requested_path = $root["requested_path"];
908 if (
909 $requested_path === ""
910 || \WordPress\Reprint\Server\normalize_path($requested_path) !== $requested_path
911 ) {
912 throw new InvalidArgumentException("File-index root requested_path must be normalized");
913 }
914 $resolved_path = $root["resolved_path"] ?? null;
915 if ($resolved_path !== null && !is_string($resolved_path)) {
916 throw new InvalidArgumentException("File-index root resolved_path has invalid type");
917 }
918 if (!in_array($root["type"], ["directory", "file", "symlink", "missing"], true)) {
919 throw new InvalidArgumentException("File-index root type is invalid: {$root["type"]}");
920 }
921 if ($root["type"] === "missing") {
922 if ($resolved_path !== null) {
923 throw new InvalidArgumentException(
924 "Missing file-index root has a resolved_path: {$requested_path}"
925 );
926 }
927 } elseif ($resolved_path === null || $resolved_path === "") {
928 throw new InvalidArgumentException("File-index root missing resolved_path: {$requested_path}");
929 }
930 return [
931 "requested_path" => $requested_path,
932 "resolved_path" => $resolved_path,
933 "type" => $root["type"],
934 ];
935 }
936
937 /**
938 * Returns resolved directory roots, including followed directory links.
939 *
940 * @param FileIndexRoot[] $roots Structured roots. Each has requested_path,
941 * resolved_path, and type keys; type is directory,
942 * file, symlink, or missing.
943 * @return string[] Resolved directory paths.
944 */
945 private static function resolved_directory_roots(array $roots, bool $follow_symlinks): array
946 {
947 $directories = [];
948 foreach ($roots as $root) {
949 if (
950 $root["type"] === "directory"
951 || ( $follow_symlinks && $root["type"] === "symlink" && $root["resolved_path"] !== null && is_dir($root["resolved_path"]) )
952 ) {
953 if (!in_array($root["resolved_path"], $directories, true)) {
954 $directories[] = $root["resolved_path"];
955 }
956 }
957 }
958 return $directories;
959 }
960
961 /**
962 * Builds the index entries describing one inspected path.
963 *
964 * @param string $path Absolute path already confirmed by lstat().
965 * @param array $stat lstat() result for the path.
966 * @param bool $follow_symlinks Whether directory links may reveal intermediate links.
967 * @return array {
968 * @type array[] $entries Intermediate links, then the path's own entry.
969 * @type string $type One of file, link, dir, or other.
970 * }
971 */
972 private static function index_entries_for_path(
973 string $path,
974 array $stat,
975 bool $follow_symlinks
976 ): array {
977 $mode = $stat["mode"] & self::STAT_TYPE_MASK;
978 $type = "file";
979 $link_target = null;
980 $intermediate_symlinks = [];
981 if ($mode === self::STAT_TYPE_LINK) {
982 $type = "link";
983 $resolved_symlink = self::resolve_symlink_target($path);
984 $link_target = $resolved_symlink["target"];
985 if ($follow_symlinks) {
986 $intermediate_symlinks = $resolved_symlink["intermediates"];
987 }
988 } elseif ($mode === self::STAT_TYPE_DIR) {
989 $type = "dir";
990 } elseif ($mode !== self::STAT_TYPE_FILE) {
991 $type = "other";
992 }
993
994 // Directory size does not describe its descendants, so it is zeroed.
995 $item = [
996 "path" => $path,
997 "ctime" => (int) ( isset($stat["ctime"]) ? $stat["ctime"] : 0 ),
998 "size" => $type === "file" || $type === "link" ? (int) ( isset($stat["size"]) ? $stat["size"] : 0 ) : 0,
999 "type" => $type,
1000 ];
1001 if ($link_target !== null) {
1002 $item["target"] = $link_target;
1003 }
1004 if ($type === "dir") {
1005 // Actual empty directory, not a directory with all its children
1006 // excluded from the synchronization
1007 $directory_handle = @opendir($path);
1008 if ($directory_handle !== false) {
1009 $item["empty"] = true;
1010 while (true) {
1011 $directory_entry = readdir($directory_handle);
1012 if ($directory_entry === false) {
1013 break;
1014 }
1015 if ($directory_entry !== "." && $directory_entry !== "..") {
1016 $item["empty"] = false;
1017 break;
1018 }
1019 }
1020 closedir($directory_handle);
1021 }
1022 // If opendir() fails, leave "empty" absent. Pull reports the
1023 // directory error and push does not plan deletions from it.
1024 }
1025
1026 // Intermediate links and the inspected path share one step because a
1027 // cursor cannot stop between them without losing one of the entries.
1028 $entries = $intermediate_symlinks;
1029 // Descendants imply non-empty parents: /a/file already implies /a.
1030 // Emit a directory only when it is empty or uninspectable, when no
1031 // descendant can establish that it exists.
1032 if (
1033 $type !== "dir"
1034 || !isset($item["empty"])
1035 || $item["empty"]
1036 ) {
1037 $entries[] = $item;
1038 }
1039
1040 return ["entries" => $entries, "type" => $type];
1041 }
1042
1043 /**
1044 * Resolves a directory symlink and finds symlinks in its unresolved path.
1045 *
1046 * Managed WordPress hosts often chain symlinks: /srv may point to /,
1047 * /srv/wordpress may point to /wordpress, and readlink() may return a
1048 * relative path containing more symlinks. realpath() gives the final
1049 * canonical directory used for further indexing. File symlinks do not get
1050 * a canonical target because the pull client does not traverse them.
1051 *
1052 * realpath() skips the intermediate links, so the unresolved readlink()
1053 * path is also walked. Those intermediate entries let pull recreate the
1054 * complete path rather than only its final target.
1055 *
1056 * @param string $path Absolute path to the symlink.
1057 * @return array {
1058 * Symlink details for file indexing.
1059 *
1060 * @type string|null $target Canonical directory target, or null.
1061 * @type array[] $intermediates Symlinks encountered before that target.
1062 * }
1063 */
1064 private static function resolve_symlink_target(string $path): array
1065 {
1066 // Only links ending at a directory need a canonical target because only
1067 // directories can add more traversal work. Broken, self-referential,
1068 // and file links remain ordinary link entries without a target.
1069 clearstatcache(true, $path);
1070 $resolved_target = @realpath($path);
1071 if (
1072 $resolved_target === false
1073 || $resolved_target === $path
1074 || !is_dir($resolved_target)
1075 ) {
1076 return ["target" => null, "intermediates" => []];
1077 }
1078
1079 // realpath() jumps directly to the final directory. Walk the unresolved
1080 // target as well so links along that path are included in the index.
1081 $intermediates = [];
1082 $raw_target = @readlink($path);
1083 if ($raw_target !== false && $raw_target !== "") {
1084 if ($raw_target[0] !== "/") {
1085 $raw_target = wp_join_unix_paths(dirname($path), $raw_target);
1086 }
1087 // Resolve only textual dot segments. realpath() would skip the
1088 // intermediate links that this walk must inspect.
1089 $absolute_raw_target = \WordPress\Reprint\Server\normalize_path($raw_target);
1090 if (
1091 $absolute_raw_target !== ""
1092 && $absolute_raw_target[0] === "/"
1093 && $absolute_raw_target !== $resolved_target
1094 ) {
1095 $intermediates = self::find_parent_symlinks($absolute_raw_target);
1096 }
1097 }
1098
1099 return ["target" => $resolved_target, "intermediates" => $intermediates];
1100 }
1101
1102 /**
1103 * Returns symlinks found while walking the parents of an absolute path.
1104 *
1105 * For `/srv/wordpress/wp-content/plugins`, this inspects `/srv`, then
1106 * `/srv/wordpress`, and so on. After finding a link, traversal continues
1107 * from its canonical path. The walk is intentionally not recursive into
1108 * the final target; pull decides whether another directory needs indexing.
1109 *
1110 * @param string $absolute_path Absolute filesystem path.
1111 * @return array[] Intermediate symlink index entries.
1112 */
1113 private static function find_parent_symlinks(string $absolute_path): array
1114 {
1115 $entries = [];
1116 $parts = explode("/", $absolute_path);
1117 $current = "";
1118
1119 // Keep the requested spelling while inspecting each parent. PHP follows
1120 // a parent link when checking the next component, so changing $current
1121 // to realpath() would turn later emitted links into resolved paths.
1122 foreach ($parts as $part) {
1123 if ($part === "") {
1124 $current = "/";
1125 continue;
1126 }
1127 $current = wp_join_unix_paths($current, $part);
1128 if (!@is_link($current)) {
1129 continue;
1130 }
1131
1132 // Preserve the link spelling returned by readlink(); pull needs it
1133 // to reconstruct the same link rather than only its final directory.
1134 $target = @readlink($current);
1135 if ($target !== false && $target !== "") {
1136 $stat = @lstat($current);
1137 $entries[] = [
1138 "path" => $current,
1139 "ctime" => (int) ( is_array($stat) && isset($stat["ctime"]) ? $stat["ctime"] : 0 ),
1140 "size" => 0,
1141 "type" => "link",
1142 "target" => $target,
1143 "intermediate" => true,
1144 ];
1145 }
1146 }
1147 return $entries;
1148 }
1149 }
1150