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-index-processor.php +1149 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,1149 @@
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 +}