PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / vendor / wp-php-toolkit / reprint-exporter / src / class-file-tree-producer.php
jetpack / vendor / wp-php-toolkit / reprint-exporter / src Last commit date
class-file-tree-producer.php 3 weeks ago class-hmac-client.php 3 weeks ago class-hmac-server.php 3 weeks ago class-http-server.php 3 weeks ago class-mysql-dump-producer.php 3 weeks ago class-pdo-polyfill.php 3 weeks ago class-sqlite-driver-pdo.php 3 weeks ago class-staged-artifacts.php 3 weeks ago class-staged-endpoints.php 3 weeks ago class-staged-push-stream-protocol.php 3 weeks ago class-wpdb-driver-pdo.php 3 weeks ago export.php 3 weeks ago utils.php 3 weeks ago
class-file-tree-producer.php
668 lines
1 <?php
2 /**
3 * Streams a provided list of filesystem paths in sorted order with
4 * cursor-based resumption. The caller must pass the same paths array
5 * on each request when resuming from a cursor. Callers are responsible
6 * for encoding cursors for transport (e.g. base64 for HTTP headers).
7 */
8 class FileTreeProducer
9 {
10 const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
11
12 const PHASE_STREAMING = "streaming";
13 const PHASE_FINISHED = "finished";
14
15 /** @var array */
16 private $directories;
17 /** @var int */
18 private $chunk_size;
19 /** @var bool */
20 private $index_only;
21 /** @var string|null */
22 private $filesystem_root;
23
24 /** @var string */
25 private $phase;
26 /** @var array|null */
27 private $current_chunk = null;
28
29 /** Explicit list of paths to stream, sorted on first use. */
30 /** @var array */
31 private $paths;
32 /** @var bool */
33 private $paths_sorted = false;
34 /** @var bool */
35 private $paths_positioned = false;
36 /** Ephemeral index into $paths; NOT stored in cursor. */
37 /** @var int */
38 private $paths_position = 0;
39
40 /** State for the file currently being streamed in chunks. */
41 private $streaming_file_handle = null;
42
43 public function __destruct()
44 {
45 if ($this->streaming_file_handle !== null) {
46 fclose($this->streaming_file_handle);
47 $this->streaming_file_handle = null;
48 }
49 }
50 /** @var int */
51 private $streaming_file_offset = 0;
52 /** @var array|null */
53 private $current_file_meta = null;
54
55 /** Tracks the last emitted path for cursor generation. */
56 /** @var string|null */
57 private $last_emitted_path = null;
58 /** @var int|null */
59 private $last_emitted_ctime = null;
60
61 /**
62 * @param string|array $directories Root directories to scan.
63 * @param array $options {
64 * @type int $chunk_size Bytes per file chunk (default 5MB).
65 * @type bool $index_only Emit index entries instead of file contents.
66 * @type string $cursor JSON cursor string for resumption.
67 * @type array $paths Paths to stream (required).
68 * }
69 */
70 public function __construct($directories, array $options = [])
71 {
72 $this->directories = $this->normalize_directories($directories);
73 $this->chunk_size = $options["chunk_size"] ?? self::DEFAULT_CHUNK_SIZE;
74 $this->index_only = $options["index_only"] ?? false;
75
76 if (!isset($options["paths"]) || !is_array($options["paths"])) {
77 throw new InvalidArgumentException(
78 "The 'paths' option is required and must be an array"
79 );
80 }
81 $this->paths = $options["paths"];
82
83 if (isset($options["cursor"])) {
84 $this->initialize_from_cursor($options["cursor"]);
85 } else {
86 $this->initialize_new();
87 }
88 }
89
90 /**
91 * Sets up a fresh traversal from the beginning.
92 */
93 private function initialize_new(): void
94 {
95 $this->phase = self::PHASE_STREAMING;
96 $dirs = $this->directories;
97 sort($dirs, SORT_STRING);
98 $this->filesystem_root = $dirs[0] ?? "/";
99
100 $this->current_chunk = null;
101 $this->streaming_file_handle = null;
102 $this->streaming_file_offset = 0;
103 $this->current_file_meta = null;
104 $this->last_emitted_path = null;
105 $this->last_emitted_ctime = null;
106 $this->paths_sorted = false;
107 $this->paths_positioned = false;
108 $this->paths_position = 0;
109 }
110
111 /**
112 * Restores producer state from a JSON cursor string.
113 *
114 * Cursor format is minimal: (path, ctime, byte_offset)
115 * - path: the file/dir/symlink we were processing or just finished
116 * - ctime: the ctime of the file when we started (for change detection)
117 * - bytes: byte offset within the file (0 if finished or non-file)
118 *
119 * On resume, position within the paths array is determined by binary
120 * search based on the path. This ensures correctness even when the
121 * paths array changes between requests.
122 */
123 private function initialize_from_cursor(string $cursor_json): void
124 {
125 $cursor = json_decode($cursor_json, true);
126 if ($cursor === null && json_last_error() !== JSON_ERROR_NONE) {
127 throw new InvalidArgumentException(
128 "Invalid cursor format: " . json_last_error_msg()
129 );
130 }
131
132 $this->phase = $cursor["phase"] ?? self::PHASE_STREAMING;
133 $this->filesystem_root = isset($cursor["root"])
134 ? base64_decode($cursor["root"])
135 : ($this->directories[0] ?? "/");
136 $this->current_chunk = null;
137 $this->streaming_file_handle = null;
138 $this->paths_sorted = false;
139 $this->paths_positioned = false;
140 $this->paths_position = 0;
141
142 if ($this->phase !== self::PHASE_STREAMING) {
143 $this->phase = self::PHASE_FINISHED;
144 return;
145 }
146
147 $path = isset($cursor["path"]) ? base64_decode($cursor["path"]) : null;
148 $ctime = $cursor["ctime"] ?? null;
149 $byte_offset = $cursor["bytes"] ?? 0;
150
151 $this->last_emitted_path = null;
152 $this->last_emitted_ctime = null;
153
154 if ($path !== null && $byte_offset > 0) {
155 // Resuming mid-file.
156 clearstatcache(true, $path);
157 $size = @filesize($path);
158 if ($size === false) {
159 // File disappeared; treat as completed.
160 $this->current_file_meta = null;
161 $this->streaming_file_offset = 0;
162 $this->last_emitted_path = $path;
163 } else {
164 $this->current_file_meta = [
165 "path" => $path,
166 "ctime" => $ctime,
167 "size" => $size,
168 ];
169 $this->streaming_file_offset = $byte_offset;
170 $this->last_emitted_path = $path;
171 }
172 } else {
173 $this->current_file_meta = null;
174 $this->streaming_file_offset = 0;
175 $this->last_emitted_path = $path;
176 }
177 // Position within paths array will be resolved by binary search
178 // when get_next_path_entry() is first called
179 }
180
181 /** @param string|array $directories */
182 private function normalize_directories($directories): array
183 {
184 if (is_string($directories)) {
185 return [rtrim($directories, "/")];
186 }
187 return array_map(function ($d) {
188 return rtrim($d, "/");
189 }, $directories);
190 }
191
192 /**
193 * Advances to the next chunk. Returns false when finished.
194 */
195 public function next_chunk(): bool
196 {
197 if ($this->phase === self::PHASE_FINISHED) {
198 return false;
199 }
200
201 $this->stream_step();
202 return $this->phase !== self::PHASE_FINISHED;
203 }
204
205 /**
206 * Produces the next chunk: file data, index entry, directory, or symlink.
207 */
208 private function stream_step(): void
209 {
210 if ($this->current_file_meta !== null) {
211 $this->stream_file_chunk($this->current_file_meta);
212 return;
213 }
214
215 while (true) {
216 // get_next_server_file() may set current_chunk directly for
217 // symlinks and directories, so clear it before each iteration.
218 $this->current_chunk = null;
219
220 $server_file = $this->get_next_server_file();
221
222 if ($this->current_chunk !== null) {
223 return;
224 }
225
226 if ($server_file === null) {
227 $this->phase = self::PHASE_FINISHED;
228 $this->current_chunk = null;
229 return;
230 }
231
232 if ($this->index_only) {
233 $this->emit_index_chunk($server_file);
234 return;
235 }
236
237 $this->stream_file_chunk($server_file);
238 return;
239 }
240 }
241
242 /**
243 * Emits an index entry chunk without streaming file contents.
244 */
245 private function emit_index_chunk(array $file): void
246 {
247 $this->current_chunk = [
248 "type" => "index",
249 "path" => $file["path"],
250 "ctime" => $file["ctime"],
251 "size" => $file["size"],
252 ];
253 $this->last_emitted_path = $file["path"];
254 $this->last_emitted_ctime = $file["ctime"];
255 $this->current_file_meta = null;
256 }
257
258 /**
259 * Returns the next file entry, or sets current_chunk for non-file entries
260 * (symlinks, directories, missing paths) and returns null.
261 */
262 private function get_next_server_file(): ?array
263 {
264 return $this->get_next_path_entry();
265 }
266
267 /**
268 * Returns the next entry from the paths array.
269 *
270 * Paths are sorted on first access. Position is determined by binary
271 * search based on last_emitted_path, not by a stored index. This ensures
272 * correctness even when the paths array changes between requests.
273 */
274 private function get_next_path_entry(): ?array
275 {
276 if (!$this->paths_sorted) {
277 sort($this->paths, SORT_STRING);
278 $this->paths_sorted = true;
279 }
280
281 if (!$this->paths_positioned) {
282 if ($this->last_emitted_path !== null) {
283 $this->paths_position = $this->binary_search_next(
284 $this->paths,
285 $this->last_emitted_path
286 );
287 } else {
288 $this->paths_position = 0;
289 }
290 $this->paths_positioned = true;
291 }
292
293 while ($this->paths_position < count($this->paths)) {
294 $path = $this->paths[$this->paths_position];
295 $this->paths_position++;
296
297 $resolved_path = $this->resolve_path($path);
298 if ($resolved_path === null) {
299 // Path doesn't exist or isn't accessible, emit as missing
300 $this->last_emitted_path = $path;
301 $this->last_emitted_ctime = null;
302 $this->current_chunk = [
303 "type" => "missing",
304 "path" => $path,
305 ];
306 return null;
307 }
308
309 $info = $this->lstat_path($resolved_path);
310 if ($info === null) {
311 continue;
312 }
313
314 if ($info["type"] === "link") {
315 $target = @readlink($resolved_path);
316 $this->last_emitted_path = $resolved_path;
317 $this->last_emitted_ctime = $info["ctime"];
318 $this->current_chunk = [
319 "type" => "symlink",
320 "path" => $resolved_path,
321 "target" => $target !== false ? $target : "",
322 "ctime" => $info["ctime"] ?? 0,
323 ];
324 return null;
325 }
326
327 if ($info["type"] === "dir") {
328 $this->last_emitted_path = $resolved_path;
329 $this->last_emitted_ctime = $info["ctime"] ?? null;
330 $this->current_chunk = [
331 "type" => "directory",
332 "path" => $resolved_path,
333 "ctime" => $info["ctime"] ?? 0,
334 ];
335 return null;
336 }
337
338 if ($info["type"] === "file") {
339 $ctime = $info["ctime"];
340 $size = $info["size"];
341 if ($ctime === null || $size === null) {
342 continue;
343 }
344 $this->current_file_meta = [
345 "path" => $resolved_path,
346 "ctime" => $ctime,
347 "size" => $size,
348 ];
349 $this->streaming_file_offset = 0;
350 return $this->current_file_meta;
351 }
352 }
353
354 return null;
355 }
356
357 /**
358 * Resolves a path that might be relative to one of the root directories.
359 *
360 * Uses both file_exists() and is_link() because file_exists() follows
361 * symlinks and returns false for broken symlinks, but the symlink
362 * itself is still a valid filesystem entry we want to stream.
363 */
364 private function resolve_path(string $path): ?string
365 {
366 if ($path === "") {
367 return null;
368 }
369
370 clearstatcache(true, $path);
371 if ($path[0] === "/" && (file_exists($path) || is_link($path))) {
372 return $path;
373 }
374
375 foreach ($this->directories as $dir) {
376 $candidate = $dir . "/" . ltrim($path, "/");
377 clearstatcache(true, $candidate);
378 if (file_exists($candidate) || is_link($candidate)) {
379 return $candidate;
380 }
381 }
382
383 if ($path[0] === "/") {
384 return null;
385 }
386
387 return null;
388 }
389
390 /**
391 * Reads the next chunk from the current file and performs post-read
392 * change detection via ctime comparison.
393 */
394 private function stream_file_chunk(array $file): void
395 {
396 if ($this->streaming_file_handle === null) {
397 clearstatcache(true, $file["path"]);
398 $pre_stat = @lstat($file["path"]);
399 if ($pre_stat === false || (($pre_stat["mode"] & 0170000) !== 0100000)) {
400 $this->streaming_file_handle = null;
401 $this->current_file_meta = null;
402 $this->current_chunk = [
403 "type" => "error",
404 "error_type" => $pre_stat === false ? "file_missing" : "file_changed",
405 "path" => $file["path"],
406 "message" => $pre_stat === false
407 ? "File disappeared before read"
408 : "Path is no longer a regular file",
409 ];
410 $this->last_emitted_path = $file["path"];
411 $this->last_emitted_ctime = $file["ctime"];
412 return;
413 }
414
415 $this->streaming_file_handle = @fopen($file["path"], "r");
416 if (!$this->streaming_file_handle) {
417 $this->streaming_file_handle = null;
418 $this->current_file_meta = null;
419 $this->current_chunk = [
420 "type" => "error",
421 "error_type" => "file_open",
422 "path" => $file["path"],
423 "message" => "Failed to open file",
424 ];
425 $this->last_emitted_path = $file["path"];
426 $this->last_emitted_ctime = $file["ctime"];
427 return;
428 }
429 if ($this->streaming_file_offset > 0) {
430 $seek_result = fseek(
431 $this->streaming_file_handle,
432 $this->streaming_file_offset
433 );
434 if ($seek_result === -1) {
435 fclose($this->streaming_file_handle);
436 $this->streaming_file_handle = null;
437 $this->current_file_meta = null;
438 $this->current_chunk = [
439 "type" => "error",
440 "error_type" => "file_seek",
441 "path" => $file["path"],
442 "message" => "Failed to seek to offset {$this->streaming_file_offset}",
443 ];
444 $this->last_emitted_path = $file["path"];
445 $this->last_emitted_ctime = $file["ctime"];
446 $this->streaming_file_offset = 0;
447 return;
448 }
449 }
450 }
451
452 $data = fread($this->streaming_file_handle, $this->chunk_size);
453 if (false === $data || ("" === $data && $file["size"] !== 0)) {
454 fclose($this->streaming_file_handle);
455 $this->streaming_file_handle = null;
456 $this->streaming_file_offset = 0;
457 $this->last_emitted_path = $file["path"];
458 $this->last_emitted_ctime = $file["ctime"];
459 $this->current_file_meta = null;
460 $this->current_chunk = [
461 "type" => "error",
462 "error_type" => "file_read",
463 "path" => $file["path"],
464 "message" => "Failed to read file",
465 ];
466 return;
467 }
468
469 $offset = $this->streaming_file_offset;
470 $this->streaming_file_offset += strlen($data);
471
472 $is_first = $offset === 0;
473 $is_last = feof($this->streaming_file_handle);
474
475 $changed = false;
476 $change_ctime = null;
477 $change_size = null;
478 $error_type = "file_changed";
479
480 // Detect whether the file changed while we were reading it.
481 clearstatcache(true, $file["path"]);
482 $stat = @stat($file["path"]);
483 if ($stat === false) {
484 $changed = true;
485 $error_type = "file_missing";
486 } else {
487 $now_ctime = $stat["ctime"];
488 if ($now_ctime !== $file["ctime"]) {
489 $changed = true;
490 $change_ctime = $now_ctime;
491 }
492 }
493
494 if ($changed) {
495 fclose($this->streaming_file_handle);
496 $this->streaming_file_handle = null;
497 $this->streaming_file_offset = 0;
498 $this->last_emitted_path = $file["path"];
499 $this->last_emitted_ctime = $file["ctime"];
500 $this->current_file_meta = null;
501 $this->current_chunk = [
502 "type" => "error",
503 "error_type" => $error_type,
504 "path" => $file["path"],
505 "message" =>
506 $error_type === "file_missing"
507 ? "File disappeared during stream"
508 : "File changed during stream",
509 "expected_ctime" => $file["ctime"],
510 "actual_ctime" => $change_ctime,
511 ];
512 return;
513 }
514
515 $this->current_chunk = [
516 "type" => "file",
517 "path" => $file["path"],
518 "data" => $data,
519 "size" => $file["size"],
520 "ctime" => $file["ctime"],
521 "offset" => $offset,
522 "is_first_chunk" => $is_first,
523 "is_last_chunk" => $is_last,
524 "file_changed" => $changed,
525 "change_ctime" => $change_ctime,
526 "change_size" => $change_size,
527 ];
528
529 if ($is_last) {
530 fclose($this->streaming_file_handle);
531 $this->streaming_file_handle = null;
532 $this->streaming_file_offset = 0;
533 $this->last_emitted_path = $file["path"];
534 $this->last_emitted_ctime = $file["ctime"];
535 $this->current_file_meta = null;
536 }
537 }
538
539 /**
540 * Returns the chunk produced by the last call to next_chunk().
541 */
542 public function get_current_chunk(): ?array
543 {
544 return $this->current_chunk;
545 }
546
547 /**
548 * Serializes state into a JSON cursor string.
549 *
550 * Cursor format is minimal: (path, ctime, byte_offset)
551 * - path: last emitted path, or current file being streamed
552 * - ctime: ctime of file when we started reading (for change detection)
553 * - bytes: byte offset within the current file (0 if not mid-file)
554 *
555 * No traversal stack or list indices are stored. On resume, position is
556 * determined by binary search based on the path. This ensures correctness
557 * even when the filesystem changes between requests.
558 */
559 public function get_reentrancy_cursor(): string
560 {
561 if ($this->phase === self::PHASE_FINISHED) {
562 return json_encode([
563 "phase" => self::PHASE_FINISHED,
564 "root" => base64_encode($this->filesystem_root),
565 ]);
566 }
567
568 $cursor = [
569 "phase" => $this->phase,
570 "root" => base64_encode($this->filesystem_root),
571 ];
572
573 if ($this->current_file_meta !== null) {
574 $cursor["path"] = base64_encode($this->current_file_meta["path"]);
575 $cursor["ctime"] = $this->current_file_meta["ctime"];
576 $cursor["bytes"] = $this->streaming_file_offset;
577 } else if ($this->last_emitted_path !== null) {
578 $cursor["path"] = base64_encode($this->last_emitted_path);
579 $cursor["ctime"] = $this->last_emitted_ctime;
580 $cursor["bytes"] = 0;
581 }
582 return json_encode($cursor);
583 }
584
585 /**
586 * Returns progress metadata for logging and UI updates.
587 */
588 public function get_progress(): array
589 {
590 $progress = [
591 "phase" => $this->phase,
592 ];
593
594 if ($this->phase === self::PHASE_STREAMING) {
595 if ($this->last_emitted_path !== null) {
596 $progress["last_path"] = base64_encode($this->last_emitted_path);
597 }
598 if ($this->current_file_meta) {
599 $file = $this->current_file_meta;
600 $progress["current_file"] = [
601 "path" => base64_encode($file["path"]),
602 "size" => $file["size"],
603 "bytes_read" => $this->streaming_file_offset,
604 ];
605 }
606 }
607
608 return $progress;
609 }
610
611 /**
612 * Returns the filesystem root path.
613 */
614 public function get_filesystem_root(): ?string
615 {
616 return $this->filesystem_root;
617 }
618
619 /**
620 * Returns the index of the first entry strictly greater than $last
621 * in a sorted array, using binary search.
622 */
623 private function binary_search_next(array $entries, string $last): int
624 {
625 $low = 0;
626 $high = count($entries);
627 while ($low < $high) {
628 $mid = intdiv($low + $high, 2);
629 if (strcmp($entries[$mid], $last) <= 0) {
630 $low = $mid + 1;
631 } else {
632 $high = $mid;
633 }
634 }
635 return $low;
636 }
637
638 /**
639 * Classifies a path as file, dir, link, or other via a single lstat() call.
640 *
641 * @return array|null {type: 'file'|'dir'|'link'|'other', ctime: ?int, size: ?int}
642 */
643 private function lstat_path(string $path): ?array
644 {
645 clearstatcache(true, $path);
646 $stat = @lstat($path);
647 if ($stat === false) {
648 return null;
649 }
650
651 $mode = $stat["mode"] & 0170000;
652 $type = "other";
653 if ($mode === 0120000) {
654 $type = "link";
655 } elseif ($mode === 0040000) {
656 $type = "dir";
657 } elseif ($mode === 0100000) {
658 $type = "file";
659 }
660
661 return [
662 "type" => $type,
663 "ctime" => isset($stat["ctime"]) ? (int) $stat["ctime"] : null,
664 "size" => isset($stat["size"]) ? (int) $stat["size"] : null,
665 ];
666 }
667 }
668