From cf091e5c3a33829128f977f4257a5cc2db50a2ff Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Wed, 12 Aug 2026 20:30:37 +0530 Subject: [PATCH 01/30] Add canonical_root_path() for configured roots --- packages/reprint-server/src/utils.php | 32 ++++++++++ tests/ExporterUtilsTest.php | 84 +++++++++++++++++++++++++++ tests/phpunit.xml | 1 + 3 files changed, 117 insertions(+) create mode 100644 tests/ExporterUtilsTest.php diff --git a/packages/reprint-server/src/utils.php b/packages/reprint-server/src/utils.php index 93aaec1b2..3461ec028 100644 --- a/packages/reprint-server/src/utils.php +++ b/packages/reprint-server/src/utils.php @@ -35,6 +35,7 @@ function str_contains(string $haystack, string $needle): bool { use InvalidArgumentException; use RuntimeException; +use function WordPress\Filesystem\wp_join_unix_paths; // Composer's "files" autoload includes this file once per registered // path. In a monorepo where the same package is mirrored into vendor/ @@ -393,6 +394,37 @@ function path_is_same_as_or_descendant_of($path, $ancestor): bool return $path === $ancestor || str_starts_with($path, $ancestor . "/"); } +/** + * Canonicalizes one configured root path. + * + * A directory resolves through realpath(). Any other path keeps its final + * component, because a root may be a single file named by --include and + * resolving that component would index a symlinked file under its target. + * + * @param string $path Root path as configured. + * @return string|null Canonical root path, or null when it does not exist. + */ +function canonical_root_path(string $path): ?string +{ + clearstatcache(true, $path); + if (is_dir($path)) { + $canonical_path = realpath($path); + return $canonical_path === false ? null : $canonical_path; + } + + // file_exists() follows links and so reports a broken link as absent. + if (@lstat($path) === false) { + return null; + } + + $canonical_parent = realpath(dirname($path)); + if ($canonical_parent === false) { + return null; + } + + return wp_join_unix_paths($canonical_parent, basename($path)); +} + /** * Indicates whether a candidate path is a descendant of an ancestor. * diff --git a/tests/ExporterUtilsTest.php b/tests/ExporterUtilsTest.php new file mode 100644 index 000000000..c9d702f4b --- /dev/null +++ b/tests/ExporterUtilsTest.php @@ -0,0 +1,84 @@ +tempDir = sys_get_temp_dir() . '/exporter-utils-' . uniqid(); + mkdir($this->tempDir . '/real', 0755, true); + file_put_contents($this->tempDir . '/real/target.txt', 'hi'); + symlink('real/target.txt', $this->tempDir . '/link-to-file'); + symlink('nowhere.txt', $this->tempDir . '/broken-link'); + symlink('real', $this->tempDir . '/link-to-dir'); + } + + protected function tearDown(): void + { + foreach (['link-to-file', 'broken-link', 'link-to-dir', 'real/target.txt'] as $path) { + @unlink($this->tempDir . '/' . $path); + } + @rmdir($this->tempDir . '/real'); + @rmdir($this->tempDir); + parent::tearDown(); + } + + public function testDirectoryResolvesThroughRealpath(): void + { + $this->assertSame( + realpath($this->tempDir . '/real'), + canonical_root_path($this->tempDir . '/real') + ); + } + + public function testDirectorySymlinkStillResolvesToItsTarget(): void + { + $this->assertSame( + realpath($this->tempDir . '/real'), + canonical_root_path($this->tempDir . '/link-to-dir'), + 'A symlinked directory root must keep resolving, as traversal depends on it' + ); + } + + public function testRegularFileKeepsItsOwnPath(): void + { + $this->assertSame( + realpath($this->tempDir) . '/real/target.txt', + canonical_root_path($this->tempDir . '/real/target.txt') + ); + } + + public function testFileSymlinkKeepsItsOwnPathInsteadOfTheTarget(): void + { + $this->assertSame( + realpath($this->tempDir) . '/link-to-file', + canonical_root_path($this->tempDir . '/link-to-file'), + 'A file link must not collapse into its target, or pull writes it to the wrong path' + ); + } + + public function testBrokenSymlinkIsAcceptedRatherThanRejected(): void + { + $this->assertSame( + realpath($this->tempDir) . '/broken-link', + canonical_root_path($this->tempDir . '/broken-link') + ); + } + + public function testMissingPathReturnsNull(): void + { + $this->assertNull(canonical_root_path($this->tempDir . '/absent.txt')); + } + + public function testPathUnderAMissingParentReturnsNull(): void + { + $this->assertNull(canonical_root_path($this->tempDir . '/absent-dir/absent.txt')); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 543a99923..937b353bf 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -26,6 +26,7 @@ ExportHttpServerTest.php FileIndexDedupTest.php FileIndexSkipDefaultsTest.php + ExporterUtilsTest.php HmacServerTest.php MultipartProcessorTest.php PushEndpointsTest.php From af4f7e678a11e1ff3e45681362d9f89a2deff9d2 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Wed, 12 Aug 2026 20:39:13 +0530 Subject: [PATCH 02/30] Accept a non-directory directory[] entry --- packages/reprint-server/src/export.php | 8 +-- tests/ExportResolveDirectoriesTest.php | 75 ++++++++++++++++++++++++++ tests/phpunit.xml | 1 + 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 tests/ExportResolveDirectoriesTest.php diff --git a/packages/reprint-server/src/export.php b/packages/reprint-server/src/export.php index cd2271fef..3334b03b8 100644 --- a/packages/reprint-server/src/export.php +++ b/packages/reprint-server/src/export.php @@ -6,6 +6,7 @@ use function WordPress\Filesystem\wp_join_unix_paths; use function WordPress\Reprint\Exporter\assert_valid_path; use function WordPress\Reprint\Exporter\build_pdo_dsn; +use function WordPress\Reprint\Exporter\canonical_root_path; use function WordPress\Reprint\Exporter\json_encode_or_throw; use function WordPress\Reprint\Exporter\parse_size; use function WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of; @@ -1304,10 +1305,11 @@ function resolve_directories(array $config): array $directory = trim($directory); assert_valid_path($directory, "directory entry"); - $real_directory = realpath($directory); - if ($real_directory === false) { + // A root may be one file named by --include, so any existing path is valid. + $real_directory = canonical_root_path($directory); + if ($real_directory === null) { throw new InvalidArgumentException( - "directory does not exist or is not accessible: {$directory}\n" . + "directory entry does not exist or is not accessible: {$directory}\n" . "Current working directory: " . getcwd() . "\n" . diff --git a/tests/ExportResolveDirectoriesTest.php b/tests/ExportResolveDirectoriesTest.php new file mode 100644 index 000000000..89cd457e0 --- /dev/null +++ b/tests/ExportResolveDirectoriesTest.php @@ -0,0 +1,75 @@ +tempDir = sys_get_temp_dir() . '/resolve-directories-' . uniqid(); + mkdir($this->tempDir . '/site', 0755, true); + file_put_contents($this->tempDir . '/site/wp-config.php', 'tempDir . '/site/config-link.php'); + } + + protected function tearDown(): void + { + @unlink($this->tempDir . '/site/config-link.php'); + @unlink($this->tempDir . '/site/wp-config.php'); + @rmdir($this->tempDir . '/site'); + @rmdir($this->tempDir); + parent::tearDown(); + } + + public function testDirectoryEntryStillResolves(): void + { + $resolved = resolve_directories(['directory' => [$this->tempDir . '/site']]); + $this->assertSame([realpath($this->tempDir . '/site')], $resolved); + } + + public function testFileEntryIsAccepted(): void + { + $resolved = resolve_directories([ + 'directory' => [$this->tempDir . '/site/wp-config.php'], + ]); + $this->assertSame( + [realpath($this->tempDir . '/site') . '/wp-config.php'], + $resolved + ); + } + + public function testFileSymlinkEntryKeepsItsOwnPath(): void + { + $resolved = resolve_directories([ + 'directory' => [$this->tempDir . '/site/config-link.php'], + ]); + $this->assertSame( + [realpath($this->tempDir . '/site') . '/config-link.php'], + $resolved + ); + } + + public function testMissingEntryNamesTheObservedPath(): void + { + $missing = $this->tempDir . '/site/absent.php'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($missing); + + resolve_directories(['directory' => [$missing]]); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 937b353bf..9d065e172 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -24,6 +24,7 @@ CreateDbConnectionTest.php PushCommitTest.php ExportHttpServerTest.php + ExportResolveDirectoriesTest.php FileIndexDedupTest.php FileIndexSkipDefaultsTest.php ExporterUtilsTest.php From 724b61c6727d5947b8c9fe7e96f120e72ad3e1b4 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Wed, 12 Aug 2026 20:44:42 +0530 Subject: [PATCH 03/30] Trim the handler-restore comment --- tests/ExportResolveDirectoriesTest.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/ExportResolveDirectoriesTest.php b/tests/ExportResolveDirectoriesTest.php index 89cd457e0..d51dd0059 100644 --- a/tests/ExportResolveDirectoriesTest.php +++ b/tests/ExportResolveDirectoriesTest.php @@ -6,10 +6,8 @@ require_once dirname(__DIR__) . '/packages/reprint-server/src/export.php'; -// export.php registers process-wide error/exception handlers as a side -// effect of being loaded. Pop them back off so they don't outlive this -// file and swallow unrelated failures (or exit()) in tests that run -// later in the same PHPUnit process. +// Loading export.php installs process-wide handlers that would otherwise +// swallow unrelated failures — or exit() — in later tests. restore_error_handler(); restore_exception_handler(); From 68a6289c14c76ae2ee6309c6dfcae03de3f81cce Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Wed, 12 Aug 2026 20:45:51 +0530 Subject: [PATCH 04/30] Extract the file-index entry builder --- .../src/class-file-index-processor.php | 169 ++++++++++-------- 1 file changed, 99 insertions(+), 70 deletions(-) diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 48b51a006..dae64e2f1 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -336,76 +336,9 @@ public function next_index_step(): bool return true; } - // Translate platform mode bits into the four types understood by the - // file index. A directory link may also reveal intermediate links that - // canonicalization would otherwise hide. - $mode = $stat["mode"] & self::STAT_TYPE_MASK; - $type = "file"; - $link_target = null; - $intermediate_symlinks = []; - if ($mode === self::STAT_TYPE_LINK) { - $type = "link"; - $resolved_symlink = self::resolve_symlink_target($path); - $link_target = $resolved_symlink["target"]; - if ($this->follow_symlinks) { - $intermediate_symlinks = $resolved_symlink["intermediates"]; - } - } elseif ($mode === self::STAT_TYPE_DIR) { - $type = "dir"; - } elseif ($mode !== self::STAT_TYPE_FILE) { - $type = "other"; - } - - // Build the index entry from the one successful lstat() call. File and - // link sizes participate in push change detection; directory size does - // not describe its descendants and is normalized to zero. - $item = [ - "path" => $path, - "ctime" => (int) ( isset($stat["ctime"]) ? $stat["ctime"] : 0 ), - "size" => $type === "file" || $type === "link" ? (int) ( isset($stat["size"]) ? $stat["size"] : 0 ) : 0, - "type" => $type, - ]; - if ($link_target !== null) { - $item["target"] = $link_target; - } - if ($type === "dir") { - // The index describes physical emptiness, not emptiness after - // exclusions. A cache or Reprint-storage child still makes its - // parent non-empty; calling that parent empty could turn an - // intentionally omitted descendant into destructive push work. - $directory_handle = @opendir($path); - if ($directory_handle !== false) { - $item["empty"] = true; - while (true) { - $directory_entry = readdir($directory_handle); - if ($directory_entry === false) { - break; - } - if ($directory_entry !== "." && $directory_entry !== "..") { - $item["empty"] = false; - break; - } - } - closedir($directory_handle); - } - // When the directory cannot be inspected, leave "empty" absent. - // Pull reports the later directory-open failure. A push index - // builder can stop instead of treating unknown descendants as - // deletions. - } - - // Intermediate links and the inspected path belong to the same step - // because the cursor cannot stop between them without losing one. - $this->index_entries = $intermediate_symlinks; - // A descendant implies its non-empty ancestors. Keep explicit rows - // only for empty directories, which have no descendant to imply them. - if ( - $type !== "dir" - || !isset($item["empty"]) - || $item["empty"] - ) { - $this->index_entries[] = $item; - } + $inspected_path = self::index_entries_for_path($path, $stat, $this->follow_symlinks); + $this->index_entries = $inspected_path["entries"]; + $type = $inspected_path["type"]; $this->step_status = self::STATUS_INDEXED; // Depth-first traversal enters a new directory before returning to the @@ -765,6 +698,102 @@ private static function position_after_name(array $directory_names, string $afte return $low; } + /** + * Builds the index entries describing one inspected path. + * + * Static because start() schedules roots before the processor exists. + * + * @param string $path Absolute path already confirmed by lstat(). + * @param array $stat lstat() result for the path. + * @param bool $follow_symlinks Whether directory links may reveal intermediate links. + * @return array { + * Entries and the type recorded for this path. + * + * @type array[] $entries Intermediate link entries, then the path's own + * entry. A non-empty directory contributes no + * entry of its own because its descendants imply it. + * @type string $type One of file, link, dir, or other. + * } + */ + private static function index_entries_for_path( + string $path, + array $stat, + bool $follow_symlinks + ): array { + // Translate platform mode bits into the four types understood by the + // file index. A directory link may also reveal intermediate links that + // canonicalization would otherwise hide. + $mode = $stat["mode"] & self::STAT_TYPE_MASK; + $type = "file"; + $link_target = null; + $intermediate_symlinks = []; + if ($mode === self::STAT_TYPE_LINK) { + $type = "link"; + $resolved_symlink = self::resolve_symlink_target($path); + $link_target = $resolved_symlink["target"]; + if ($follow_symlinks) { + $intermediate_symlinks = $resolved_symlink["intermediates"]; + } + } elseif ($mode === self::STAT_TYPE_DIR) { + $type = "dir"; + } elseif ($mode !== self::STAT_TYPE_FILE) { + $type = "other"; + } + + // Build the index entry from the one successful lstat() call. File and + // link sizes participate in push change detection; directory size does + // not describe its descendants and is normalized to zero. + $item = [ + "path" => $path, + "ctime" => (int) ( isset($stat["ctime"]) ? $stat["ctime"] : 0 ), + "size" => $type === "file" || $type === "link" ? (int) ( isset($stat["size"]) ? $stat["size"] : 0 ) : 0, + "type" => $type, + ]; + if ($link_target !== null) { + $item["target"] = $link_target; + } + if ($type === "dir") { + // The index describes physical emptiness, not emptiness after + // exclusions. A cache or Reprint-storage child still makes its + // parent non-empty; calling that parent empty could turn an + // intentionally omitted descendant into destructive push work. + $directory_handle = @opendir($path); + if ($directory_handle !== false) { + $item["empty"] = true; + while (true) { + $directory_entry = readdir($directory_handle); + if ($directory_entry === false) { + break; + } + if ($directory_entry !== "." && $directory_entry !== "..") { + $item["empty"] = false; + break; + } + } + closedir($directory_handle); + } + // When the directory cannot be inspected, leave "empty" absent. + // Pull reports the later directory-open failure. A push index + // builder can stop instead of treating unknown descendants as + // deletions. + } + + // Intermediate links and the inspected path belong to the same step + // because the cursor cannot stop between them without losing one. + $entries = $intermediate_symlinks; + // A descendant implies its non-empty ancestors. Keep explicit rows + // only for empty directories, which have no descendant to imply them. + if ( + $type !== "dir" + || !isset($item["empty"]) + || $item["empty"] + ) { + $entries[] = $item; + } + + return ["entries" => $entries, "type" => $type]; + } + /** * Resolves a directory symlink and finds symlinks in its unresolved path. * From 3794ff81026e38b910495056ce8e19ae9d030f46 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Wed, 12 Aug 2026 20:48:18 +0530 Subject: [PATCH 05/30] Index a file path named as a file-index root --- .../src/class-file-index-processor.php | 58 +++++-- tests/FileIndexProcessorTest.php | 152 ++++++++++++++++++ tests/phpunit.xml | 1 + 3 files changed, 201 insertions(+), 10 deletions(-) diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index dae64e2f1..46440e52f 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -99,12 +99,10 @@ public static function start( bool $include_caches, string $storage_path ): self { - // Anchor traversal to a real directory. All later comparisons use - // canonical paths so configured roots and followed links share one - // path namespace. - clearstatcache(true, $index_directory); - $canonical_index_directory = realpath($index_directory); - if ($canonical_index_directory === false || !is_dir($canonical_index_directory)) { + // Canonical paths keep configured roots and followed links in one + // namespace. A root may also be one file named by --include. + $canonical_index_directory = \WordPress\Reprint\Exporter\canonical_root_path($index_directory); + if ($canonical_index_directory === null) { throw new InvalidArgumentException( "list_dir does not exist or is not accessible: {$index_directory}" ); @@ -142,12 +140,24 @@ public static function start( } } + // A root that is not a directory is one named path; nothing to walk. + $directory_roots = []; + $path_roots = []; + foreach ($ordered_directories as $directory) { + clearstatcache(true, $directory); + if (is_dir($directory)) { + $directory_roots[] = $directory; + } else { + $path_roots[] = $directory; + } + } + // The last stack element is visited next, so reverse the desired order // while constructing the depth-first traversal stack. $directory_stack = []; - for ($i = count($ordered_directories) - 1; $i >= 0; $i--) { + for ($i = count($directory_roots) - 1; $i >= 0; $i--) { $directory_stack[] = [ - "dir" => $ordered_directories[$i], + "dir" => $directory_roots[$i], "after" => null, ]; } @@ -157,21 +167,49 @@ public static function start( // found here must precede ordinary directory entries. $initial_index_entries = []; if ($follow_symlinks) { - foreach ($ordered_directories as $directory) { + foreach ($directory_roots as $directory) { $initial_index_entries = array_merge( $initial_index_entries, self::find_parent_symlinks($directory) ); } + // dirname() so a symlinked file is not repeated as an intermediate entry. + foreach ($path_roots as $path_root) { + $initial_index_entries = array_merge( + $initial_index_entries, + self::find_parent_symlinks(dirname($path_root)) + ); + } } + // These share the first step's cursor boundary: the endpoint always takes + // one step before its budget check, and resume() begins with none of them. + foreach ($path_roots as $path_root) { + clearstatcache(true, $path_root); + $stat = @lstat($path_root); + if ($stat === false) { + // Present when canonicalized moments ago, so it has just disappeared. + continue; + } + $inspected_path = self::index_entries_for_path($path_root, $stat, $follow_symlinks); + $initial_index_entries = array_merge( + $initial_index_entries, + $inspected_path["entries"] + ); + } + + // X-Index-Dir names a directory, so a named path reports its parent. + $reported_index_directory = in_array($canonical_index_directory, $path_roots, true) + ? dirname($canonical_index_directory) + : $canonical_index_directory; + return new self( $directories, $follow_symlinks, $include_caches, $storage_path, $directory_stack, - $canonical_index_directory, + $reported_index_directory, $initial_index_entries ); } diff --git a/tests/FileIndexProcessorTest.php b/tests/FileIndexProcessorTest.php index d8f69043d..7d90a288c 100644 --- a/tests/FileIndexProcessorTest.php +++ b/tests/FileIndexProcessorTest.php @@ -243,6 +243,158 @@ public function testStepAfterCloseIsRejected(): void $processor->next_index_step(); } + public function testSingleFileRootIsIndexedWithoutTraversal(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot, 0755, true); + file_put_contents($docroot . '/wp-config.php', 'collectEntries([$configPath], $configPath); + + $this->assertCount(1, $result['entries']); + $this->assertSame($configPath, $result['entries'][0]['path']); + $this->assertSame('file', $result['entries'][0]['type']); + $this->assertSame(filesize($configPath), $result['entries'][0]['size']); + } + + public function testFileRootAndDirectoryRootAreBothIndexed(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot . '/wp-content/plugins/hello', 0755, true); + file_put_contents($docroot . '/wp-config.php', 'collectEntries([$configPath, $pluginsPath], $configPath); + + $paths = array_column($result['entries'], 'path'); + $this->assertContains($configPath, $paths); + $this->assertContains($pluginsPath . '/hello/hello.php', $paths); + } + + public function testFileSymlinkRootIsIndexedAsTheLinkItself(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot, 0755, true); + file_put_contents($docroot . '/target.php', 'collectEntries([$linkPath], $linkPath); + + $this->assertCount(1, $result['entries']); + $this->assertSame($linkPath, $result['entries'][0]['path']); + $this->assertSame('link', $result['entries'][0]['type']); + // Only a link ending at a directory carries a target; fetch supplies this one. + $this->assertArrayNotHasKey('target', $result['entries'][0]); + } + + public function testBrokenSymlinkRootIsIndexedRatherThanRejected(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot, 0755, true); + symlink('absent.php', $docroot . '/broken.php'); + $brokenPath = (string) realpath($docroot) . '/broken.php'; + + $result = $this->collectEntries([$brokenPath], $brokenPath); + + $this->assertCount(1, $result['entries']); + $this->assertSame($brokenPath, $result['entries'][0]['path']); + $this->assertSame('link', $result['entries'][0]['type']); + } + + public function testMissingFileRootNamesTheObservedPath(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot, 0755, true); + $missingPath = $docroot . '/absent.php'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($missingPath); + + FileIndexProcessor::start([$docroot], $missingPath, false, false, ''); + } + + public function testFileRootInsideASkippedDirectoryIsStillIndexed(): void + { + // path_is_default_skipped() is tested against children, never against a root. + $docroot = $this->tempDir . '/site'; + mkdir($docroot . '/wp-content/cache', 0755, true); + file_put_contents($docroot . '/wp-content/cache/keep.php', 'collectEntries([$cachedPath], $cachedPath, false); + + $this->assertCount(1, $result['entries']); + $this->assertSame($cachedPath, $result['entries'][0]['path']); + } + + public function testResumingAfterTheFirstStepDoesNotRepeatFileRoots(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot . '/nested', 0755, true); + file_put_contents($docroot . '/wp-config.php', 'collectEntries($roots, $configPath); + $resumed = $this->collectEntries($roots, $configPath, true, true); + + $this->assertSame( + array_column($uninterrupted['entries'], 'path'), + array_column($resumed['entries'], 'path') + ); + $this->assertSame( + 1, + count(array_keys(array_column($resumed['entries'], 'path'), $configPath)), + 'The named file must be indexed exactly once across a resume' + ); + } + + /** + * Runs a processor over explicit roots and collects every entry. + * + * @param string[] $roots Configured roots, canonical. + * @param string $indexDirectory Root where traversal begins. + * @param bool $includeCaches Whether generated caches are included. + * @param bool $resumeAfterEveryStep Whether to reopen from the cursor each step. + * @return array { + * Completed traversal. + * + * @type array[] $entries File-index entries. + * @type string[] $statuses Status returned by every step. + * } + */ + private function collectEntries( + array $roots, + string $indexDirectory, + bool $includeCaches = true, + bool $resumeAfterEveryStep = false + ): array { + $processor = FileIndexProcessor::start($roots, $indexDirectory, false, $includeCaches, ''); + $entries = []; + $statuses = []; + while ($processor->next_index_step()) { + $statuses[] = $processor->get_step_status(); + foreach ($processor->get_index_entries() as $entry) { + $entries[] = $entry; + } + if ($resumeAfterEveryStep) { + $cursor = json_encode($processor->get_cursor(), JSON_THROW_ON_ERROR); + $processor->close(); + $processor = FileIndexProcessor::resume($roots, $cursor, false, $includeCaches, ''); + } + } + $processor->close(); + + return ['entries' => $entries, 'statuses' => $statuses]; + } + /** * @return array { * Completed traversal. diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 9d065e172..99fdbc34d 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -27,6 +27,7 @@ ExportResolveDirectoriesTest.php FileIndexDedupTest.php FileIndexSkipDefaultsTest.php + FileIndexProcessorTest.php ExporterUtilsTest.php HmacServerTest.php MultipartProcessorTest.php From b75ef2bfbe8638f375bb808cfeec207c96743dd8 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Wed, 12 Aug 2026 20:49:42 +0530 Subject: [PATCH 06/30] Cover file-path roots at the file-index endpoint --- tests/FileIndexDedupTest.php | 86 ++--------------------- tests/FileIndexEndpointRunnerTrait.php | 94 ++++++++++++++++++++++++++ tests/FileIndexFilePathRootTest.php | 55 +++++++++++++++ tests/phpunit.xml | 1 + 4 files changed, 154 insertions(+), 82 deletions(-) create mode 100644 tests/FileIndexEndpointRunnerTrait.php create mode 100644 tests/FileIndexFilePathRootTest.php diff --git a/tests/FileIndexDedupTest.php b/tests/FileIndexDedupTest.php index d16fbc5ca..7bd5bed4c 100644 --- a/tests/FileIndexDedupTest.php +++ b/tests/FileIndexDedupTest.php @@ -4,8 +4,12 @@ use PHPUnit\Framework\TestCase; +require_once __DIR__ . '/FileIndexEndpointRunnerTrait.php'; + final class FileIndexDedupTest extends TestCase { + use FileIndexEndpointRunnerTrait; + private string $tempDir; protected function setUp(): void @@ -74,86 +78,4 @@ public function testFileIndexTraversesParentRootWithSeparateContent(): void 'Files above the document root should not be indexed', ); } - - /** - * @param string[] $directories - * @return string[] - */ - private function runFileIndex(array $directories, string $listDir): array - { - $configPath = $this->tempDir . '/config.json'; - file_put_contents( - $configPath, - json_encode([ - 'directory' => $directories, - 'list_dir' => $listDir, - 'follow_symlinks' => true, - 'batch_size' => 1000, - ], JSON_THROW_ON_ERROR), - ); - - $scriptPath = $this->tempDir . '/run-file-index.php'; - file_put_contents( - $scriptPath, - sprintf( - <<<'PHP' - ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - $process = proc_open($command, $descriptorSpec, $pipes); - $this->assertIsResource($process); - - $stdout = stream_get_contents($pipes[1]); - fclose($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[2]); - $exitCode = proc_close($process); - - $this->assertSame(0, $exitCode, "file_index should exit cleanly.\nstderr: {$stderr}"); - - $decoded = gzdecode($stdout); - $this->assertNotFalse($decoded, 'Expected gzip-compressed multipart response'); - - preg_match_all('/"path":"([^"]+)"/', $decoded, $matches); - - return array_map( - static fn(string $encodedPath): string => (string) base64_decode($encodedPath, true), - $matches[1], - ); - } - - private function recursiveDelete(string $dir): void - { - if (!is_dir($dir)) { - return; - } - foreach (scandir($dir) as $item) { - if ($item === '.' || $item === '..') { - continue; - } - $path = $dir . '/' . $item; - if (is_dir($path) && !is_link($path)) { - $this->recursiveDelete($path); - continue; - } - unlink($path); - } - rmdir($dir); - } } diff --git a/tests/FileIndexEndpointRunnerTrait.php b/tests/FileIndexEndpointRunnerTrait.php new file mode 100644 index 000000000..f8ca17a41 --- /dev/null +++ b/tests/FileIndexEndpointRunnerTrait.php @@ -0,0 +1,94 @@ +tempDir . '/config.json'; + file_put_contents( + $configPath, + json_encode([ + 'directory' => $directories, + 'list_dir' => $listDir, + 'follow_symlinks' => true, + 'batch_size' => 1000, + ], JSON_THROW_ON_ERROR), + ); + + $scriptPath = $this->tempDir . '/run-file-index.php'; + file_put_contents( + $scriptPath, + sprintf( + <<<'PHP' + ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($command, $descriptorSpec, $pipes); + $this->assertIsResource($process); + + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + $this->assertSame(0, $exitCode, "file_index should exit cleanly.\nstderr: {$stderr}"); + + $decoded = gzdecode($stdout); + $this->assertNotFalse($decoded, 'Expected gzip-compressed multipart response'); + + preg_match_all('/"path":"([^"]+)"/', $decoded, $matches); + + return array_map( + static fn(string $encodedPath): string => (string) base64_decode($encodedPath, true), + $matches[1], + ); + } + + protected function recursiveDelete(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + if (is_dir($path) && !is_link($path)) { + $this->recursiveDelete($path); + continue; + } + unlink($path); + } + rmdir($dir); + } +} diff --git a/tests/FileIndexFilePathRootTest.php b/tests/FileIndexFilePathRootTest.php new file mode 100644 index 000000000..cb148d7a7 --- /dev/null +++ b/tests/FileIndexFilePathRootTest.php @@ -0,0 +1,55 @@ +tempDir = sys_get_temp_dir() . '/file-index-file-root-' . uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + $this->recursiveDelete($this->tempDir); + parent::tearDown(); + } + + public function testEndpointIndexesASingleFileRoot(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot . '/wp-content', 0755, true); + file_put_contents($docroot . '/wp-config.php', 'runFileIndex([$configPath], $configPath); + + $this->assertSame([$configPath], $paths); + } + + public function testEndpointIndexesAFileRootAlongsideADirectoryRoot(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot . '/wp-content/plugins/hello', 0755, true); + file_put_contents($docroot . '/wp-config.php', 'runFileIndex([$configPath, $pluginsPath], $configPath); + + $this->assertContains($configPath, $paths); + $this->assertContains($pluginsPath . '/hello/hello.php', $paths); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 99fdbc34d..a7a23e0ea 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -28,6 +28,7 @@ FileIndexDedupTest.php FileIndexSkipDefaultsTest.php FileIndexProcessorTest.php + FileIndexFilePathRootTest.php ExporterUtilsTest.php HmacServerTest.php MultipartProcessorTest.php From 88fdf374a68abb573dd95ce64d2ae68bfd88c56d Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Wed, 12 Aug 2026 20:52:40 +0530 Subject: [PATCH 07/30] Document and cover --only with a file path --- packages/reprint-client/src/import.php | 4 +- tests/e2e/site-registry.json | 3 + .../tests/import-55-only-file-path.test.js | 63 +++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/tests/import-55-only-file-path.test.js diff --git a/packages/reprint-client/src/import.php b/packages/reprint-client/src/import.php index 19559d2f8..d3b116b0a 100755 --- a/packages/reprint-client/src/import.php +++ b/packages/reprint-client/src/import.php @@ -12666,8 +12666,8 @@ function get_importer_version(): string { 'target' => 'include', 'placeholder' => 'SOURCE', 'repeatable' => true, - 'help' => 'Restrict the file pull to SOURCE (a :token: like :wp-content: or :wp-uploads:, or an absolute path); ' . - 'repeat for several. Default pulls everything', + 'help' => 'Restrict the file pull to SOURCE (a :token: like :wp-content: or :wp-uploads:, or an absolute ' . + 'path to a directory or a single file); repeat for several. Default pulls everything', 'commands' => ['pull-files', 'files-pull'], 'aliases' => ['only'], ], diff --git a/tests/e2e/site-registry.json b/tests/e2e/site-registry.json index 612d523d7..37a0b2e9e 100644 --- a/tests/e2e/site-registry.json +++ b/tests/e2e/site-registry.json @@ -154,6 +154,9 @@ }, "files-pull-mirror": { "port": 8130 + }, + "only-file-path": { + "port": 8131 } } } diff --git a/tests/e2e/tests/import-55-only-file-path.test.js b/tests/e2e/tests/import-55-only-file-path.test.js new file mode 100644 index 000000000..fd5a2c67d --- /dev/null +++ b/tests/e2e/tests/import-55-only-file-path.test.js @@ -0,0 +1,63 @@ +/** + * Test 55: files-pull --only + * + * `--only` accepts a single file, not just a directory (issue #539). + */ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + runImporter, createTempDir, cleanupTempDir, + getSiteUrl, getSiteSecret, getSiteDir, fsRootDir, +} from '../lib/test-helpers.js'; +import { ensureSite } from '../lib/site-setup.js'; + +describe('Import: files-pull --only ', { timeout: 180000 }, () => { + const site = 'only-file-path'; + let tempDir; + let siteDir; + + beforeAll(async () => { + await ensureSite(site, { + afterCreate: async (remoteSiteDir) => { + writeFileSync(join(remoteSiteDir, 'single-file.php'), ' { + cleanupTempDir(tempDir); + }); + + function importUrl() { + return `${getSiteUrl(site)}&directory=${siteDir}`; + } + + it('files-pull completes when --only names one file', () => { + const result = runImporter(importUrl(), tempDir, 'files-pull', { + secret: getSiteSecret(site), + extraArgs: ['--only', join(siteDir, 'single-file.php')], + }); + assert.equal( + result.exitCode, 0, + `Expected exit 0\nstderr: ${result.stderr}\nstdout: ${result.stdout}`, + ); + }); + + it('the named file is pulled with its contents intact', () => { + const pulled = join(fsRootDir(tempDir), siteDir, 'single-file.php'); + assert.ok(existsSync(pulled), `Expected the named file at ${pulled}`); + assert.equal(readFileSync(pulled, 'utf-8'), ' { + const importedRoot = join(fsRootDir(tempDir), siteDir); + assert.ok(!existsSync(join(importedRoot, 'wp-admin')), + 'wp-admin must not be pulled when --only names one file'); + assert.ok(!existsSync(join(importedRoot, 'wp-content')), + 'wp-content must not be pulled when --only names one file'); + }); +}); From 460ae03ba7a1cbd3c06ea7b3b807a2825ca8e62f Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Thu, 13 Aug 2026 12:26:09 +0530 Subject: [PATCH 08/30] Trim comments to the load-bearing ones --- .../src/class-file-index-processor.php | 37 +++++-------------- packages/reprint-server/src/utils.php | 5 +-- tests/FileIndexEndpointRunnerTrait.php | 5 +-- tests/FileIndexProcessorTest.php | 2 - 4 files changed, 13 insertions(+), 36 deletions(-) diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 46440e52f..73dbbd9e5 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -99,8 +99,7 @@ public static function start( bool $include_caches, string $storage_path ): self { - // Canonical paths keep configured roots and followed links in one - // namespace. A root may also be one file named by --include. + // A root may be one file named by --include. $canonical_index_directory = \WordPress\Reprint\Exporter\canonical_root_path($index_directory); if ($canonical_index_directory === null) { throw new InvalidArgumentException( @@ -182,13 +181,11 @@ public static function start( } } - // These share the first step's cursor boundary: the endpoint always takes - // one step before its budget check, and resume() begins with none of them. + // Emitted by the first step; resume() begins with none of them. foreach ($path_roots as $path_root) { clearstatcache(true, $path_root); $stat = @lstat($path_root); if ($stat === false) { - // Present when canonicalized moments ago, so it has just disappeared. continue; } $inspected_path = self::index_entries_for_path($path_root, $stat, $follow_symlinks); @@ -745,11 +742,7 @@ private static function position_after_name(array $directory_names, string $afte * @param array $stat lstat() result for the path. * @param bool $follow_symlinks Whether directory links may reveal intermediate links. * @return array { - * Entries and the type recorded for this path. - * - * @type array[] $entries Intermediate link entries, then the path's own - * entry. A non-empty directory contributes no - * entry of its own because its descendants imply it. + * @type array[] $entries Intermediate links, then the path's own entry. * @type string $type One of file, link, dir, or other. * } */ @@ -758,9 +751,6 @@ private static function index_entries_for_path( array $stat, bool $follow_symlinks ): array { - // Translate platform mode bits into the four types understood by the - // file index. A directory link may also reveal intermediate links that - // canonicalization would otherwise hide. $mode = $stat["mode"] & self::STAT_TYPE_MASK; $type = "file"; $link_target = null; @@ -778,9 +768,7 @@ private static function index_entries_for_path( $type = "other"; } - // Build the index entry from the one successful lstat() call. File and - // link sizes participate in push change detection; directory size does - // not describe its descendants and is normalized to zero. + // Directory size does not describe its descendants, so it is zeroed. $item = [ "path" => $path, "ctime" => (int) ( isset($stat["ctime"]) ? $stat["ctime"] : 0 ), @@ -791,10 +779,8 @@ private static function index_entries_for_path( $item["target"] = $link_target; } if ($type === "dir") { - // The index describes physical emptiness, not emptiness after - // exclusions. A cache or Reprint-storage child still makes its - // parent non-empty; calling that parent empty could turn an - // intentionally omitted descendant into destructive push work. + // Physical emptiness, not emptiness after exclusions: calling an + // excluded child's parent empty would make push delete it. $directory_handle = @opendir($path); if ($directory_handle !== false) { $item["empty"] = true; @@ -810,17 +796,12 @@ private static function index_entries_for_path( } closedir($directory_handle); } - // When the directory cannot be inspected, leave "empty" absent. - // Pull reports the later directory-open failure. A push index - // builder can stop instead of treating unknown descendants as - // deletions. + // An absent "empty" means unknown, so push must not infer deletions. } - // Intermediate links and the inspected path belong to the same step - // because the cursor cannot stop between them without losing one. + // One step: the cursor cannot stop between a link and its intermediates. $entries = $intermediate_symlinks; - // A descendant implies its non-empty ancestors. Keep explicit rows - // only for empty directories, which have no descendant to imply them. + // A descendant implies its ancestors, so only empty directories need a row. if ( $type !== "dir" || !isset($item["empty"]) diff --git a/packages/reprint-server/src/utils.php b/packages/reprint-server/src/utils.php index 3461ec028..8471f0050 100644 --- a/packages/reprint-server/src/utils.php +++ b/packages/reprint-server/src/utils.php @@ -397,9 +397,8 @@ function path_is_same_as_or_descendant_of($path, $ancestor): bool /** * Canonicalizes one configured root path. * - * A directory resolves through realpath(). Any other path keeps its final - * component, because a root may be a single file named by --include and - * resolving that component would index a symlinked file under its target. + * A non-directory keeps its final component, so a file named by --include is + * indexed as itself rather than as the target of a symlink. * * @param string $path Root path as configured. * @return string|null Canonical root path, or null when it does not exist. diff --git a/tests/FileIndexEndpointRunnerTrait.php b/tests/FileIndexEndpointRunnerTrait.php index f8ca17a41..836c699f4 100644 --- a/tests/FileIndexEndpointRunnerTrait.php +++ b/tests/FileIndexEndpointRunnerTrait.php @@ -3,10 +3,9 @@ declare(strict_types=1); /** - * Runs endpoint_file_index() and returns the paths it indexed. + * Runs endpoint_file_index() in a subprocess and returns the paths it indexed. * - * A subprocess because the endpoint streams a gzipped multipart response - * straight to stdout and installs its own error handlers. + * A subprocess because the endpoint streams to stdout and installs handlers. */ trait FileIndexEndpointRunnerTrait { diff --git a/tests/FileIndexProcessorTest.php b/tests/FileIndexProcessorTest.php index 7d90a288c..e5707fd2b 100644 --- a/tests/FileIndexProcessorTest.php +++ b/tests/FileIndexProcessorTest.php @@ -364,8 +364,6 @@ public function testResumingAfterTheFirstStepDoesNotRepeatFileRoots(): void * @param bool $includeCaches Whether generated caches are included. * @param bool $resumeAfterEveryStep Whether to reopen from the cursor each step. * @return array { - * Completed traversal. - * * @type array[] $entries File-index entries. * @type string[] $statuses Status returned by every step. * } From a15a5bb8926e1c8dc0330b022fea64b3475af221 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Thu, 13 Aug 2026 13:58:23 +0530 Subject: [PATCH 09/30] Omit and bound named file-index roots Named paths were indexed without the storage-path and default-skip checks that traversal applies, so a file inside the Reprint storage path was indexed when selected explicitly. They were also all inspected in start() and returned by one step, which could exceed the batch size and left them out of the cursor. Each named path is now one step, carried in the cursor, and subject to the same omissions as a path reached by traversal. --- .../src/class-file-index-processor.php | 113 ++++++++++++++---- tests/FileIndexProcessorTest.php | 64 +++++++++- 2 files changed, 149 insertions(+), 28 deletions(-) diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 73dbbd9e5..9f1c31a8b 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -61,6 +61,9 @@ final class FileIndexProcessor { /** @var array[] Intermediate symlinks emitted before a new traversal begins. */ private $initial_index_entries; + /** @var string[] Named paths still to inspect, one per step, before traversal. */ + private $pending_path_roots = []; + /** @var string[]|null Sorted names in the current directory. */ private $current_directory_names = null; @@ -172,27 +175,6 @@ public static function start( self::find_parent_symlinks($directory) ); } - // dirname() so a symlinked file is not repeated as an intermediate entry. - foreach ($path_roots as $path_root) { - $initial_index_entries = array_merge( - $initial_index_entries, - self::find_parent_symlinks(dirname($path_root)) - ); - } - } - - // Emitted by the first step; resume() begins with none of them. - foreach ($path_roots as $path_root) { - clearstatcache(true, $path_root); - $stat = @lstat($path_root); - if ($stat === false) { - continue; - } - $inspected_path = self::index_entries_for_path($path_root, $stat, $follow_symlinks); - $initial_index_entries = array_merge( - $initial_index_entries, - $inspected_path["entries"] - ); } // X-Index-Dir names a directory, so a named path reports its parent. @@ -207,7 +189,8 @@ public static function start( $storage_path, $directory_stack, $reported_index_directory, - $initial_index_entries + $initial_index_entries, + $path_roots ); } @@ -273,6 +256,24 @@ public static function resume( ]; } + // Named paths not yet inspected. Absent from cursors written before + // one request could carry them. + $pending_path_roots = []; + $encoded_path_roots = isset($cursor["paths"]) ? $cursor["paths"] : []; + if (!is_array($encoded_path_roots)) { + throw new InvalidArgumentException("Index cursor paths must be an array"); + } + foreach ($encoded_path_roots as $encoded_path_root) { + if (!is_string($encoded_path_root) || $encoded_path_root === "") { + throw new InvalidArgumentException("Index cursor path entry must be a non-empty string"); + } + $path_root = base64_decode($encoded_path_root, true); + if ($path_root === false || $path_root === "") { + throw new InvalidArgumentException("Index cursor path entry has invalid encoding"); + } + $pending_path_roots[] = $path_root; + } + // During continuation, the active directory is the best description // of what this request is indexing. A completed cursor has no active // directory, so it falls back to the first configured root. @@ -287,7 +288,8 @@ public static function resume( $storage_path, $directory_stack, $index_directory, - [] + [], + $pending_path_roots ); } @@ -319,6 +321,14 @@ public function next_index_step(): bool return true; } + // One named path per step, before traversal. Inspecting them here + // rather than in start() keeps every step bounded and lets the cursor + // carry the ones still pending. + if (!empty($this->pending_path_roots)) { + $this->index_next_path_root(); + return true; + } + // Load the directory at the top of the stack only when no sorted name // list is retained. A directory failure is itself a step; an empty // stack means traversal has no further event. @@ -439,7 +449,8 @@ public function get_directory_error() * @return array { * File-index cursor. * - * @type array[] $stack Active directories with base64-encoded path names. + * @type array[] $stack Active directories with base64-encoded path names. + * @type string[] $paths Base64-encoded named paths not yet inspected. * } */ public function get_cursor(): array @@ -451,7 +462,11 @@ public function get_cursor(): array "after" => $frame["after"] !== null ? base64_encode($frame["after"]) : null, ]; } - return ["stack" => $encoded_stack]; + $encoded_path_roots = []; + foreach ($this->pending_path_roots as $path_root) { + $encoded_path_roots[] = base64_encode($path_root); + } + return ["stack" => $encoded_stack, "paths" => $encoded_path_roots]; } /** @@ -564,6 +579,7 @@ public static function path_is_default_skipped(string $path): bool * @param array[] $directory_stack Active directory stack. * @param string $index_directory Directory reported by the endpoint. * @param array[] $initial_index_entries Intermediate symlinks emitted before traversal. + * @param string[] $pending_path_roots Named paths still to inspect, one per step. */ private function __construct( array $directories, @@ -572,7 +588,8 @@ private function __construct( string $storage_path, array $directory_stack, string $index_directory, - array $initial_index_entries + array $initial_index_entries, + array $pending_path_roots = [] ) { $this->directories = $directories; $this->follow_symlinks = $follow_symlinks; @@ -581,6 +598,7 @@ private function __construct( $this->directory_stack = $directory_stack; $this->index_directory = $index_directory; $this->initial_index_entries = $initial_index_entries; + $this->pending_path_roots = $pending_path_roots; } /** @@ -733,6 +751,49 @@ private static function position_after_name(array $directory_names, string $afte return $low; } + /** + * Inspects the next named path and settles its cursor entry. + * + * Omissions match traversal: a path under a default-skipped directory or + * under the Reprint storage path is skipped even though the caller named + * it, so selecting one file cannot reach what selecting its directory + * cannot. + */ + private function index_next_path_root(): void + { + // Settle the cursor before any stat call, so a path that disappears + // or is omitted is not inspected again after a resume. + $path_root = array_shift($this->pending_path_roots); + + if (!$this->include_caches && self::path_is_default_skipped($path_root)) { + $this->step_status = self::STATUS_SKIPPED; + return; + } + if ( + $this->storage_path !== "" + && \WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of($path_root, $this->storage_path) + ) { + $this->step_status = self::STATUS_SKIPPED; + return; + } + + clearstatcache(true, $path_root); + $stat = @lstat($path_root); + if ($stat === false) { + $this->step_status = self::STATUS_PATH_UNAVAILABLE; + return; + } + + $entries = []; + if ($this->follow_symlinks) { + // dirname() so a symlinked file is not repeated as an intermediate entry. + $entries = self::find_parent_symlinks(dirname($path_root)); + } + $inspected_path = self::index_entries_for_path($path_root, $stat, $this->follow_symlinks); + $this->index_entries = array_merge($entries, $inspected_path["entries"]); + $this->step_status = self::STATUS_INDEXED; + } + /** * Builds the index entries describing one inspected path. * diff --git a/tests/FileIndexProcessorTest.php b/tests/FileIndexProcessorTest.php index e5707fd2b..cd13a9762 100644 --- a/tests/FileIndexProcessorTest.php +++ b/tests/FileIndexProcessorTest.php @@ -318,9 +318,10 @@ public function testMissingFileRootNamesTheObservedPath(): void FileIndexProcessor::start([$docroot], $missingPath, false, false, ''); } - public function testFileRootInsideASkippedDirectoryIsStillIndexed(): void + public function testFileRootInsideASkippedDirectoryIsOmitted(): void { - // path_is_default_skipped() is tested against children, never against a root. + // Selecting one file must not reach what selecting its directory cannot: + // a directory root here indexes nothing, because its children are skipped. $docroot = $this->tempDir . '/site'; mkdir($docroot . '/wp-content/cache', 0755, true); file_put_contents($docroot . '/wp-content/cache/keep.php', 'collectEntries([$cachedPath], $cachedPath, false); + $this->assertSame([], $result['entries']); + $this->assertContains(FileIndexProcessor::STATUS_SKIPPED, $result['statuses']); + } + + public function testFileRootInsideASkippedDirectoryIsIndexedWhenCachesAreIncluded(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot . '/wp-content/cache', 0755, true); + file_put_contents($docroot . '/wp-content/cache/keep.php', 'collectEntries([$cachedPath], $cachedPath, true); + $this->assertCount(1, $result['entries']); $this->assertSame($cachedPath, $result['entries'][0]['path']); } + public function testFileRootInsideTheStoragePathIsNeverIndexed(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot . '/.reprint', 0755, true); + file_put_contents($docroot . '/.reprint/sender.json', '{"token":"secret"}'); + $storagePath = (string) realpath($docroot . '/.reprint'); + $senderPath = $storagePath . '/sender.json'; + + $processor = FileIndexProcessor::start([$senderPath], $senderPath, false, true, $storagePath); + $entries = []; + while ($processor->next_index_step()) { + foreach ($processor->get_index_entries() as $entry) { + $entries[] = $entry; + } + } + $processor->close(); + + $this->assertSame([], $entries, 'Reprint storage must never be indexed, even when named'); + } + + public function testEachNamedPathIsOneStepAndSurvivesAResume(): void + { + $docroot = $this->tempDir . '/site'; + mkdir($docroot, 0755, true); + $roots = []; + for ($index = 0; $index < 5; $index++) { + $path = $docroot . '/file' . $index . '.php'; + file_put_contents($path, 'collectEntries($roots, $roots[0]); + $resumed = $this->collectEntries($roots, $roots[0], true, true); + + $this->assertSame($roots, array_column($uninterrupted['entries'], 'path')); + $this->assertSame($roots, array_column($resumed['entries'], 'path')); + $this->assertSame( + 5, + count(array_filter( + $uninterrupted['statuses'], + static fn(?string $status): bool => $status === FileIndexProcessor::STATUS_INDEXED + )), + 'Each named path must be its own step, not one step returning all of them' + ); + } + public function testResumingAfterTheFirstStepDoesNotRepeatFileRoots(): void { $docroot = $this->tempDir . '/site'; From e78f69b2a0d0bafcde45b1df45ea5ccb655a5181 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Thu, 13 Aug 2026 14:05:07 +0530 Subject: [PATCH 10/30] Trim comments to one line each --- .../src/class-file-index-processor.php | 22 +++++-------------- packages/reprint-server/src/utils.php | 3 +-- tests/ExportResolveDirectoriesTest.php | 3 +-- tests/FileIndexEndpointRunnerTrait.php | 4 +--- tests/FileIndexProcessorTest.php | 3 +-- .../tests/import-55-only-file-path.test.js | 6 +---- 6 files changed, 10 insertions(+), 31 deletions(-) diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 9f1c31a8b..91ad82f3d 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -256,8 +256,7 @@ public static function resume( ]; } - // Named paths not yet inspected. Absent from cursors written before - // one request could carry them. + // Absent from cursors written before this field existed. $pending_path_roots = []; $encoded_path_roots = isset($cursor["paths"]) ? $cursor["paths"] : []; if (!is_array($encoded_path_roots)) { @@ -321,9 +320,7 @@ public function next_index_step(): bool return true; } - // One named path per step, before traversal. Inspecting them here - // rather than in start() keeps every step bounded and lets the cursor - // carry the ones still pending. + // One per step, before traversal, so each step stays bounded. if (!empty($this->pending_path_roots)) { $this->index_next_path_root(); return true; @@ -752,17 +749,11 @@ private static function position_after_name(array $directory_names, string $afte } /** - * Inspects the next named path and settles its cursor entry. - * - * Omissions match traversal: a path under a default-skipped directory or - * under the Reprint storage path is skipped even though the caller named - * it, so selecting one file cannot reach what selecting its directory - * cannot. + * Inspects one named path, applying the omissions traversal applies. */ private function index_next_path_root(): void { - // Settle the cursor before any stat call, so a path that disappears - // or is omitted is not inspected again after a resume. + // Settle the cursor first so a skipped or vanished path is not retried. $path_root = array_shift($this->pending_path_roots); if (!$this->include_caches && self::path_is_default_skipped($path_root)) { @@ -797,8 +788,6 @@ private function index_next_path_root(): void /** * Builds the index entries describing one inspected path. * - * Static because start() schedules roots before the processor exists. - * * @param string $path Absolute path already confirmed by lstat(). * @param array $stat lstat() result for the path. * @param bool $follow_symlinks Whether directory links may reveal intermediate links. @@ -840,8 +829,7 @@ private static function index_entries_for_path( $item["target"] = $link_target; } if ($type === "dir") { - // Physical emptiness, not emptiness after exclusions: calling an - // excluded child's parent empty would make push delete it. + // Physical emptiness, not post-exclusion: else push deletes the parent. $directory_handle = @opendir($path); if ($directory_handle !== false) { $item["empty"] = true; diff --git a/packages/reprint-server/src/utils.php b/packages/reprint-server/src/utils.php index 8471f0050..dca43a164 100644 --- a/packages/reprint-server/src/utils.php +++ b/packages/reprint-server/src/utils.php @@ -397,8 +397,7 @@ function path_is_same_as_or_descendant_of($path, $ancestor): bool /** * Canonicalizes one configured root path. * - * A non-directory keeps its final component, so a file named by --include is - * indexed as itself rather than as the target of a symlink. + * A non-directory keeps its final component, so a named symlink stays itself. * * @param string $path Root path as configured. * @return string|null Canonical root path, or null when it does not exist. diff --git a/tests/ExportResolveDirectoriesTest.php b/tests/ExportResolveDirectoriesTest.php index d51dd0059..eb65f90c9 100644 --- a/tests/ExportResolveDirectoriesTest.php +++ b/tests/ExportResolveDirectoriesTest.php @@ -6,8 +6,7 @@ require_once dirname(__DIR__) . '/packages/reprint-server/src/export.php'; -// Loading export.php installs process-wide handlers that would otherwise -// swallow unrelated failures — or exit() — in later tests. +// Loading export.php installs handlers that would exit() on a later test. restore_error_handler(); restore_exception_handler(); diff --git a/tests/FileIndexEndpointRunnerTrait.php b/tests/FileIndexEndpointRunnerTrait.php index 836c699f4..2cf9f4723 100644 --- a/tests/FileIndexEndpointRunnerTrait.php +++ b/tests/FileIndexEndpointRunnerTrait.php @@ -3,9 +3,7 @@ declare(strict_types=1); /** - * Runs endpoint_file_index() in a subprocess and returns the paths it indexed. - * - * A subprocess because the endpoint streams to stdout and installs handlers. + * Runs endpoint_file_index() in a subprocess, since it streams to stdout. */ trait FileIndexEndpointRunnerTrait { diff --git a/tests/FileIndexProcessorTest.php b/tests/FileIndexProcessorTest.php index cd13a9762..020bde610 100644 --- a/tests/FileIndexProcessorTest.php +++ b/tests/FileIndexProcessorTest.php @@ -320,8 +320,7 @@ public function testMissingFileRootNamesTheObservedPath(): void public function testFileRootInsideASkippedDirectoryIsOmitted(): void { - // Selecting one file must not reach what selecting its directory cannot: - // a directory root here indexes nothing, because its children are skipped. + // A directory root here indexes nothing, so a file root must not either. $docroot = $this->tempDir . '/site'; mkdir($docroot . '/wp-content/cache', 0755, true); file_put_contents($docroot . '/wp-content/cache/keep.php', ' - * - * `--only` accepts a single file, not just a directory (issue #539). - */ +/** Test 55: `--only` accepts a single file, not just a directory (issue #539). */ import { describe, it, beforeAll, afterAll } from 'vitest'; import assert from 'node:assert/strict'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; From e6d4fe6923b45f184f019f0c3876d1232f2da15c Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 11:05:27 +0200 Subject: [PATCH 11/30] Refactor file-index roots around structured records --- .../src/class-file-index-processor.php | 254 ++++++++++++++---- packages/reprint-server/src/export.php | 121 ++++++++- tests/ExportResolveDirectoriesTest.php | 26 +- 3 files changed, 332 insertions(+), 69 deletions(-) diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 91ad82f3d..8a2cd1000 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -40,6 +40,9 @@ final class FileIndexProcessor { const STAT_TYPE_FILE = 0100000; const STAT_TYPE_DIR = 0040000; + /** @var array[] Configured file-index roots, in requested order. */ + private $roots; + /** @var string[] Canonical directories allowed during traversal. */ private $directories; @@ -61,7 +64,7 @@ final class FileIndexProcessor { /** @var array[] Intermediate symlinks emitted before a new traversal begins. */ private $initial_index_entries; - /** @var string[] Named paths still to inspect, one per step, before traversal. */ + /** @var string[] Requested paths still to inspect, one per step, before traversal. */ private $pending_path_roots = []; /** @var string[]|null Sorted names in the current directory. */ @@ -86,71 +89,80 @@ final class FileIndexProcessor { private $closed = false; /** - * Starts a traversal at the requested directory and schedules the other roots. + * Starts a traversal at the requested root and schedules the other roots. * - * @param string[] $directories Canonical directories allowed during traversal. - * @param string $index_directory Directory where traversal begins. + * @param array[] $roots Structured file-index roots. + * @param string $index_directory Requested root where indexing begins. * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories. * @param bool $include_caches Whether generated caches and development files are included. * @param string $storage_path Reprint storage path omitted from the index, or an empty string. * @return self New file-index processor. */ public static function start( - array $directories, + array $roots, string $index_directory, bool $follow_symlinks, bool $include_caches, string $storage_path ): self { - // A root may be one file named by --include. - $canonical_index_directory = \WordPress\Reprint\Exporter\canonical_root_path($index_directory); - if ($canonical_index_directory === null) { - throw new InvalidArgumentException( - "list_dir does not exist or is not accessible: {$index_directory}" - ); + $legacy_string_roots = isset($roots[0]) && is_string($roots[0]); + $roots = self::normalize_roots($roots); + $requested_index_root = \WordPress\Reprint\Exporter\normalize_path($index_directory); + $index_root = null; + foreach ($roots as $root) { + if ($root["requested_path"] === $requested_index_root) { + $index_root = $root; + break; + } + } + if ($index_root === null) { + $resolved_index_root = @realpath($index_directory); + if (!$legacy_string_roots || $resolved_index_root === false || !is_dir($resolved_index_root)) { + throw new InvalidArgumentException("list_dir is not a configured file-index root: {$index_directory}"); + } + $index_root = [ + "requested_path" => $requested_index_root, + "resolved_path" => $resolved_index_root, + "type" => "directory", + ]; } // An ordinary traversal must begin inside a configured root. Following // links deliberately relaxes that boundary because a link target may // be outside every configured root and still belong in the index. - if (!$follow_symlinks && !\WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of($canonical_index_directory, $directories)) { + $directories = self::resolved_directory_roots($roots, $follow_symlinks); + if (!$legacy_string_roots && !$follow_symlinks && !empty($directories) && $index_root["resolved_path"] !== null && !\WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of($index_root["resolved_path"], $directories)) { throw new InvalidArgumentException( - "list_dir is outside of allowed roots: {$canonical_index_directory}" + "list_dir is outside of allowed roots: {$requested_index_root}" ); } // Visit the requested directory first, followed by every other root in // stable byte order. Stable ordering makes a cursor independent of the // order in which configuration discovered the additional roots. - $ordered_directories = [$canonical_index_directory]; - $extra_directories = []; - foreach ($directories as $directory) { - if ($directory !== $canonical_index_directory) { - $extra_directories[] = $directory; - } - } - sort($extra_directories, SORT_STRING); - foreach ($extra_directories as $directory) { - // Parent and child roots both remain scheduled. On wp.com Atomic, - // for example, the document root may be a parent of the primary - // WordPress root while also containing a separate wp-content. - // Dropping the parent would hide those plugins and themes. When - // traversal later reaches the scheduled child, the root check - // prevents entering it a second time. - if (!in_array($directory, $ordered_directories, true)) { - $ordered_directories[] = $directory; + $ordered_roots = [$index_root]; + $extra_roots = []; + foreach ($roots as $root) { + if ($root["requested_path"] !== $index_root["requested_path"]) { + $extra_roots[] = $root; } } + usort($extra_roots, static function (array $left, array $right): int { + return strcmp($left["requested_path"], $right["requested_path"]); + }); + $ordered_roots = array_merge($ordered_roots, $extra_roots); // A root that is not a directory is one named path; nothing to walk. $directory_roots = []; $path_roots = []; - foreach ($ordered_directories as $directory) { - clearstatcache(true, $directory); - if (is_dir($directory)) { - $directory_roots[] = $directory; - } else { - $path_roots[] = $directory; + foreach ($ordered_roots as $root) { + if ($root["type"] === "directory" || ($follow_symlinks && $root["type"] === "symlink" && is_dir($root["resolved_path"]))) { + if (!in_array($root["resolved_path"], $directory_roots, true)) { + $directory_roots[] = $root["resolved_path"]; + } + } + if ($root["type"] !== "directory") { + $path_roots[] = $root["requested_path"]; } } @@ -169,20 +181,21 @@ public static function start( // found here must precede ordinary directory entries. $initial_index_entries = []; if ($follow_symlinks) { - foreach ($directory_roots as $directory) { - $initial_index_entries = array_merge( - $initial_index_entries, - self::find_parent_symlinks($directory) - ); + foreach ($ordered_roots as $root) { + if ($root["type"] === "directory") { + $parent_path = $legacy_string_roots ? $root["resolved_path"] : $root["requested_path"]; + $initial_index_entries = array_merge($initial_index_entries, self::find_parent_symlinks($parent_path)); + } } } // X-Index-Dir names a directory, so a named path reports its parent. - $reported_index_directory = in_array($canonical_index_directory, $path_roots, true) - ? dirname($canonical_index_directory) - : $canonical_index_directory; + $reported_index_directory = $index_root["type"] === "directory" + ? $index_root["resolved_path"] + : dirname($index_root["requested_path"]); return new self( + $roots, $directories, $follow_symlinks, $include_caches, @@ -197,7 +210,7 @@ public static function start( /** * Resumes traversal from a cursor returned by get_cursor(). * - * @param string[] $directories Canonical directories allowed during traversal. + * @param array[] $roots Structured file-index roots. * @param string $cursor_json JSON cursor returned by the preceding request. * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories. * @param bool $include_caches Whether generated caches and development files are included. @@ -205,12 +218,15 @@ public static function start( * @return self Resumed file-index processor. */ public static function resume( - array $directories, + array $roots, string $cursor_json, bool $follow_symlinks, bool $include_caches, string $storage_path ): self { + $roots = self::normalize_roots($roots); + $directories = self::resolved_directory_roots($roots, $follow_symlinks); + // A cursor is caller-held continuation state. Reject malformed JSON or // a missing stack before any filesystem work begins. $cursor = json_decode($cursor_json, true); @@ -281,6 +297,7 @@ public static function resume( : ( isset($directories[0]) ? $directories[0] : "/" ); return new self( + $roots, $directories, $follow_symlinks, $include_caches, @@ -569,6 +586,7 @@ public static function path_is_default_skipped(string $path): bool /** * Initializes common traversal state. * + * @param array[] $roots Structured file-index roots. * @param string[] $directories Canonical directories allowed during traversal. * @param bool $follow_symlinks Whether directory symlinks may leave the allowed directories. * @param bool $include_caches Whether generated caches and development files are included. @@ -579,6 +597,7 @@ public static function path_is_default_skipped(string $path): bool * @param string[] $pending_path_roots Named paths still to inspect, one per step. */ private function __construct( + array $roots, array $directories, bool $follow_symlinks, bool $include_caches, @@ -588,6 +607,7 @@ private function __construct( array $initial_index_entries, array $pending_path_roots = [] ) { + $this->roots = $roots; $this->directories = $directories; $this->follow_symlinks = $follow_symlinks; $this->include_caches = $include_caches; @@ -754,7 +774,18 @@ private static function position_after_name(array $directory_names, string $afte private function index_next_path_root(): void { // Settle the cursor first so a skipped or vanished path is not retried. - $path_root = array_shift($this->pending_path_roots); + $requested_path = array_shift($this->pending_path_roots); + $root = $this->find_root($requested_path); + if ($root === null) { + throw new InvalidArgumentException("Index cursor names a root absent from this request: {$requested_path}"); + } + + if ($root["type"] === "missing") { + $this->step_status = self::STATUS_PATH_UNAVAILABLE; + return; + } + + $path_root = $root["requested_path"]; if (!$this->include_caches && self::path_is_default_skipped($path_root)) { $this->step_status = self::STATUS_SKIPPED; @@ -781,10 +812,137 @@ private function index_next_path_root(): void $entries = self::find_parent_symlinks(dirname($path_root)); } $inspected_path = self::index_entries_for_path($path_root, $stat, $this->follow_symlinks); - $this->index_entries = array_merge($entries, $inspected_path["entries"]); + + // A selected symlink always remains at its requested path. When + // followed, its target content is emitted in the physical namespace + // that normal traversal already uses. Two aliases may therefore share + // one target entry while both link entries remain present. + $entries = array_merge($entries, $inspected_path["entries"]); + if ( + $this->follow_symlinks + && $root["type"] === "symlink" + && $root["resolved_path"] !== null + && !is_dir($root["resolved_path"]) + && !$this->resolved_target_was_indexed($root) + ) { + clearstatcache(true, $root["resolved_path"]); + $target_stat = @lstat($root["resolved_path"]); + if (is_array($target_stat)) { + $target = self::index_entries_for_path($root["resolved_path"], $target_stat, false); + $entries = array_merge($entries, $target["entries"]); + } + } + if ( + $root["type"] === "file" + && $root["resolved_path"] !== null + && $root["resolved_path"] !== $root["requested_path"] + ) { + // A regular root reached through no link normally has identical + // coordinates. Keep this branch for records supplied by callers + // which already normalized a physical file root. + $entries = self::index_entries_for_path($root["resolved_path"], $stat, false)["entries"]; + } + $this->index_entries = $entries; $this->step_status = self::STATUS_INDEXED; } + /** Finds the current structured root by its requested path. */ + private function find_root(string $requested_path): ?array + { + foreach ($this->roots as $root) { + if ($root["requested_path"] === $requested_path) { + return $root; + } + } + return null; + } + + /** Whether an earlier named root already emitted this physical target. */ + private function resolved_target_was_indexed(array $root): bool + { + foreach ($this->roots as $candidate) { + if ($candidate["requested_path"] === $root["requested_path"]) { + return false; + } + if ( + $candidate["resolved_path"] === $root["resolved_path"] + && !in_array($candidate["requested_path"], $this->pending_path_roots, true) + ) { + return true; + } + } + return false; + } + + /** + * Normalizes legacy string roots and validates structured root records. + * + * @param array[]|string[] $roots File-index roots. + * @return array[] Structured roots. + */ + private static function normalize_roots(array $roots): array + { + $normalized = []; + foreach ($roots as $root) { + if (is_string($root)) { + clearstatcache(true, $root); + $stat = @lstat($root); + if ($stat === false) { + throw new InvalidArgumentException("File-index root does not exist or is not accessible: {$root}"); + } + $requested_path = \WordPress\Reprint\Exporter\normalize_path($root); + $resolved_path = @realpath($requested_path); + $mode = $stat["mode"] & self::STAT_TYPE_MASK; + $type = $mode === self::STAT_TYPE_LINK ? "symlink" : ( is_dir($requested_path) ? "directory" : "file" ); + $normalized[] = [ + "requested_path" => $requested_path, + "resolved_path" => $resolved_path === false ? null : $resolved_path, + "type" => $type, + ]; + continue; + } + if (!is_array($root) || !isset($root["requested_path"], $root["type"])) { + throw new InvalidArgumentException("File-index roots must contain requested_path and type"); + } + if (!is_string($root["requested_path"]) || !is_string($root["type"])) { + throw new InvalidArgumentException("File-index root fields have invalid types"); + } + $resolved_path = $root["resolved_path"] ?? null; + if ($resolved_path !== null && !is_string($resolved_path)) { + throw new InvalidArgumentException("File-index root resolved_path has invalid type"); + } + if (!in_array($root["type"], ["directory", "file", "symlink", "missing"], true)) { + throw new InvalidArgumentException("File-index root type is invalid: {$root["type"]}"); + } + if ($root["type"] !== "missing" && ($resolved_path === null || $resolved_path === "")) { + throw new InvalidArgumentException("File-index root missing resolved_path: {$root["requested_path"]}"); + } + $normalized[] = [ + "requested_path" => \WordPress\Reprint\Exporter\normalize_path($root["requested_path"]), + "resolved_path" => $resolved_path, + "type" => $root["type"], + ]; + } + return $normalized; + } + + /** Returns physical directory roots, including followed directory links. */ + private static function resolved_directory_roots(array $roots, bool $follow_symlinks): array + { + $directories = []; + foreach ($roots as $root) { + if ( + $root["type"] === "directory" + || ( $follow_symlinks && $root["type"] === "symlink" && $root["resolved_path"] !== null && is_dir($root["resolved_path"]) ) + ) { + if (!in_array($root["resolved_path"], $directories, true)) { + $directories[] = $root["resolved_path"]; + } + } + } + return $directories; + } + /** * Builds the index entries describing one inspected path. * diff --git a/packages/reprint-server/src/export.php b/packages/reprint-server/src/export.php index 3334b03b8..02d6a8353 100644 --- a/packages/reprint-server/src/export.php +++ b/packages/reprint-server/src/export.php @@ -8,6 +8,7 @@ use function WordPress\Reprint\Exporter\build_pdo_dsn; use function WordPress\Reprint\Exporter\canonical_root_path; use function WordPress\Reprint\Exporter\json_encode_or_throw; +use function WordPress\Reprint\Exporter\normalize_path; use function WordPress\Reprint\Exporter\parse_size; use function WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of; use function WordPress\Reprint\Exporter\trim_right_slash; @@ -1280,7 +1281,7 @@ function endpoint_db_index( } /** - * Resolves directory paths from config. + * Resolves directory paths from config for operations which can walk only directories. */ function resolve_directories(array $config): array { @@ -1305,11 +1306,11 @@ function resolve_directories(array $config): array $directory = trim($directory); assert_valid_path($directory, "directory entry"); - // A root may be one file named by --include, so any existing path is valid. - $real_directory = canonical_root_path($directory); - if ($real_directory === null) { + clearstatcache(true, $directory); + $real_directory = @realpath($directory); + if ($real_directory === false || !is_dir($real_directory)) { throw new InvalidArgumentException( - "directory entry does not exist or is not accessible: {$directory}\n" . + "directory entry is not an accessible directory: {$directory}\n" . "Current working directory: " . getcwd() . "\n" . @@ -1328,6 +1329,108 @@ function resolve_directories(array $config): array return $directories; } +/** + * Resolves the configured roots for the file-index endpoint. + * + * requested_path retains the caller's normalized spelling. resolved_path is + * the physical target used for walking and target de-duplication. + * + * @return array[] { + * File-index roots. + * + * @type string $requested_path Configured normalized root path. + * @type string|null $resolved_path Physical root path, when available. + * @type string $type directory, file, symlink, or missing. + * } + */ +function resolve_file_index_roots(array $config): array +{ + $roots_input = $config["directory"] ?? null; + if (!$roots_input) { + throw new InvalidArgumentException("directory is required for files operation"); + } + + $roots = []; + foreach (is_array($roots_input) ? $roots_input : [$roots_input] as $root_input) { + if (!is_string($root_input)) { + throw new InvalidArgumentException("directory entries must be non-empty strings"); + } + $root_input = trim($root_input); + assert_valid_path($root_input, "directory entry"); + $requested_path = normalize_path($root_input); + clearstatcache(true, $requested_path); + $stat = @lstat($requested_path); + if ($stat === false) { + if (!empty($config["allow_missing_roots"]) && file_index_root_is_confirmed_absent($requested_path)) { + $roots[] = [ + "requested_path" => $requested_path, + "resolved_path" => null, + "type" => "missing", + ]; + continue; + } + throw new InvalidArgumentException( + "Selected file-index root does not exist or is not accessible: {$requested_path}" + ); + } + + $mode = $stat["mode"] & STAT_TYPE_MASK; + $type = $mode === STAT_TYPE_LINK ? "symlink" : ( is_dir($requested_path) ? "directory" : "file" ); + $resolved_path = @realpath($requested_path); + if ($type === "symlink" && $resolved_path === false) { + throw new InvalidArgumentException("Selected file-index root is a broken symlink: {$requested_path}"); + } + if ($resolved_path === false) { + throw new InvalidArgumentException( + "Selected file-index root does not exist or is not accessible: {$requested_path}" + ); + } + if (empty($config["follow_symlinks"])) { + $parent_link = file_index_parent_symlink($requested_path); + if ($parent_link !== null) { + throw new InvalidArgumentException( + "Selected file-index root {$requested_path} is reached through parent symlink " . + "{$parent_link["path"]} targeting {$parent_link["target"]}; use --follow-symlinks." + ); + } + } + $roots[] = [ + "requested_path" => $requested_path, + "resolved_path" => $resolved_path, + "type" => $type, + ]; + } + + return $roots; +} + +/** Returns whether the parent can confirm that a selected name is absent. */ +function file_index_root_is_confirmed_absent(string $requested_path): bool +{ + $parent = dirname($requested_path); + $names = @scandir($parent, SCANDIR_SORT_NONE); + return is_array($names) && !in_array(basename($requested_path), $names, true); +} + +/** Returns the first symlink in a requested root's parent path. */ +function file_index_parent_symlink(string $requested_path): ?array +{ + $current = "/"; + $parts = explode("/", trim(dirname($requested_path), "/")); + foreach ($parts as $part) { + if ($part === "") { + continue; + } + $current = wp_join_unix_paths($current, $part); + if (!@is_link($current)) { + continue; + } + $target = @readlink($current); + return ["path" => $current, "target" => $target === false ? "(unreadable)" : $target]; + } + return null; +} + /** * Returns lightweight preflight checks: filesystem accessibility, DB connectivity, * and environment details useful for diagnostics. @@ -2653,7 +2756,7 @@ function endpoint_file_index( // requests so path type transitions (symlink/file/dir) are seen correctly. clearstatcache(true); - $directories = resolve_directories($config); + $file_index_roots = resolve_file_index_roots($config); $batch_size = require_int_range( "batch_size", (int) ($config["batch_size"] ?? 5000), @@ -2668,7 +2771,7 @@ function endpoint_file_index( if (isset($config["cursor"])) { $file_index = FileIndexProcessor::resume( - $directories, + $file_index_roots, $config["cursor"], $follow_symlinks, $include_caches, @@ -2680,7 +2783,7 @@ function endpoint_file_index( throw new InvalidArgumentException("list_dir is required for file_index"); } $file_index = FileIndexProcessor::start( - $directories, + $file_index_roots, $list_directory, $follow_symlinks, $include_caches, @@ -2693,7 +2796,7 @@ function endpoint_file_index( } $list_directory = $file_index->get_index_directory(); - $filesystem_root = $directories[0] ?? "/"; + $filesystem_root = $file_index_roots[0]["resolved_path"] ?? "/"; prepare_streaming_response(); ['gz' => $gz, 'boundary' => $boundary] = begin_multipart_stream(); diff --git a/tests/ExportResolveDirectoriesTest.php b/tests/ExportResolveDirectoriesTest.php index eb65f90c9..dc87f891e 100644 --- a/tests/ExportResolveDirectoriesTest.php +++ b/tests/ExportResolveDirectoriesTest.php @@ -38,25 +38,27 @@ public function testDirectoryEntryStillResolves(): void $this->assertSame([realpath($this->tempDir . '/site')], $resolved); } - public function testFileEntryIsAccepted(): void + public function testDirectoryResolverRejectsAFileEntry(): void { - $resolved = resolve_directories([ - 'directory' => [$this->tempDir . '/site/wp-config.php'], - ]); - $this->assertSame( - [realpath($this->tempDir . '/site') . '/wp-config.php'], - $resolved - ); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('not an accessible directory'); + + resolve_directories(['directory' => [$this->tempDir . '/site/wp-config.php']]); } - public function testFileSymlinkEntryKeepsItsOwnPath(): void + public function testFileIndexResolverKeepsRequestedAndResolvedCoordinates(): void { - $resolved = resolve_directories([ + $roots = resolve_file_index_roots([ 'directory' => [$this->tempDir . '/site/config-link.php'], + 'follow_symlinks' => true, ]); $this->assertSame( - [realpath($this->tempDir . '/site') . '/config-link.php'], - $resolved + [[ + 'requested_path' => $this->tempDir . '/site/config-link.php', + 'resolved_path' => realpath($this->tempDir . '/site/wp-config.php'), + 'type' => 'symlink', + ]], + $roots ); } From 54762605ad7ffb4ce943888f300ebe8d351d0333 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 11:10:42 +0200 Subject: [PATCH 12/30] Support named file roots in files pull --- packages/reprint-client/src/import.php | 138 +++++++++++---- .../src/class-file-index-processor.php | 35 ++-- packages/reprint-server/src/export.php | 5 +- tests/ExportResolveDirectoriesTest.php | 26 +++ tests/FileIndexNamedRootTest.php | 159 ++++++++++++++++++ tests/Import/OnlyFilesPathPrefixDiffTest.php | 37 ++++ 6 files changed, 349 insertions(+), 51 deletions(-) create mode 100644 tests/FileIndexNamedRootTest.php diff --git a/packages/reprint-client/src/import.php b/packages/reprint-client/src/import.php index d3b116b0a..da867b093 100755 --- a/packages/reprint-client/src/import.php +++ b/packages/reprint-client/src/import.php @@ -3600,11 +3600,12 @@ private function build_files_pull_mirror_fetch_list(): void $local_relative_path = $local_entry["path"]; $remote_entry = $changed_path_diff->get_entry_in_new_index(); if ($remote_entry !== null) { - /** @var array{copy_source_path:string} $remote_entry */ + /** @var array{copy_source_path:string,type:string} $remote_entry */ if ( !$this->is_selected_for_pulling( $remote_entry["copy_source_path"], - true + true, + $remote_entry["type"] ) ) { continue; @@ -3631,6 +3632,7 @@ private function build_files_pull_mirror_fetch_list(): void || !$this->is_selected_for_pulling( $local_absolute_path, false, + $local_entry["type"], $included_local_absolute_path_prefixes, $excluded_local_absolute_path_prefixes ) @@ -7336,10 +7338,12 @@ private function fetch_file_batch( } $params = $this->get_tuned_params("file_fetch"); - // Always send directory[] – see comment in fetch_next_remote_index(). - $export_dirs = $this->get_export_directories(); - if (!empty($export_dirs)) { - $params["directory"] = $export_dirs; + // file_fetch retains its directory-only server contract. --only may + // name a file, so use the preflight directory roots rather than the + // scoped file-index roots used by fetch_next_remote_index(). + $fetch_directories = $this->get_root_directories_from_preflight(); + if (!empty($fetch_directories)) { + $params["directory"] = $fetch_directories; } $url = $this->build_url("file_fetch", $cursor, $params); $this->audit_log("Downloading file fetch from {$url}"); @@ -7646,6 +7650,10 @@ private function fetch_next_remote_index(?string $list_dir_override = null): boo if (!empty($export_dirs)) { $params["directory"] = $export_dirs; } + $missing_roots = $this->previously_indexed_selected_roots(); + if ($missing_roots !== []) { + $params["missing_roots"] = $missing_roots; + } $url = $this->build_url("file_index", $cursor, $params); $context = new StreamingContext(); @@ -7888,12 +7896,20 @@ private function compare_remote_indexes_and_build_fetch_list(): bool $remote_absolute_path = $index_diff->get_path(); $transition = $index_diff->get_path_transition(); if ($transition === "deleted") { + $remote_path_type = + $index_diff->get_path_type_in_old_index(); + if ($remote_path_type === null) { + throw new LogicException( + "Deleted remote index path is absent from the prior remote index: {$remote_absolute_path}" + ); + } // The remote index is a union across files-pull path // selections. Keep paths outside this run's selection. if ( $this->is_selected_for_pulling( $remote_absolute_path, - false + false, + $remote_path_type ) ) { $remote_deletion_root = @@ -7917,31 +7933,40 @@ private function compare_remote_indexes_and_build_fetch_list(): bool ); } } - } elseif ( - $transition !== "unchanged" - && $this->is_selected_for_pulling( - $remote_absolute_path, - true - ) - ) { - // Preserve-local protects only paths which no earlier - // files-pull recorded in the remote index. - $preserve_local_skip_reason = $transition === "added" - ? $this->should_skip_for_preserve_local( - $remote_absolute_path - ) - : null; - if ($preserve_local_skip_reason) { - $this->audit_log( - $preserve_local_skip_reason, - true + } elseif ($transition !== "unchanged") { + $remote_path_type = + $index_diff->get_path_type_in_new_index(); + if ($remote_path_type === null) { + throw new LogicException( + "Remote index path is absent from the next remote index: {$remote_absolute_path}" ); - $this->emit_skip_progress($remote_absolute_path); - } else { - $this->append_to_fetch_list( + } + if ( + $this->is_selected_for_pulling( $remote_absolute_path, - $fetch_list_file_handle - ); + true, + $remote_path_type + ) + ) { + // Preserve-local protects only paths which no earlier + // files-pull recorded in the remote index. + $preserve_local_skip_reason = $transition === "added" + ? $this->should_skip_for_preserve_local( + $remote_absolute_path + ) + : null; + if ($preserve_local_skip_reason) { + $this->audit_log( + $preserve_local_skip_reason, + true + ); + $this->emit_skip_progress($remote_absolute_path); + } else { + $this->append_to_fetch_list( + $remote_absolute_path, + $fetch_list_file_handle + ); + } } } @@ -9784,6 +9809,45 @@ private function resolve_remote_paths( return $minimal; } + /** + * Returns selected roots that the prior remote index confirms as tracked. + * + * The server may represent only these roots as an empty selected result + * when it can confirm their current absence. A newly typed missing path is + * still rejected at the endpoint. + * + * @return string[] Selected roots present in the prior remote index. + */ + private function previously_indexed_selected_roots(): array + { + if ($this->pull_only_files_with_path_prefixes === [] || !is_file($this->remote_index_file)) { + return []; + } + $remaining = array_fill_keys($this->pull_only_files_with_path_prefixes, true); + $handle = fopen($this->remote_index_file, 'r'); + if (!is_resource($handle)) { + throw new RuntimeException("Failed to open the current remote index for selected roots."); + } + try { + while ($remaining !== [] && ($line = fgets($handle)) !== false) { + $entry = json_decode($line, true); + if (!is_array($entry) || !isset($entry['path']) || !is_string($entry['path'])) { + continue; + } + $path = base64_decode($entry['path'], true); + if ($path !== false) { + unset($remaining[$path]); + } + } + } finally { + fclose($handle); + } + return array_values(array_diff( + $this->pull_only_files_with_path_prefixes, + array_keys($remaining) + )); + } + /** * Checks whether a remote or local path passes --include and --exclude. * @@ -9791,12 +9855,14 @@ private function resolve_remote_paths( * including followed symlink targets outside an include prefix. Locally * discovered paths still need that include check. Mirror supplies local * prefixes for those paths because remapping has changed their coordinates. - * An included root itself is not selected because the current remote index - * lists its contents, not the root entry. Exclusions always win. + * An included directory root itself is not selected because the current + * remote index lists its contents, not the root entry. Exclusions always + * win. * * @param string $path Remote or local absolute path to check. * @param bool $is_next_remote_index_entry Whether the server already applied * the include filter to this path. + * @param string $path_type Type recorded for the path in its index. * @param list|null $included_path_prefixes Include prefixes in the * path's coordinates, or * null for the remote prefixes. @@ -9807,6 +9873,7 @@ private function resolve_remote_paths( private function is_selected_for_pulling( string $path, bool $is_next_remote_index_entry, + string $path_type, ?array $included_path_prefixes = null, ?array $excluded_path_prefixes = null ): bool @@ -9824,7 +9891,12 @@ private function is_selected_for_pulling( $included_path_prefix ); if ($remainder === "") { - return false; + // Directory roots have no row in a freshly indexed tree, + // so their old row must survive a scoped delta. Named + // file and link roots do have one; their confirmed absence + // must remove the tracked local path. + $selected = $path_type !== "dir"; + break; } if ($remainder !== null) { $selected = true; diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 8a2cd1000..6f8fcb801 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -91,7 +91,7 @@ final class FileIndexProcessor { /** * Starts a traversal at the requested root and schedules the other roots. * - * @param array[] $roots Structured file-index roots. + * @param array[]|string[] $roots Structured roots or legacy directory roots. * @param string $index_directory Requested root where indexing begins. * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories. * @param bool $include_caches Whether generated caches and development files are included. @@ -210,7 +210,7 @@ public static function start( /** * Resumes traversal from a cursor returned by get_cursor(). * - * @param array[] $roots Structured file-index roots. + * @param array[]|string[] $roots Structured roots or legacy directory roots. * @param string $cursor_json JSON cursor returned by the preceding request. * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories. * @param bool $include_caches Whether generated caches and development files are included. @@ -812,18 +812,22 @@ private function index_next_path_root(): void $entries = self::find_parent_symlinks(dirname($path_root)); } $inspected_path = self::index_entries_for_path($path_root, $stat, $this->follow_symlinks); + $resolved_target_was_indexed = $root["resolved_path"] !== null + && $this->resolved_target_was_indexed($root); // A selected symlink always remains at its requested path. When // followed, its target content is emitted in the physical namespace // that normal traversal already uses. Two aliases may therefore share // one target entry while both link entries remain present. - $entries = array_merge($entries, $inspected_path["entries"]); + if (!( $root["type"] === "file" && $resolved_target_was_indexed )) { + $entries = array_merge($entries, $inspected_path["entries"]); + } if ( $this->follow_symlinks && $root["type"] === "symlink" && $root["resolved_path"] !== null && !is_dir($root["resolved_path"]) - && !$this->resolved_target_was_indexed($root) + && !$resolved_target_was_indexed ) { clearstatcache(true, $root["resolved_path"]); $target_stat = @lstat($root["resolved_path"]); @@ -836,11 +840,15 @@ private function index_next_path_root(): void $root["type"] === "file" && $root["resolved_path"] !== null && $root["resolved_path"] !== $root["requested_path"] + && !$resolved_target_was_indexed ) { // A regular root reached through no link normally has identical // coordinates. Keep this branch for records supplied by callers // which already normalized a physical file root. - $entries = self::index_entries_for_path($root["resolved_path"], $stat, false)["entries"]; + $entries = array_merge( + $entries, + self::index_entries_for_path($root["resolved_path"], $stat, false)["entries"] + ); } $this->index_entries = $entries; $this->step_status = self::STATUS_INDEXED; @@ -861,10 +869,9 @@ private function find_root(string $requested_path): ?array private function resolved_target_was_indexed(array $root): bool { foreach ($this->roots as $candidate) { - if ($candidate["requested_path"] === $root["requested_path"]) { - return false; - } if ( + $candidate["requested_path"] !== $root["requested_path"] + && $candidate["resolved_path"] === $root["resolved_path"] && !in_array($candidate["requested_path"], $this->pending_path_roots, true) ) { @@ -1096,8 +1103,9 @@ private static function find_parent_symlinks(string $absolute_path): array $parts = explode("/", $absolute_path); $current = ""; - // Inspect each accumulated parent path. After a link, continue from its - // canonical location so later segments refer to the path PHP will use. + // Keep the requested spelling while inspecting each parent. PHP follows + // a parent link when checking the next component, so changing $current + // to realpath() would turn later emitted links into physical paths. foreach ($parts as $part) { if ($part === "") { $current = "/"; @@ -1122,13 +1130,6 @@ private static function find_parent_symlinks(string $absolute_path): array "intermediate" => true, ]; } - - // Resolve only the parent already inspected. This keeps the walk - // iterative and leaves any later link visible to the next segment. - $canonical_current = @realpath($current); - if ($canonical_current !== false) { - $current = $canonical_current; - } } return $entries; } diff --git a/packages/reprint-server/src/export.php b/packages/reprint-server/src/export.php index 02d6a8353..f81c929d1 100644 --- a/packages/reprint-server/src/export.php +++ b/packages/reprint-server/src/export.php @@ -1361,7 +1361,10 @@ function resolve_file_index_roots(array $config): array clearstatcache(true, $requested_path); $stat = @lstat($requested_path); if ($stat === false) { - if (!empty($config["allow_missing_roots"]) && file_index_root_is_confirmed_absent($requested_path)) { + $missing_roots = isset($config["missing_roots"]) && is_array($config["missing_roots"]) + ? $config["missing_roots"] + : []; + if (in_array($requested_path, $missing_roots, true) && file_index_root_is_confirmed_absent($requested_path)) { $roots[] = [ "requested_path" => $requested_path, "resolved_path" => null, diff --git a/tests/ExportResolveDirectoriesTest.php b/tests/ExportResolveDirectoriesTest.php index dc87f891e..e4bef5a5b 100644 --- a/tests/ExportResolveDirectoriesTest.php +++ b/tests/ExportResolveDirectoriesTest.php @@ -71,4 +71,30 @@ public function testMissingEntryNamesTheObservedPath(): void resolve_directories(['directory' => [$missing]]); } + + public function testFileIndexResolverRejectsBrokenSelectedSymlink(): void + { + $path = $this->tempDir . '/site/broken.php'; + symlink('absent.php', $path); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('broken symlink'); + + resolve_file_index_roots(['directory' => [$path], 'follow_symlinks' => true]); + } + + public function testFileIndexResolverRejectsParentSymlinkWithoutFollowing(): void + { + $releases = $this->tempDir . '/releases'; + mkdir($releases, 0755, true); + file_put_contents($releases . '/wp-config.php', 'tempDir) . '/current'; + symlink($releases, $current); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($current); + $this->expectExceptionMessage('use --follow-symlinks'); + + resolve_file_index_roots(['directory' => [$current . '/wp-config.php']]); + } } diff --git a/tests/FileIndexNamedRootTest.php b/tests/FileIndexNamedRootTest.php new file mode 100644 index 000000000..5fdac4d0f --- /dev/null +++ b/tests/FileIndexNamedRootTest.php @@ -0,0 +1,159 @@ +tempDir = sys_get_temp_dir() . '/file-index-named-root-' . uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + $this->deleteTree($this->tempDir); + parent::tearDown(); + } + + public function testSelectedFileSymlinkEmitsItsLinkAndPhysicalTargetOnce(): void + { + $site = $this->tempDir . '/site'; + $shared = $this->tempDir . '/shared'; + mkdir($site, 0755, true); + mkdir($shared, 0755, true); + file_put_contents($shared . '/config.php', 'collect([ + $this->root($site . '/config.php', $shared . '/config.php', 'symlink'), + ], $site . '/config.php'); + + $paths = array_column($entries, 'path'); + $this->assertContains($site . '/config.php', $paths); + $this->assertContains($shared . '/config.php', $paths); + $this->assertSame('link', $this->entryAt($entries, $site . '/config.php')['type']); + $this->assertSame('file', $this->entryAt($entries, $shared . '/config.php')['type']); + } + + public function testSelectedDirectorySymlinkEmitsLinkThenIndexesPhysicalTree(): void + { + $site = $this->tempDir . '/site'; + $shared = $this->tempDir . '/shared'; + mkdir($site, 0755, true); + mkdir($shared . '/theme', 0755, true); + file_put_contents($shared . '/theme/style.css', 'body{}'); + symlink($shared . '/theme', $site . '/theme'); + + $entries = $this->collect([ + $this->root($site . '/theme', $shared . '/theme', 'symlink'), + ], $site . '/theme'); + + $this->assertSame('link', $this->entryAt($entries, $site . '/theme')['type']); + $this->assertContains( + (string) realpath($shared . '/theme') . '/style.css', + array_column($entries, 'path') + ); + } + + public function testParentSymlinkIsEmittedAtRequestedPathAndFileAtResolvedPath(): void + { + $releases = $this->tempDir . '/releases/42'; + $current = $this->tempDir . '/current'; + mkdir($releases, 0755, true); + file_put_contents($releases . '/wp-config.php', 'collect([ + $this->root($current . '/wp-config.php', $releases . '/wp-config.php', 'file'), + ], $current . '/wp-config.php'); + + $this->assertSame('link', $this->entryAt($entries, $current)['type']); + $this->assertSame('file', $this->entryAt($entries, $releases . '/wp-config.php')['type']); + } + + public function testSelectedAliasesKeepBothLinksAndIndexOnePhysicalTargetAcrossResume(): void + { + $site = $this->tempDir . '/site'; + $shared = $this->tempDir . '/shared'; + mkdir($site, 0755, true); + mkdir($shared, 0755, true); + file_put_contents($shared . '/config.php', 'root($site . '/first.php', $shared . '/config.php', 'symlink'), + $this->root($site . '/second.php', $shared . '/config.php', 'symlink'), + $this->root($shared . '/config.php', $shared . '/config.php', 'file'), + ]; + + $entries = $this->collect($roots, $site . '/first.php', true); + $paths = array_column($entries, 'path'); + + $this->assertContains($site . '/first.php', $paths); + $this->assertContains($site . '/second.php', $paths); + $this->assertSame(1, count(array_keys($paths, $shared . '/config.php', true))); + } + + /** @param array[] $roots @return array[] */ + private function collect(array $roots, string $start, bool $resume = false): array + { + $processor = FileIndexProcessor::start($roots, $start, true, true, ''); + $entries = []; + while ($processor->next_index_step()) { + foreach ($processor->get_index_entries() as $entry) { + $entries[] = $entry; + } + if ($resume) { + $cursor = json_encode($processor->get_cursor(), JSON_THROW_ON_ERROR); + $processor->close(); + $processor = FileIndexProcessor::resume($roots, $cursor, true, true, ''); + } + } + $processor->close(); + return $entries; + } + + /** @return array{requested_path:string,resolved_path:string,type:string} */ + private function root(string $requested, string $resolved, string $type): array + { + return ['requested_path' => $requested, 'resolved_path' => $resolved, 'type' => $type]; + } + + /** @param array[] $entries @return array */ + private function entryAt(array $entries, string $path): array + { + foreach ($entries as $entry) { + if ($entry['path'] === $path) { + return $entry; + } + } + $this->fail("Expected indexed path {$path}"); + } + + private function deleteTree(string $directory): void + { + if (!is_dir($directory)) { + return; + } + foreach (scandir($directory) as $name) { + if ($name === '.' || $name === '..') { + continue; + } + $path = $directory . '/' . $name; + if (is_dir($path) && !is_link($path)) { + $this->deleteTree($path); + } else { + unlink($path); + } + } + rmdir($directory); + } +} diff --git a/tests/Import/OnlyFilesPathPrefixDiffTest.php b/tests/Import/OnlyFilesPathPrefixDiffTest.php index c07653790..1037593b8 100644 --- a/tests/Import/OnlyFilesPathPrefixDiffTest.php +++ b/tests/Import/OnlyFilesPathPrefixDiffTest.php @@ -291,4 +291,41 @@ public function testOnlyRootItselfSurvivesTheDeleteDrains(): void $this->assertFileDoesNotExist($orphan); $this->assertNotContains('/wp-content/themes/old/orphan.css', $this->readRemoteIndexEntryPaths()); } + + public function testConfirmedAbsentSelectedFileRootIsDeleted(): void + { + $this->writeIndex( + 'remote-index.jsonl', + $this->indexLine('/wp-config.php', 1000, 10) + ); + $this->writeIndex('remote-index.next.jsonl', ''); + $local = $this->seedLocalFile('/wp-config.php'); + + [$client, $reflection] = $this->prepareClient(['/wp-config.php']); + $reflection->getMethod('compare_remote_indexes_and_build_fetch_list')->invoke($client); + $reflection->getProperty('pull_index_journal') + ->getValue($client) + ->apply_pending_records(); + + $this->assertFileDoesNotExist($local); + $this->assertNotContains('/wp-config.php', $this->readRemoteIndexEntryPaths()); + } + + public function testOnlyPreviouslyIndexedRootsMayBeReportedMissing(): void + { + $this->writeIndex( + 'remote-index.jsonl', + $this->indexLine('/wp-config.php', 1000, 10) + ); + + [$client, $reflection] = $this->prepareClient([ + '/wp-config.php', + '/new-file.php', + ]); + + $this->assertSame( + ['/wp-config.php'], + $reflection->getMethod('previously_indexed_selected_roots')->invoke($client) + ); + } } From 83981f69e1e253f48b0e1c2f042c113f4189027e Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 11:42:17 +0200 Subject: [PATCH 13/30] Clarify named file-index roots --- .../src/class-file-index-processor.php | 52 +++++++++++-------- packages/reprint-server/src/utils.php | 5 +- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 6f8fcb801..ea0cb39a5 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -64,8 +64,8 @@ final class FileIndexProcessor { /** @var array[] Intermediate symlinks emitted before a new traversal begins. */ private $initial_index_entries; - /** @var string[] Requested paths still to inspect, one per step, before traversal. */ - private $pending_path_roots = []; + /** @var string[] Requested named roots still to index, one per step. */ + private $pending_named_roots = []; /** @var string[]|null Sorted names in the current directory. */ private $current_directory_names = null; @@ -152,7 +152,8 @@ public static function start( }); $ordered_roots = array_merge($ordered_roots, $extra_roots); - // A root that is not a directory is one named path; nothing to walk. + // `--only` may select wp-config.php or a symlink. Index one named root + // per step, then continue with directory walking. $directory_roots = []; $path_roots = []; foreach ($ordered_roots as $root) { @@ -273,7 +274,7 @@ public static function resume( } // Absent from cursors written before this field existed. - $pending_path_roots = []; + $pending_named_roots = []; $encoded_path_roots = isset($cursor["paths"]) ? $cursor["paths"] : []; if (!is_array($encoded_path_roots)) { throw new InvalidArgumentException("Index cursor paths must be an array"); @@ -286,7 +287,7 @@ public static function resume( if ($path_root === false || $path_root === "") { throw new InvalidArgumentException("Index cursor path entry has invalid encoding"); } - $pending_path_roots[] = $path_root; + $pending_named_roots[] = $path_root; } // During continuation, the active directory is the best description @@ -305,7 +306,7 @@ public static function resume( $directory_stack, $index_directory, [], - $pending_path_roots + $pending_named_roots ); } @@ -337,9 +338,10 @@ public function next_index_step(): bool return true; } - // One per step, before traversal, so each step stays bounded. - if (!empty($this->pending_path_roots)) { - $this->index_next_path_root(); + // Index one selected named root before walking directories. This keeps + // each step bounded and makes its cursor boundary unambiguous. + if (!empty($this->pending_named_roots)) { + $this->index_next_named_root(); return true; } @@ -477,7 +479,7 @@ public function get_cursor(): array ]; } $encoded_path_roots = []; - foreach ($this->pending_path_roots as $path_root) { + foreach ($this->pending_named_roots as $path_root) { $encoded_path_roots[] = base64_encode($path_root); } return ["stack" => $encoded_stack, "paths" => $encoded_path_roots]; @@ -594,7 +596,7 @@ public static function path_is_default_skipped(string $path): bool * @param array[] $directory_stack Active directory stack. * @param string $index_directory Directory reported by the endpoint. * @param array[] $initial_index_entries Intermediate symlinks emitted before traversal. - * @param string[] $pending_path_roots Named paths still to inspect, one per step. + * @param string[] $pending_named_roots Requested named roots still to index, one per step. */ private function __construct( array $roots, @@ -605,7 +607,7 @@ private function __construct( array $directory_stack, string $index_directory, array $initial_index_entries, - array $pending_path_roots = [] + array $pending_named_roots = [] ) { $this->roots = $roots; $this->directories = $directories; @@ -615,7 +617,7 @@ private function __construct( $this->directory_stack = $directory_stack; $this->index_directory = $index_directory; $this->initial_index_entries = $initial_index_entries; - $this->pending_path_roots = $pending_path_roots; + $this->pending_named_roots = $pending_named_roots; } /** @@ -769,12 +771,12 @@ private static function position_after_name(array $directory_names, string $afte } /** - * Inspects one named path, applying the omissions traversal applies. + * Indexes one requested named root using traversal's exclusions. */ - private function index_next_path_root(): void + private function index_next_named_root(): void { // Settle the cursor first so a skipped or vanished path is not retried. - $requested_path = array_shift($this->pending_path_roots); + $requested_path = array_shift($this->pending_named_roots); $root = $this->find_root($requested_path); if ($root === null) { throw new InvalidArgumentException("Index cursor names a root absent from this request: {$requested_path}"); @@ -808,7 +810,8 @@ private function index_next_path_root(): void $entries = []; if ($this->follow_symlinks) { - // dirname() so a symlinked file is not repeated as an intermediate entry. + // Record links in the requested parent path. The inspected root may + // add links from its own symlink target, so keep both entry sets. $entries = self::find_parent_symlinks(dirname($path_root)); } $inspected_path = self::index_entries_for_path($path_root, $stat, $this->follow_symlinks); @@ -873,7 +876,7 @@ private function resolved_target_was_indexed(array $root): bool $candidate["requested_path"] !== $root["requested_path"] && $candidate["resolved_path"] === $root["resolved_path"] - && !in_array($candidate["requested_path"], $this->pending_path_roots, true) + && !in_array($candidate["requested_path"], $this->pending_named_roots, true) ) { return true; } @@ -994,7 +997,8 @@ private static function index_entries_for_path( $item["target"] = $link_target; } if ($type === "dir") { - // Physical emptiness, not post-exclusion: else push deletes the parent. + // This is physical emptiness, not a directory whose children are + // merely excluded from synchronization. $directory_handle = @opendir($path); if ($directory_handle !== false) { $item["empty"] = true; @@ -1010,12 +1014,16 @@ private static function index_entries_for_path( } closedir($directory_handle); } - // An absent "empty" means unknown, so push must not infer deletions. + // If opendir() fails, leave "empty" absent. Pull reports the + // directory error and push does not plan deletions from it. } - // One step: the cursor cannot stop between a link and its intermediates. + // Intermediate links and the inspected path share one step because a + // cursor cannot stop between them without losing one of the entries. $entries = $intermediate_symlinks; - // A descendant implies its ancestors, so only empty directories need a row. + // Descendants imply non-empty parents: /a/file already implies /a. + // Emit a directory only when it is empty or uninspectable, when no + // descendant can establish that it exists. if ( $type !== "dir" || !isset($item["empty"]) diff --git a/packages/reprint-server/src/utils.php b/packages/reprint-server/src/utils.php index dca43a164..f1ae68d6d 100644 --- a/packages/reprint-server/src/utils.php +++ b/packages/reprint-server/src/utils.php @@ -397,7 +397,10 @@ function path_is_same_as_or_descendant_of($path, $ancestor): bool /** * Canonicalizes one configured root path. * - * A non-directory keeps its final component, so a named symlink stays itself. + * A regular file or file symlink keeps its final component after its parent is + * canonicalized. For example, `/srv/site/config-link.php` remains + * `config-link.php` rather than becoming its target. Directory symlinks are + * directories to PHP and resolve to their physical directory. * * @param string $path Root path as configured. * @return string|null Canonical root path, or null when it does not exist. From 4cb6a04f3cab0111e3051ac9da067bef0e490e80 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 12:10:29 +0200 Subject: [PATCH 14/30] Fix named-root pull review findings --- packages/reprint-client/src/import.php | 12 ++- .../src/class-file-index-processor.php | 6 +- packages/reprint-server/src/export.php | 1 - packages/reprint-server/src/utils.php | 32 ------- tests/ExporterUtilsTest.php | 84 ------------------- tests/FileIndexFilePathRootTest.php | 16 ++++ tests/FileIndexNamedRootTest.php | 18 ++++ tests/FileIndexSkipDefaultsTest.php | 3 +- tests/Import/OnlyFilesPathPrefixDiffTest.php | 19 +++++ tests/Import/OnlyFilesPathPrefixTest.php | 18 ++-- ...st.js => import-56-only-file-path.test.js} | 2 +- tests/phpunit.xml | 2 +- 12 files changed, 81 insertions(+), 132 deletions(-) delete mode 100644 tests/ExporterUtilsTest.php rename tests/e2e/tests/{import-55-only-file-path.test.js => import-56-only-file-path.test.js} (97%) diff --git a/packages/reprint-client/src/import.php b/packages/reprint-client/src/import.php index da867b093..09de173cf 100755 --- a/packages/reprint-client/src/import.php +++ b/packages/reprint-client/src/import.php @@ -403,6 +403,9 @@ class ImportClient */ private $pull_excluded_files_with_path_prefixes = []; + /** @var string[]|null Selected roots found in the current remote index. */ + private $previously_indexed_selected_root_paths = null; + /** @var AdaptiveTuner|null Adjusts request pacing based on server response times and errors. */ private $tuner = null; @@ -9820,8 +9823,12 @@ private function resolve_remote_paths( */ private function previously_indexed_selected_roots(): array { + if ($this->previously_indexed_selected_root_paths !== null) { + return $this->previously_indexed_selected_root_paths; + } if ($this->pull_only_files_with_path_prefixes === [] || !is_file($this->remote_index_file)) { - return []; + $this->previously_indexed_selected_root_paths = []; + return $this->previously_indexed_selected_root_paths; } $remaining = array_fill_keys($this->pull_only_files_with_path_prefixes, true); $handle = fopen($this->remote_index_file, 'r'); @@ -9842,10 +9849,11 @@ private function previously_indexed_selected_roots(): array } finally { fclose($handle); } - return array_values(array_diff( + $this->previously_indexed_selected_root_paths = array_values(array_diff( $this->pull_only_files_with_path_prefixes, array_keys($remaining) )); + return $this->previously_indexed_selected_root_paths; } /** diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index ea0cb39a5..5b0e84232 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -117,7 +117,11 @@ public static function start( } if ($index_root === null) { $resolved_index_root = @realpath($index_directory); - if (!$legacy_string_roots || $resolved_index_root === false || !is_dir($resolved_index_root)) { + if ( + $resolved_index_root === false + || !is_dir($resolved_index_root) + || ( !$legacy_string_roots && !$follow_symlinks ) + ) { throw new InvalidArgumentException("list_dir is not a configured file-index root: {$index_directory}"); } $index_root = [ diff --git a/packages/reprint-server/src/export.php b/packages/reprint-server/src/export.php index f81c929d1..18c97246c 100644 --- a/packages/reprint-server/src/export.php +++ b/packages/reprint-server/src/export.php @@ -6,7 +6,6 @@ use function WordPress\Filesystem\wp_join_unix_paths; use function WordPress\Reprint\Exporter\assert_valid_path; use function WordPress\Reprint\Exporter\build_pdo_dsn; -use function WordPress\Reprint\Exporter\canonical_root_path; use function WordPress\Reprint\Exporter\json_encode_or_throw; use function WordPress\Reprint\Exporter\normalize_path; use function WordPress\Reprint\Exporter\parse_size; diff --git a/packages/reprint-server/src/utils.php b/packages/reprint-server/src/utils.php index f1ae68d6d..600eef6d1 100644 --- a/packages/reprint-server/src/utils.php +++ b/packages/reprint-server/src/utils.php @@ -394,38 +394,6 @@ function path_is_same_as_or_descendant_of($path, $ancestor): bool return $path === $ancestor || str_starts_with($path, $ancestor . "/"); } -/** - * Canonicalizes one configured root path. - * - * A regular file or file symlink keeps its final component after its parent is - * canonicalized. For example, `/srv/site/config-link.php` remains - * `config-link.php` rather than becoming its target. Directory symlinks are - * directories to PHP and resolve to their physical directory. - * - * @param string $path Root path as configured. - * @return string|null Canonical root path, or null when it does not exist. - */ -function canonical_root_path(string $path): ?string -{ - clearstatcache(true, $path); - if (is_dir($path)) { - $canonical_path = realpath($path); - return $canonical_path === false ? null : $canonical_path; - } - - // file_exists() follows links and so reports a broken link as absent. - if (@lstat($path) === false) { - return null; - } - - $canonical_parent = realpath(dirname($path)); - if ($canonical_parent === false) { - return null; - } - - return wp_join_unix_paths($canonical_parent, basename($path)); -} - /** * Indicates whether a candidate path is a descendant of an ancestor. * diff --git a/tests/ExporterUtilsTest.php b/tests/ExporterUtilsTest.php deleted file mode 100644 index c9d702f4b..000000000 --- a/tests/ExporterUtilsTest.php +++ /dev/null @@ -1,84 +0,0 @@ -tempDir = sys_get_temp_dir() . '/exporter-utils-' . uniqid(); - mkdir($this->tempDir . '/real', 0755, true); - file_put_contents($this->tempDir . '/real/target.txt', 'hi'); - symlink('real/target.txt', $this->tempDir . '/link-to-file'); - symlink('nowhere.txt', $this->tempDir . '/broken-link'); - symlink('real', $this->tempDir . '/link-to-dir'); - } - - protected function tearDown(): void - { - foreach (['link-to-file', 'broken-link', 'link-to-dir', 'real/target.txt'] as $path) { - @unlink($this->tempDir . '/' . $path); - } - @rmdir($this->tempDir . '/real'); - @rmdir($this->tempDir); - parent::tearDown(); - } - - public function testDirectoryResolvesThroughRealpath(): void - { - $this->assertSame( - realpath($this->tempDir . '/real'), - canonical_root_path($this->tempDir . '/real') - ); - } - - public function testDirectorySymlinkStillResolvesToItsTarget(): void - { - $this->assertSame( - realpath($this->tempDir . '/real'), - canonical_root_path($this->tempDir . '/link-to-dir'), - 'A symlinked directory root must keep resolving, as traversal depends on it' - ); - } - - public function testRegularFileKeepsItsOwnPath(): void - { - $this->assertSame( - realpath($this->tempDir) . '/real/target.txt', - canonical_root_path($this->tempDir . '/real/target.txt') - ); - } - - public function testFileSymlinkKeepsItsOwnPathInsteadOfTheTarget(): void - { - $this->assertSame( - realpath($this->tempDir) . '/link-to-file', - canonical_root_path($this->tempDir . '/link-to-file'), - 'A file link must not collapse into its target, or pull writes it to the wrong path' - ); - } - - public function testBrokenSymlinkIsAcceptedRatherThanRejected(): void - { - $this->assertSame( - realpath($this->tempDir) . '/broken-link', - canonical_root_path($this->tempDir . '/broken-link') - ); - } - - public function testMissingPathReturnsNull(): void - { - $this->assertNull(canonical_root_path($this->tempDir . '/absent.txt')); - } - - public function testPathUnderAMissingParentReturnsNull(): void - { - $this->assertNull(canonical_root_path($this->tempDir . '/absent-dir/absent.txt')); - } -} diff --git a/tests/FileIndexFilePathRootTest.php b/tests/FileIndexFilePathRootTest.php index cb148d7a7..ec2c8bab7 100644 --- a/tests/FileIndexFilePathRootTest.php +++ b/tests/FileIndexFilePathRootTest.php @@ -52,4 +52,20 @@ public function testEndpointIndexesAFileRootAlongsideADirectoryRoot(): void $this->assertContains($configPath, $paths); $this->assertContains($pluginsPath . '/hello/hello.php', $paths); } + + public function testEndpointIndexesAFollowedTargetOutsideTheConfiguredRoot(): void + { + $site = $this->tempDir . '/site'; + $shared = $this->tempDir . '/shared'; + mkdir($site, 0755, true); + mkdir($shared . '/theme', 0755, true); + file_put_contents($shared . '/theme/style.css', 'body{}'); + symlink($shared . '/theme', $site . '/theme'); + + $target = (string) realpath($shared . '/theme'); + $paths = $this->runFileIndex([$site . '/theme'], $target); + + $this->assertContains($site . '/theme', $paths); + $this->assertContains($target . '/style.css', $paths); + } } diff --git a/tests/FileIndexNamedRootTest.php b/tests/FileIndexNamedRootTest.php index 5fdac4d0f..fcee8af21 100644 --- a/tests/FileIndexNamedRootTest.php +++ b/tests/FileIndexNamedRootTest.php @@ -63,6 +63,24 @@ public function testSelectedDirectorySymlinkEmitsLinkThenIndexesPhysicalTree(): ); } + public function testFollowedTargetOutsideConfiguredRootsCanStartAnIndex(): void + { + $site = $this->tempDir . '/site'; + $shared = $this->tempDir . '/shared'; + mkdir($site, 0755, true); + mkdir($shared . '/theme', 0755, true); + file_put_contents($shared . '/theme/style.css', 'body{}'); + symlink($shared . '/theme', $site . '/theme'); + + $target = (string) realpath($shared . '/theme'); + $entries = $this->collect([ + $this->root($site . '/theme', $target, 'symlink'), + ], $target); + + $this->assertContains($site . '/theme', array_column($entries, 'path')); + $this->assertContains($target . '/style.css', array_column($entries, 'path')); + } + public function testParentSymlinkIsEmittedAtRequestedPathAndFileAtResolvedPath(): void { $releases = $this->tempDir . '/releases/42'; diff --git a/tests/FileIndexSkipDefaultsTest.php b/tests/FileIndexSkipDefaultsTest.php index b4a69a989..44c21a38a 100644 --- a/tests/FileIndexSkipDefaultsTest.php +++ b/tests/FileIndexSkipDefaultsTest.php @@ -36,7 +36,8 @@ final class FileIndexSkipDefaultsTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->tempDir = sys_get_temp_dir() . '/file-index-skip-test-' . uniqid(); + $tempRoot = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir(); + $this->tempDir = $tempRoot . '/file-index-skip-test-' . uniqid(); mkdir($this->tempDir, 0755, true); } diff --git a/tests/Import/OnlyFilesPathPrefixDiffTest.php b/tests/Import/OnlyFilesPathPrefixDiffTest.php index 1037593b8..ec0806bc6 100644 --- a/tests/Import/OnlyFilesPathPrefixDiffTest.php +++ b/tests/Import/OnlyFilesPathPrefixDiffTest.php @@ -311,6 +311,25 @@ public function testConfirmedAbsentSelectedFileRootIsDeleted(): void $this->assertNotContains('/wp-config.php', $this->readRemoteIndexEntryPaths()); } + public function testExcludedSelectedLinkRootIsNotDeleted(): void + { + $this->writeIndex( + 'remote-index.jsonl', + $this->indexLine('/mnt/uploads', 1000, 10, 'link') + ); + $this->writeIndex('remote-index.next.jsonl', ''); + $local = $this->seedLocalFile('/mnt/uploads'); + + [$client, $reflection] = $this->prepareClient( + ['/mnt/uploads'], + ['/mnt/uploads'] + ); + $reflection->getMethod('compare_remote_indexes_and_build_fetch_list')->invoke($client); + + $this->assertFileExists($local); + $this->assertContains('/mnt/uploads', $this->readRemoteIndexEntryPaths()); + } + public function testOnlyPreviouslyIndexedRootsMayBeReportedMissing(): void { $this->writeIndex( diff --git a/tests/Import/OnlyFilesPathPrefixTest.php b/tests/Import/OnlyFilesPathPrefixTest.php index 1d49204b3..1e45d9a33 100644 --- a/tests/Import/OnlyFilesPathPrefixTest.php +++ b/tests/Import/OnlyFilesPathPrefixTest.php @@ -230,13 +230,13 @@ public function testPullOnlyFilesPrefixSelectionDefaultsToTrueAndIsSlashAware(): { $c = $this->withPaths(array('content_dir' => '/var/www/html/wp-content')); // No --include: every file path is selected (keeps the diff deleting orphans). - $this->assertTrue($this->call($c, 'is_selected_for_pulling', array('/anything/at/all.php', false))); + $this->assertTrue($this->call($c, 'is_selected_for_pulling', array('/anything/at/all.php', false, 'file'))); $this->set($c, 'pull_only_files_with_path_prefixes', array('/var/www/html/wp-content')); - $this->assertTrue($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/themes/a.css', false))); - $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-config.php', false))); + $this->assertTrue($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/themes/a.css', false, 'file'))); + $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-config.php', false, 'file'))); // Byte-order sibling must not match the prefix. - $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content.bak/x', false))); + $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content.bak/x', false, 'file'))); } public function testIncludeAndExcludePathPrefixSelection(): void @@ -245,13 +245,13 @@ public function testIncludeAndExcludePathPrefixSelection(): void $this->set($c, 'pull_only_files_with_path_prefixes', array('/var/www/html/wp-content')); $this->set($c, 'pull_excluded_files_with_path_prefixes', array('/var/www/html/wp-content/uploads')); - $this->assertTrue($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/themes/a.css', false))); - $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/uploads/a.jpg', false))); - $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-config.php', false))); - $this->assertTrue($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/uploads.backup/a.jpg', false))); + $this->assertTrue($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/themes/a.css', false, 'file'))); + $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/uploads/a.jpg', false, 'file'))); + $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-config.php', false, 'file'))); + $this->assertTrue($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/uploads.backup/a.jpg', false, 'file'))); $this->set($c, 'pull_excluded_files_with_path_prefixes', array('/')); - $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/themes/a.css', false))); + $this->assertFalse($this->call($c, 'is_selected_for_pulling', array('/var/www/html/wp-content/themes/a.css', false, 'file'))); } public function testFilterModesRewriteToUploadPathSelections(): void diff --git a/tests/e2e/tests/import-55-only-file-path.test.js b/tests/e2e/tests/import-56-only-file-path.test.js similarity index 97% rename from tests/e2e/tests/import-55-only-file-path.test.js rename to tests/e2e/tests/import-56-only-file-path.test.js index eedabf305..1f516008b 100644 --- a/tests/e2e/tests/import-55-only-file-path.test.js +++ b/tests/e2e/tests/import-56-only-file-path.test.js @@ -1,4 +1,4 @@ -/** Test 55: `--only` accepts a single file, not just a directory (issue #539). */ +/** Test 56: `--only` accepts a single file, not just a directory (issue #539). */ import { describe, it, beforeAll, afterAll } from 'vitest'; import assert from 'node:assert/strict'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; diff --git a/tests/phpunit.xml b/tests/phpunit.xml index a7a23e0ea..0bf75c94e 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -29,7 +29,7 @@ FileIndexSkipDefaultsTest.php FileIndexProcessorTest.php FileIndexFilePathRootTest.php - ExporterUtilsTest.php + FileIndexNamedRootTest.php HmacServerTest.php MultipartProcessorTest.php PushEndpointsTest.php From 7306337f0409a9e29354218e58fca582f5329b03 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 12:33:56 +0200 Subject: [PATCH 15/30] Clarify file-index root traversal --- packages/reprint-client/src/import.php | 9 +- .../src/lib/push/class-push-plan.php | 16 +- .../src/class-file-index-processor.php | 174 +++++++++--------- packages/reprint-server/src/export.php | 57 +++++- tests/ExportResolveDirectoriesTest.php | 49 +++++ tests/FileIndexNamedRootTest.php | 23 ++- tests/FileIndexProcessorTest.php | 133 ++++++++++--- 7 files changed, 336 insertions(+), 125 deletions(-) diff --git a/packages/reprint-client/src/import.php b/packages/reprint-client/src/import.php index 09de173cf..a7e238d89 100755 --- a/packages/reprint-client/src/import.php +++ b/packages/reprint-client/src/import.php @@ -3478,9 +3478,14 @@ private function build_files_pull_mirror_local_changes(): void if (!is_resource($fresh_local_index_handle)) { throw new RuntimeException("Failed to create the fresh local index."); } + $filesystem_root_record = [ + "requested_path" => $this->filesystem_root, + "resolved_path" => $this->filesystem_root, + "type" => "directory", + ]; $file_index_processor = FileIndexProcessor::start( - [$this->filesystem_root], - $this->filesystem_root, + [$filesystem_root_record], + $filesystem_root_record, false, $this->include_caches, $plan_directory diff --git a/packages/reprint-client/src/lib/push/class-push-plan.php b/packages/reprint-client/src/lib/push/class-push-plan.php index d279b1773..e44ee5f06 100644 --- a/packages/reprint-client/src/lib/push/class-push-plan.php +++ b/packages/reprint-client/src/lib/push/class-push-plan.php @@ -156,9 +156,14 @@ public static function start( if (!is_resource($plan->fresh_local_index_handle)) { throw new RuntimeException("Failed to open the fresh local index: {$plan->fresh_local_index_file}"); } + $filesystem_root_record = [ + "requested_path" => $plan->filesystem_root, + "resolved_path" => $plan->filesystem_root, + "type" => "directory", + ]; $plan->file_index_processor = FileIndexProcessor::start( - [$plan->filesystem_root], - $plan->filesystem_root, + [$filesystem_root_record], + $filesystem_root_record, false, false, $plan->plan_directory @@ -361,8 +366,13 @@ private function open_fresh_local_index_for_continuation(): void if (fseek($this->fresh_local_index_handle, $cursor["fresh_local_index_byte_offset"]) !== 0) { throw new RuntimeException("Failed to seek to the fresh local index byte offset."); } + $filesystem_root_record = [ + "requested_path" => $this->filesystem_root, + "resolved_path" => $this->filesystem_root, + "type" => "directory", + ]; $this->file_index_processor = FileIndexProcessor::resume( - [$this->filesystem_root], + [$filesystem_root_record], json_encode($cursor["file_index_cursor"], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), false, false, diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 5b0e84232..9c2344444 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -26,6 +26,11 @@ * the endpoint's established ordering and cursor behavior. It also means one * unusually wide directory is held in memory; changing that requires a * separate traversal design rather than hiding it inside this extraction. + * + * @phpstan-type FileIndexRoot ( + * array{requested_path:string,resolved_path:string,type:'directory'|'file'|'symlink'} + * | array{requested_path:string,resolved_path:null,type:'missing'} + * ) */ final class FileIndexProcessor { @@ -91,63 +96,45 @@ final class FileIndexProcessor { /** * Starts a traversal at the requested root and schedules the other roots. * - * @param array[]|string[] $roots Structured roots or legacy directory roots. - * @param string $index_directory Requested root where indexing begins. - * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories. - * @param bool $include_caches Whether generated caches and development files are included. - * @param string $storage_path Reprint storage path omitted from the index, or an empty string. + * @param FileIndexRoot[] $roots Structured roots scheduled for this index. + * @param FileIndexRoot $start_root Root scheduled first. It may be an + * external directory reached by a followed link. + * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories. + * @param bool $include_caches Whether generated caches and development files are included. + * @param string $storage_path Reprint storage path omitted from the index, or an empty string. * @return self New file-index processor. */ public static function start( array $roots, - string $index_directory, + array $start_root, bool $follow_symlinks, bool $include_caches, string $storage_path ): self { - $legacy_string_roots = isset($roots[0]) && is_string($roots[0]); - $roots = self::normalize_roots($roots); - $requested_index_root = \WordPress\Reprint\Exporter\normalize_path($index_directory); - $index_root = null; + $roots = self::validate_roots($roots); + $start_root = self::validate_root($start_root); + $start_root_is_configured = false; foreach ($roots as $root) { - if ($root["requested_path"] === $requested_index_root) { - $index_root = $root; + if ($root["requested_path"] === $start_root["requested_path"]) { + $start_root_is_configured = true; break; } } - if ($index_root === null) { - $resolved_index_root = @realpath($index_directory); - if ( - $resolved_index_root === false - || !is_dir($resolved_index_root) - || ( !$legacy_string_roots && !$follow_symlinks ) - ) { - throw new InvalidArgumentException("list_dir is not a configured file-index root: {$index_directory}"); - } - $index_root = [ - "requested_path" => $requested_index_root, - "resolved_path" => $resolved_index_root, - "type" => "directory", - ]; - } - - // An ordinary traversal must begin inside a configured root. Following - // links deliberately relaxes that boundary because a link target may - // be outside every configured root and still belong in the index. - $directories = self::resolved_directory_roots($roots, $follow_symlinks); - if (!$legacy_string_roots && !$follow_symlinks && !empty($directories) && $index_root["resolved_path"] !== null && !\WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of($index_root["resolved_path"], $directories)) { + if (!$start_root_is_configured && $start_root["type"] !== "directory") { throw new InvalidArgumentException( - "list_dir is outside of allowed roots: {$requested_index_root}" + "File-index start root must be a configured root or a directory: {$start_root["requested_path"]}" ); } + $directories = self::resolved_directory_roots($roots, $follow_symlinks); + // Visit the requested directory first, followed by every other root in // stable byte order. Stable ordering makes a cursor independent of the // order in which configuration discovered the additional roots. - $ordered_roots = [$index_root]; + $ordered_roots = [$start_root]; $extra_roots = []; foreach ($roots as $root) { - if ($root["requested_path"] !== $index_root["requested_path"]) { + if ($root["requested_path"] !== $start_root["requested_path"]) { $extra_roots[] = $root; } } @@ -188,16 +175,18 @@ public static function start( if ($follow_symlinks) { foreach ($ordered_roots as $root) { if ($root["type"] === "directory") { - $parent_path = $legacy_string_roots ? $root["resolved_path"] : $root["requested_path"]; - $initial_index_entries = array_merge($initial_index_entries, self::find_parent_symlinks($parent_path)); + $initial_index_entries = array_merge( + $initial_index_entries, + self::find_parent_symlinks($root["requested_path"]) + ); } } } // X-Index-Dir names a directory, so a named path reports its parent. - $reported_index_directory = $index_root["type"] === "directory" - ? $index_root["resolved_path"] - : dirname($index_root["requested_path"]); + $reported_index_directory = $start_root["type"] === "directory" + ? $start_root["resolved_path"] + : dirname($start_root["requested_path"]); return new self( $roots, @@ -215,11 +204,11 @@ public static function start( /** * Resumes traversal from a cursor returned by get_cursor(). * - * @param array[]|string[] $roots Structured roots or legacy directory roots. - * @param string $cursor_json JSON cursor returned by the preceding request. - * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories. - * @param bool $include_caches Whether generated caches and development files are included. - * @param string $storage_path Reprint storage path omitted from the index, or an empty string. + * @param FileIndexRoot[] $roots Structured roots scheduled for this index. + * @param string $cursor_json JSON cursor returned by the preceding request. + * @param bool $follow_symlinks Whether directory symlinks may lead outside the allowed directories. + * @param bool $include_caches Whether generated caches and development files are included. + * @param string $storage_path Reprint storage path omitted from the index, or an empty string. * @return self Resumed file-index processor. */ public static function resume( @@ -229,7 +218,7 @@ public static function resume( bool $include_caches, string $storage_path ): self { - $roots = self::normalize_roots($roots); + $roots = self::validate_roots($roots); $directories = self::resolved_directory_roots($roots, $follow_symlinks); // A cursor is caller-held continuation state. Reject malformed JSON or @@ -889,55 +878,60 @@ private function resolved_target_was_indexed(array $root): bool } /** - * Normalizes legacy string roots and validates structured root records. + * Validates root records produced by the endpoint resolver or local callers. * - * @param array[]|string[] $roots File-index roots. - * @return array[] Structured roots. + * @param array[] $roots File-index roots. + * @return FileIndexRoot[] */ - private static function normalize_roots(array $roots): array + private static function validate_roots(array $roots): array { - $normalized = []; + $validated_roots = []; foreach ($roots as $root) { - if (is_string($root)) { - clearstatcache(true, $root); - $stat = @lstat($root); - if ($stat === false) { - throw new InvalidArgumentException("File-index root does not exist or is not accessible: {$root}"); - } - $requested_path = \WordPress\Reprint\Exporter\normalize_path($root); - $resolved_path = @realpath($requested_path); - $mode = $stat["mode"] & self::STAT_TYPE_MASK; - $type = $mode === self::STAT_TYPE_LINK ? "symlink" : ( is_dir($requested_path) ? "directory" : "file" ); - $normalized[] = [ - "requested_path" => $requested_path, - "resolved_path" => $resolved_path === false ? null : $resolved_path, - "type" => $type, - ]; - continue; - } - if (!is_array($root) || !isset($root["requested_path"], $root["type"])) { - throw new InvalidArgumentException("File-index roots must contain requested_path and type"); - } - if (!is_string($root["requested_path"]) || !is_string($root["type"])) { - throw new InvalidArgumentException("File-index root fields have invalid types"); - } - $resolved_path = $root["resolved_path"] ?? null; - if ($resolved_path !== null && !is_string($resolved_path)) { - throw new InvalidArgumentException("File-index root resolved_path has invalid type"); - } - if (!in_array($root["type"], ["directory", "file", "symlink", "missing"], true)) { - throw new InvalidArgumentException("File-index root type is invalid: {$root["type"]}"); - } - if ($root["type"] !== "missing" && ($resolved_path === null || $resolved_path === "")) { - throw new InvalidArgumentException("File-index root missing resolved_path: {$root["requested_path"]}"); + $validated_roots[] = self::validate_root($root); + } + return $validated_roots; + } + + /** + * @param mixed $root Candidate file-index root. + * @return FileIndexRoot Validated file-index root. + */ + private static function validate_root($root): array + { + if (!is_array($root) || !isset($root["requested_path"], $root["type"])) { + throw new InvalidArgumentException("File-index roots must contain requested_path and type"); + } + if (!is_string($root["requested_path"]) || !is_string($root["type"])) { + throw new InvalidArgumentException("File-index root fields have invalid types"); + } + $requested_path = $root["requested_path"]; + if ( + $requested_path === "" + || \WordPress\Reprint\Exporter\normalize_path($requested_path) !== $requested_path + ) { + throw new InvalidArgumentException("File-index root requested_path must be normalized"); + } + $resolved_path = $root["resolved_path"] ?? null; + if ($resolved_path !== null && !is_string($resolved_path)) { + throw new InvalidArgumentException("File-index root resolved_path has invalid type"); + } + if (!in_array($root["type"], ["directory", "file", "symlink", "missing"], true)) { + throw new InvalidArgumentException("File-index root type is invalid: {$root["type"]}"); + } + if ($root["type"] === "missing") { + if ($resolved_path !== null) { + throw new InvalidArgumentException( + "Missing file-index root has a resolved_path: {$requested_path}" + ); } - $normalized[] = [ - "requested_path" => \WordPress\Reprint\Exporter\normalize_path($root["requested_path"]), - "resolved_path" => $resolved_path, - "type" => $root["type"], - ]; + } elseif ($resolved_path === null || $resolved_path === "") { + throw new InvalidArgumentException("File-index root missing resolved_path: {$requested_path}"); } - return $normalized; + return [ + "requested_path" => $requested_path, + "resolved_path" => $resolved_path, + "type" => $root["type"], + ]; } /** Returns physical directory roots, including followed directory links. */ diff --git a/packages/reprint-server/src/export.php b/packages/reprint-server/src/export.php index 18c97246c..f89a0e761 100644 --- a/packages/reprint-server/src/export.php +++ b/packages/reprint-server/src/export.php @@ -1406,6 +1406,56 @@ function resolve_file_index_roots(array $config): array return $roots; } +/** + * Resolves the root scheduled first for a file-index request. + * + * A request normally starts from one of its selected roots. With symlink + * following enabled, the client may instead start a separate request at a + * physical directory reached from a selected link. + * + * @param array[] $roots File-index roots returned by resolve_file_index_roots(). + * @param string $list_directory Requested root where this traversal begins. + * @param bool $follow_symlinks Whether a followed target may begin a traversal. + * @return array { + * Root scheduled first. + * + * @type string $requested_path Requested normalized root path. + * @type string|null $resolved_path Physical root path, when available. + * @type string $type directory, file, symlink, or missing. + * } + */ +function resolve_file_index_start_root( + array $roots, + string $list_directory, + bool $follow_symlinks +): array { + $requested_path = normalize_path($list_directory); + foreach ($roots as $root) { + if ($root["requested_path"] === $requested_path) { + return $root; + } + } + + if (!$follow_symlinks) { + throw new InvalidArgumentException( + "list_dir must name a selected root unless follow_symlinks is enabled: {$requested_path}" + ); + } + + $resolved_path = @realpath($requested_path); + if ($resolved_path === false || !is_dir($resolved_path)) { + throw new InvalidArgumentException( + "Followed symlink target directory does not exist or is not accessible: {$requested_path}" + ); + } + + return [ + "requested_path" => $requested_path, + "resolved_path" => $resolved_path, + "type" => "directory", + ]; +} + /** Returns whether the parent can confirm that a selected name is absent. */ function file_index_root_is_confirmed_absent(string $requested_path): bool { @@ -2784,9 +2834,14 @@ function endpoint_file_index( if (!is_string($list_directory) || $list_directory === "") { throw new InvalidArgumentException("list_dir is required for file_index"); } - $file_index = FileIndexProcessor::start( + $start_root = resolve_file_index_start_root( $file_index_roots, $list_directory, + $follow_symlinks + ); + $file_index = FileIndexProcessor::start( + $file_index_roots, + $start_root, $follow_symlinks, $include_caches, $storage_path diff --git a/tests/ExportResolveDirectoriesTest.php b/tests/ExportResolveDirectoriesTest.php index e4bef5a5b..51d94ce1c 100644 --- a/tests/ExportResolveDirectoriesTest.php +++ b/tests/ExportResolveDirectoriesTest.php @@ -62,6 +62,55 @@ public function testFileIndexResolverKeepsRequestedAndResolvedCoordinates(): voi ); } + public function testFileIndexStartRootUsesTheSelectedRootRecord(): void + { + $path = $this->tempDir . '/site/config-link.php'; + $roots = resolve_file_index_roots([ + 'directory' => [$path], + 'follow_symlinks' => true, + ]); + + $this->assertSame( + $roots[0], + resolve_file_index_start_root($roots, $path, true) + ); + } + + public function testFileIndexStartRootAllowsAnExternalDirectoryOnlyWhenFollowingLinks(): void + { + $shared = $this->tempDir . '/shared'; + mkdir($shared, 0755, true); + $roots = resolve_file_index_roots([ + 'directory' => [$this->tempDir . '/site'], + 'follow_symlinks' => true, + ]); + + $this->assertSame( + [ + 'requested_path' => $shared, + 'resolved_path' => realpath($shared), + 'type' => 'directory', + ], + resolve_file_index_start_root($roots, $shared, true) + ); + } + + public function testFileIndexStartRootRejectsAnUnselectedDirectoryWithoutFollowingLinks(): void + { + $shared = $this->tempDir . '/shared'; + mkdir($shared, 0755, true); + $site = (string) realpath($this->tempDir . '/site'); + $shared = (string) realpath($shared); + $roots = resolve_file_index_roots([ + 'directory' => [$site], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must name a selected root unless follow_symlinks is enabled'); + + resolve_file_index_start_root($roots, $shared, false); + } + public function testMissingEntryNamesTheObservedPath(): void { $missing = $this->tempDir . '/site/absent.php'; diff --git a/tests/FileIndexNamedRootTest.php b/tests/FileIndexNamedRootTest.php index fcee8af21..ca1d36929 100644 --- a/tests/FileIndexNamedRootTest.php +++ b/tests/FileIndexNamedRootTest.php @@ -123,7 +123,13 @@ public function testSelectedAliasesKeepBothLinksAndIndexOnePhysicalTargetAcrossR /** @param array[] $roots @return array[] */ private function collect(array $roots, string $start, bool $resume = false): array { - $processor = FileIndexProcessor::start($roots, $start, true, true, ''); + $processor = FileIndexProcessor::start( + $roots, + $this->startRoot($roots, $start), + true, + true, + '' + ); $entries = []; while ($processor->next_index_step()) { foreach ($processor->get_index_entries() as $entry) { @@ -145,6 +151,21 @@ private function root(string $requested, string $resolved, string $type): array return ['requested_path' => $requested, 'resolved_path' => $resolved, 'type' => $type]; } + /** @param array[] $roots @return array{requested_path:string,resolved_path:string,type:string} */ + private function startRoot(array $roots, string $start): array + { + foreach ($roots as $root) { + if ($root['requested_path'] === $start) { + return $root; + } + } + $resolved = realpath($start); + if ($resolved === false || !is_dir($resolved)) { + throw new RuntimeException("Test start root is not a directory: {$start}"); + } + return $this->root($start, $resolved, 'directory'); + } + /** @param array[] $entries @return array */ private function entryAt(array $entries, string $path): array { diff --git a/tests/FileIndexProcessorTest.php b/tests/FileIndexProcessorTest.php index 020bde610..095096f7a 100644 --- a/tests/FileIndexProcessorTest.php +++ b/tests/FileIndexProcessorTest.php @@ -67,8 +67,8 @@ public function testPathThatDisappearsAfterDirectoryScanGetsItsOwnStep(): void file_put_contents($docroot . '/a.txt', 'a'); file_put_contents($docroot . '/b.txt', 'b'); - $processor = FileIndexProcessor::start( - [realpath($docroot)], + $processor = $this->startProcessor( + [$docroot], $docroot, false, true, @@ -97,8 +97,8 @@ public function testMissingScheduledDirectoryReportsTheDirectoryAndContinues(): $docroot = (string) realpath($docroot); $vanishingDirectory = (string) realpath($vanishingDirectory); - $processor = FileIndexProcessor::start( - [realpath($docroot)], + $processor = $this->startProcessor( + [$docroot], $docroot, false, true, @@ -136,7 +136,7 @@ public function testFilesystemRootCanAuthorizeAnIndexedDirectory(): void mkdir($docroot, 0755, true); file_put_contents($docroot . '/index.php', 'startProcessor( ['/'], $docroot, false, @@ -159,8 +159,8 @@ public function testFollowedRelativeSymlinkIndexesAnIntermediateLink(): void symlink($this->tempDir . '/real', $alias); symlink('../alias/./target', $docroot . '/link'); - $processor = FileIndexProcessor::start( - [ (string) realpath($docroot) ], + $processor = $this->startProcessor( + [$docroot], $docroot, true, true, @@ -174,9 +174,12 @@ public function testFollowedRelativeSymlinkIndexesAnIntermediateLink(): void } $processor->close(); + $physicalAlias = (string) realpath(dirname($alias)) . '/alias'; $intermediate_entries = array_values(array_filter( $entries, - static fn(array $entry): bool => ( $entry['intermediate'] ?? false ) === true + static fn(array $entry): bool => + ( $entry['intermediate'] ?? false ) === true + && $entry['path'] === $physicalAlias )); $this->assertCount(1, $intermediate_entries); $this->assertSame('link', $intermediate_entries[0]['type']); @@ -196,8 +199,8 @@ public function testResumeWithACompletedCursorRemainsComplete(): void $docroot = $this->tempDir . '/site'; mkdir($docroot, 0755, true); - $processor = FileIndexProcessor::start( - [realpath($docroot)], + $processor = $this->startProcessor( + [$docroot], $docroot, false, true, @@ -215,7 +218,7 @@ public function testResumeWithACompletedCursorRemainsComplete(): void $processor->close(); $resumed = FileIndexProcessor::resume( - [realpath($docroot)], + [$this->root($docroot)], $cursor, false, true, @@ -229,8 +232,8 @@ public function testStepAfterCloseIsRejected(): void { $docroot = $this->tempDir . '/site'; mkdir($docroot, 0755, true); - $processor = FileIndexProcessor::start( - [realpath($docroot)], + $processor = $this->startProcessor( + [$docroot], $docroot, false, true, @@ -292,30 +295,46 @@ public function testFileSymlinkRootIsIndexedAsTheLinkItself(): void $this->assertArrayNotHasKey('target', $result['entries'][0]); } - public function testBrokenSymlinkRootIsIndexedRatherThanRejected(): void + public function testRootRecordRejectsAnUnresolvedSymlink(): void { $docroot = $this->tempDir . '/site'; mkdir($docroot, 0755, true); symlink('absent.php', $docroot . '/broken.php'); $brokenPath = (string) realpath($docroot) . '/broken.php'; - $result = $this->collectEntries([$brokenPath], $brokenPath); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("File-index root missing resolved_path: {$brokenPath}"); - $this->assertCount(1, $result['entries']); - $this->assertSame($brokenPath, $result['entries'][0]['path']); - $this->assertSame('link', $result['entries'][0]['type']); + FileIndexProcessor::start([ + [ + 'requested_path' => $brokenPath, + 'resolved_path' => null, + 'type' => 'symlink', + ], + ], [ + 'requested_path' => $brokenPath, + 'resolved_path' => null, + 'type' => 'symlink', + ], false, true, ''); } - public function testMissingFileRootNamesTheObservedPath(): void + public function testStartRootMustBeConfiguredOrADirectory(): void { $docroot = $this->tempDir . '/site'; mkdir($docroot, 0755, true); - $missingPath = $docroot . '/absent.php'; + $unconfiguredPath = $docroot . '/unconfigured.php'; + file_put_contents($unconfiguredPath, 'expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($missingPath); + $this->expectExceptionMessage('File-index start root must be a configured root or a directory'); - FileIndexProcessor::start([$docroot], $missingPath, false, false, ''); + FileIndexProcessor::start( + [$this->root($docroot)], + $this->root($unconfiguredPath), + false, + false, + '' + ); } public function testFileRootInsideASkippedDirectoryIsOmitted(): void @@ -353,7 +372,7 @@ public function testFileRootInsideTheStoragePathIsNeverIndexed(): void $storagePath = (string) realpath($docroot . '/.reprint'); $senderPath = $storagePath . '/sender.json'; - $processor = FileIndexProcessor::start([$senderPath], $senderPath, false, true, $storagePath); + $processor = $this->startProcessor([$senderPath], $senderPath, false, true, $storagePath); $entries = []; while ($processor->next_index_step()) { foreach ($processor->get_index_entries() as $entry) { @@ -433,7 +452,14 @@ private function collectEntries( bool $includeCaches = true, bool $resumeAfterEveryStep = false ): array { - $processor = FileIndexProcessor::start($roots, $indexDirectory, false, $includeCaches, ''); + $root_records = array_map([$this, 'root'], $roots); + $processor = FileIndexProcessor::start( + $root_records, + $this->root($indexDirectory), + false, + $includeCaches, + '' + ); $entries = []; $statuses = []; while ($processor->next_index_step()) { @@ -444,7 +470,13 @@ private function collectEntries( if ($resumeAfterEveryStep) { $cursor = json_encode($processor->get_cursor(), JSON_THROW_ON_ERROR); $processor->close(); - $processor = FileIndexProcessor::resume($roots, $cursor, false, $includeCaches, ''); + $processor = FileIndexProcessor::resume( + $root_records, + $cursor, + false, + $includeCaches, + '' + ); } } $processor->close(); @@ -452,6 +484,50 @@ private function collectEntries( return ['entries' => $entries, 'statuses' => $statuses]; } + /** + * @param string[] $roots File-index root paths. + */ + private function startProcessor( + array $roots, + string $start, + bool $followSymlinks, + bool $includeCaches, + string $storagePath + ): FileIndexProcessor { + $rootRecords = array_map([$this, 'root'], $roots); + return FileIndexProcessor::start( + $rootRecords, + $this->root($start), + $followSymlinks, + $includeCaches, + $storagePath + ); + } + + /** + * @return array{requested_path:string,resolved_path:string,type:'directory'|'file'|'symlink'} + */ + private function root(string $path): array + { + $stat = lstat($path); + if ($stat === false) { + throw new RuntimeException("Test root does not exist: {$path}"); + } + $resolvedPath = realpath($path); + if ($resolvedPath === false) { + throw new RuntimeException("Test root does not resolve: {$path}"); + } + $mode = $stat['mode'] & FileIndexProcessor::STAT_TYPE_MASK; + $type = $mode === FileIndexProcessor::STAT_TYPE_LINK + ? 'symlink' + : ( is_dir($path) ? 'directory' : 'file' ); + return [ + 'requested_path' => \WordPress\Reprint\Exporter\normalize_path($path), + 'resolved_path' => $resolvedPath, + 'type' => $type, + ]; + } + /** * @return array { * Completed traversal. @@ -464,9 +540,10 @@ private function collectEntries( private function runProcessor(string $docroot, bool $resumeAfterEveryStep): array { $canonicalDocroot = realpath($docroot); + $root = $this->root((string) $canonicalDocroot); $processor = FileIndexProcessor::start( - [$canonicalDocroot], - $canonicalDocroot, + [$root], + $root, false, false, '' @@ -483,7 +560,7 @@ private function runProcessor(string $docroot, bool $resumeAfterEveryStep): arra $cursor = json_encode($processor->get_cursor(), JSON_THROW_ON_ERROR); $processor->close(); $processor = FileIndexProcessor::resume( - [$canonicalDocroot], + [$root], $cursor, false, false, From 37699c4cf1c7251590056f18e4fcdabc73271743 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 12:36:40 +0200 Subject: [PATCH 16/30] Clarify no-follow symlink selection --- README.md | 6 ++++++ packages/reprint-server/src/utils.php | 1 - tests/FileIndexEndpointRunnerTrait.php | 8 ++++++-- tests/FileIndexFilePathRootTest.php | 17 +++++++++++++++++ ...test.js => import-60-only-file-path.test.js} | 2 +- 5 files changed, 30 insertions(+), 4 deletions(-) rename tests/e2e/tests/{import-56-only-file-path.test.js => import-60-only-file-path.test.js} (97%) diff --git a/README.md b/README.md index dfb1a1a38..d318dd06a 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,12 @@ absolute source paths, and exclusions win when the prefixes overlap. Switching filters after a completed run starts a new filtered delta against the shared remote index; there is no separate skipped-file list or fetch stage. +Symlinks are followed by default. With `--no-follow-symlinks`, a selected +symlink is copied as a link without indexing its target. A selected path reached +through a symlinked parent is rejected; use the default or +`--follow-symlinks` so Reprint can preserve the requested link path and index +the physical target. + #### Pull only files. `pull-files` runs the file side of the high-level pull pipeline: diff --git a/packages/reprint-server/src/utils.php b/packages/reprint-server/src/utils.php index 600eef6d1..93aaec1b2 100644 --- a/packages/reprint-server/src/utils.php +++ b/packages/reprint-server/src/utils.php @@ -35,7 +35,6 @@ function str_contains(string $haystack, string $needle): bool { use InvalidArgumentException; use RuntimeException; -use function WordPress\Filesystem\wp_join_unix_paths; // Composer's "files" autoload includes this file once per registered // path. In a monorepo where the same package is mirrored into vendor/ diff --git a/tests/FileIndexEndpointRunnerTrait.php b/tests/FileIndexEndpointRunnerTrait.php index 2cf9f4723..f7056bc37 100644 --- a/tests/FileIndexEndpointRunnerTrait.php +++ b/tests/FileIndexEndpointRunnerTrait.php @@ -11,7 +11,11 @@ trait FileIndexEndpointRunnerTrait * @param string[] $directories * @return string[] */ - protected function runFileIndex(array $directories, string $listDir): array + protected function runFileIndex( + array $directories, + string $listDir, + bool $followSymlinks = true + ): array { $configPath = $this->tempDir . '/config.json'; file_put_contents( @@ -19,7 +23,7 @@ protected function runFileIndex(array $directories, string $listDir): array json_encode([ 'directory' => $directories, 'list_dir' => $listDir, - 'follow_symlinks' => true, + 'follow_symlinks' => $followSymlinks, 'batch_size' => 1000, ], JSON_THROW_ON_ERROR), ); diff --git a/tests/FileIndexFilePathRootTest.php b/tests/FileIndexFilePathRootTest.php index ec2c8bab7..3c45569a9 100644 --- a/tests/FileIndexFilePathRootTest.php +++ b/tests/FileIndexFilePathRootTest.php @@ -68,4 +68,21 @@ public function testEndpointIndexesAFollowedTargetOutsideTheConfiguredRoot(): vo $this->assertContains($site . '/theme', $paths); $this->assertContains($target . '/style.css', $paths); } + + public function testEndpointCopiesASelectedDirectorySymlinkWithoutFollowingItsTarget(): void + { + $site = $this->tempDir . '/site'; + $shared = $this->tempDir . '/shared'; + mkdir($site, 0755, true); + mkdir($shared . '/theme', 0755, true); + file_put_contents($shared . '/theme/style.css', 'body{}'); + $site = (string) realpath($site); + $shared = (string) realpath($shared); + $link = $site . '/theme'; + symlink($shared . '/theme', $link); + + $paths = $this->runFileIndex([$link], $link, false); + + $this->assertSame([$link], $paths); + } } diff --git a/tests/e2e/tests/import-56-only-file-path.test.js b/tests/e2e/tests/import-60-only-file-path.test.js similarity index 97% rename from tests/e2e/tests/import-56-only-file-path.test.js rename to tests/e2e/tests/import-60-only-file-path.test.js index 1f516008b..cf9a3a62c 100644 --- a/tests/e2e/tests/import-56-only-file-path.test.js +++ b/tests/e2e/tests/import-60-only-file-path.test.js @@ -1,4 +1,4 @@ -/** Test 56: `--only` accepts a single file, not just a directory (issue #539). */ +/** Test 60: `--only` accepts a single file, not just a directory (issue #539). */ import { describe, it, beforeAll, afterAll } from 'vitest'; import assert from 'node:assert/strict'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; From 93ca3d28b218b4748a16cc15ed74eeb9380046b2 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 14:58:56 +0200 Subject: [PATCH 17/30] [Pull] Cover selected directory symlink roots --- packages/reprint-server/src/export.php | 5 ++ tests/e2e/site-registry.json | 9 ++- ...t-61-only-symlinked-directory-root.test.js | 65 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/tests/import-61-only-symlinked-directory-root.test.js diff --git a/packages/reprint-server/src/export.php b/packages/reprint-server/src/export.php index f89a0e761..807f5db5f 100644 --- a/packages/reprint-server/src/export.php +++ b/packages/reprint-server/src/export.php @@ -1331,6 +1331,11 @@ function resolve_directories(array $config): array /** * Resolves the configured roots for the file-index endpoint. * + * `directory[]` is the established wire parameter name, but file_index treats + * each value as a selected root: a directory, regular file, or symlink. + * Keep resolve_directories() for preflight and file_fetch, whose callers need + * actual directories. + * * requested_path retains the caller's normalized spelling. resolved_path is * the physical target used for walking and target de-duplication. * diff --git a/tests/e2e/site-registry.json b/tests/e2e/site-registry.json index 37a0b2e9e..f7704d4d7 100644 --- a/tests/e2e/site-registry.json +++ b/tests/e2e/site-registry.json @@ -140,6 +140,12 @@ "db-index-interruption": { "port": 8125 }, + "only-file-path": { + "port": 8131 + }, + "only-symlinked-directory-root": { + "port": 8132 + }, "mysql-session-settings-resume": { "port": 8126 }, @@ -154,9 +160,6 @@ }, "files-pull-mirror": { "port": 8130 - }, - "only-file-path": { - "port": 8131 } } } diff --git a/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js b/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js new file mode 100644 index 000000000..a5eb54ebe --- /dev/null +++ b/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js @@ -0,0 +1,65 @@ +/** Test 61: `--only` preserves a selected symlinked directory. */ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import assert from 'node:assert/strict'; +import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + runImporter, createTempDir, cleanupTempDir, + getSiteUrl, getSiteSecret, getSiteDir, fsRootDir, +} from '../lib/test-helpers.js'; +import { ensureSite } from '../lib/site-setup.js'; + +describe('Import: files-pull --only ', { timeout: 180000 }, () => { + const site = 'only-symlinked-directory-root'; + const linkTarget = '../../shared/akismet-5.7'; + let tempDir; + let siteDir; + + beforeAll(async () => { + await ensureSite(site, { + afterCreate: async (remoteSiteDir) => { + const target = join(remoteSiteDir, 'shared', 'akismet-5.7'); + const link = join(remoteSiteDir, 'wp-content', 'plugins', 'akismet'); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, 'akismet.php'), ' { + cleanupTempDir(tempDir); + }); + + function importUrl() { + return `${getSiteUrl(site)}&directory=${siteDir}`; + } + + it('files-pull completes when --only names the symlink', () => { + const result = runImporter(importUrl(), tempDir, 'files-pull', { + secret: getSiteSecret(site), + extraArgs: ['--only', join(siteDir, 'wp-content', 'plugins', 'akismet')], + }); + assert.equal( + result.exitCode, 0, + `Expected exit 0\nstderr: ${result.stderr}\nstdout: ${result.stdout}`, + ); + }); + + it('recreates the selected symlink with its original target spelling', () => { + const importedRoot = join(fsRootDir(tempDir), siteDir); + const link = join(importedRoot, 'wp-content', 'plugins', 'akismet'); + assert.ok(lstatSync(link).isSymbolicLink(), `Expected symlink at ${link}`); + assert.equal(readFileSync(link, 'utf-8'), ' { + const importedRoot = join(fsRootDir(tempDir), siteDir); + assert.ok(existsSync(join(importedRoot, 'shared', 'akismet-5.7', 'akismet.php'))); + assert.ok(!existsSync(join(importedRoot, 'wp-admin'))); + assert.ok(!existsSync(join(importedRoot, 'wp-includes'))); + }); +}); From fe47b61d4ed95621f0c8526a83c50a621b2574bd Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 15:16:15 +0200 Subject: [PATCH 18/30] [Pull] Make selected symlink E2E setup idempotent --- .../e2e/tests/import-61-only-symlinked-directory-root.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js b/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js index a5eb54ebe..9effe3578 100644 --- a/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js +++ b/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js @@ -1,7 +1,7 @@ /** Test 61: `--only` preserves a selected symlinked directory. */ import { describe, it, beforeAll, afterAll } from 'vitest'; import assert from 'node:assert/strict'; -import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { runImporter, createTempDir, cleanupTempDir, @@ -22,6 +22,7 @@ describe('Import: files-pull --only ', { timeout: 180000 }, const link = join(remoteSiteDir, 'wp-content', 'plugins', 'akismet'); mkdirSync(target, { recursive: true }); writeFileSync(join(target, 'akismet.php'), ' Date: Mon, 17 Aug 2026 15:18:33 +0200 Subject: [PATCH 19/30] [Pull] Clarify empty directory index comment --- packages/reprint-server/src/class-file-index-processor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 9c2344444..df6ede816 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -995,8 +995,8 @@ private static function index_entries_for_path( $item["target"] = $link_target; } if ($type === "dir") { - // This is physical emptiness, not a directory whose children are - // merely excluded from synchronization. + // Actual empty directory, not a directory with all its children + // excluded from the synchronization $directory_handle = @opendir($path); if ($directory_handle !== false) { $item["empty"] = true; From 3e3cad55f7c6371319c68237aca36cb4077cc62a Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 15:56:17 +0200 Subject: [PATCH 20/30] [Pull] Clarify file-index root scheduling --- .../src/class-file-index-processor.php | 63 ++++++++++--------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index df6ede816..611815e00 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -48,8 +48,8 @@ final class FileIndexProcessor { /** @var array[] Configured file-index roots, in requested order. */ private $roots; - /** @var string[] Canonical directories allowed during traversal. */ - private $directories; + /** @var string[] Canonical directories selected by the request. */ + private $configured_directories; /** @var bool Whether directory symlinks may lead outside the allowed directories. */ private $follow_symlinks; @@ -126,7 +126,7 @@ public static function start( ); } - $directories = self::resolved_directory_roots($roots, $follow_symlinks); + $configured_directories = self::resolved_directory_roots($roots, $follow_symlinks); // Visit the requested directory first, followed by every other root in // stable byte order. Stable ordering makes a cursor independent of the @@ -143,27 +143,23 @@ public static function start( }); $ordered_roots = array_merge($ordered_roots, $extra_roots); - // `--only` may select wp-config.php or a symlink. Index one named root - // per step, then continue with directory walking. - $directory_roots = []; - $path_roots = []; + // A selected directory symlink has two responsibilities: emit its + // requested link entry and traverse its resolved target. Keep the + // two work lists separate so each follows its own coordinate. + $traversal_directories = self::resolved_directory_roots($ordered_roots, $follow_symlinks); + $pending_named_roots = []; foreach ($ordered_roots as $root) { - if ($root["type"] === "directory" || ($follow_symlinks && $root["type"] === "symlink" && is_dir($root["resolved_path"]))) { - if (!in_array($root["resolved_path"], $directory_roots, true)) { - $directory_roots[] = $root["resolved_path"]; - } - } if ($root["type"] !== "directory") { - $path_roots[] = $root["requested_path"]; + $pending_named_roots[] = $root["requested_path"]; } } // The last stack element is visited next, so reverse the desired order // while constructing the depth-first traversal stack. $directory_stack = []; - for ($i = count($directory_roots) - 1; $i >= 0; $i--) { + for ($i = count($traversal_directories) - 1; $i >= 0; $i--) { $directory_stack[] = [ - "dir" => $directory_roots[$i], + "dir" => $traversal_directories[$i], "after" => null, ]; } @@ -190,14 +186,14 @@ public static function start( return new self( $roots, - $directories, + $configured_directories, $follow_symlinks, $include_caches, $storage_path, $directory_stack, $reported_index_directory, $initial_index_entries, - $path_roots + $pending_named_roots ); } @@ -219,7 +215,7 @@ public static function resume( string $storage_path ): self { $roots = self::validate_roots($roots); - $directories = self::resolved_directory_roots($roots, $follow_symlinks); + $configured_directories = self::resolved_directory_roots($roots, $follow_symlinks); // A cursor is caller-held continuation state. Reject malformed JSON or // a missing stack before any filesystem work begins. @@ -288,11 +284,11 @@ public static function resume( // directory, so it falls back to the first configured root. $index_directory = !empty($directory_stack) ? $directory_stack[count($directory_stack) - 1]["dir"] - : ( isset($directories[0]) ? $directories[0] : "/" ); + : ( isset($configured_directories[0]) ? $configured_directories[0] : "/" ); return new self( $roots, - $directories, + $configured_directories, $follow_symlinks, $include_caches, $storage_path, @@ -403,7 +399,7 @@ public function next_index_step(): bool $canonical_directory = realpath($path); if ( $canonical_directory === false - || !\WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of($this->directories, $canonical_directory) + || !\WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of($this->configured_directories, $canonical_directory) ) { $this->directory_stack[] = [ "dir" => $path, @@ -582,7 +578,7 @@ public static function path_is_default_skipped(string $path): bool * Initializes common traversal state. * * @param array[] $roots Structured file-index roots. - * @param string[] $directories Canonical directories allowed during traversal. + * @param string[] $configured_directories Canonical directories selected by the request. * @param bool $follow_symlinks Whether directory symlinks may leave the allowed directories. * @param bool $include_caches Whether generated caches and development files are included. * @param string $storage_path Reprint storage path omitted from the index, or an empty string. @@ -593,7 +589,7 @@ public static function path_is_default_skipped(string $path): bool */ private function __construct( array $roots, - array $directories, + array $configured_directories, bool $follow_symlinks, bool $include_caches, string $storage_path, @@ -603,7 +599,7 @@ private function __construct( array $pending_named_roots = [] ) { $this->roots = $roots; - $this->directories = $directories; + $this->configured_directories = $configured_directories; $this->follow_symlinks = $follow_symlinks; $this->include_caches = $include_caches; $this->storage_path = self::canonical_storage_path($storage_path); @@ -651,7 +647,7 @@ private function open_current_directory(): bool // boundary, then continue with the remaining stack. if ( !$this->follow_symlinks - && !\WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of($canonical_directory, $this->directories) + && !\WordPress\Reprint\Exporter\path_is_same_as_or_descendant_of($canonical_directory, $this->configured_directories) ) { array_pop($this->directory_stack); $this->directory_error = [ @@ -812,7 +808,7 @@ private function index_next_named_root(): void && $this->resolved_target_was_indexed($root); // A selected symlink always remains at its requested path. When - // followed, its target content is emitted in the physical namespace + // followed, its target content is emitted in the resolved-path namespace // that normal traversal already uses. Two aliases may therefore share // one target entry while both link entries remain present. if (!( $root["type"] === "file" && $resolved_target_was_indexed )) { @@ -840,7 +836,7 @@ private function index_next_named_root(): void ) { // A regular root reached through no link normally has identical // coordinates. Keep this branch for records supplied by callers - // which already normalized a physical file root. + // which already normalized a resolved file root. $entries = array_merge( $entries, self::index_entries_for_path($root["resolved_path"], $stat, false)["entries"] @@ -861,7 +857,7 @@ private function find_root(string $requested_path): ?array return null; } - /** Whether an earlier named root already emitted this physical target. */ + /** Whether an earlier named root already emitted this resolved target. */ private function resolved_target_was_indexed(array $root): bool { foreach ($this->roots as $candidate) { @@ -934,7 +930,14 @@ private static function validate_root($root): array ]; } - /** Returns physical directory roots, including followed directory links. */ + /** + * Returns resolved directory roots, including followed directory links. + * + * @param FileIndexRoot[] $roots Structured roots. Each has requested_path, + * resolved_path, and type keys; type is directory, + * file, symlink, or missing. + * @return string[] Resolved directory paths. + */ private static function resolved_directory_roots(array $roots, bool $follow_symlinks): array { $directories = []; @@ -1111,7 +1114,7 @@ private static function find_parent_symlinks(string $absolute_path): array // Keep the requested spelling while inspecting each parent. PHP follows // a parent link when checking the next component, so changing $current - // to realpath() would turn later emitted links into physical paths. + // to realpath() would turn later emitted links into resolved paths. foreach ($parts as $part) { if ($part === "") { $current = "/"; From 838cddae75bd9ee61ca24d468c0be09dbd547bc6 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 17 Aug 2026 16:35:49 +0200 Subject: [PATCH 21/30] [Pull] Read selected symlink directory content --- tests/e2e/tests/import-61-only-symlinked-directory-root.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js b/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js index 9effe3578..93cef2103 100644 --- a/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js +++ b/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js @@ -53,7 +53,7 @@ describe('Import: files-pull --only ', { timeout: 180000 }, const importedRoot = join(fsRootDir(tempDir), siteDir); const link = join(importedRoot, 'wp-content', 'plugins', 'akismet'); assert.ok(lstatSync(link).isSymbolicLink(), `Expected symlink at ${link}`); - assert.equal(readFileSync(link, 'utf-8'), ' Date: Mon, 17 Aug 2026 16:56:47 +0200 Subject: [PATCH 22/30] Fix mixed-version exporter utility loading --- packages/reprint-server/src/utils.php | 36 +++++++++++---------------- tests/ExportHttpServerTest.php | 33 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/packages/reprint-server/src/utils.php b/packages/reprint-server/src/utils.php index 93aaec1b2..602b8e571 100644 --- a/packages/reprint-server/src/utils.php +++ b/packages/reprint-server/src/utils.php @@ -183,27 +183,6 @@ function normalize_path(string $path): string return "/" . implode("/", $resolved); } -/** - * Removes trailing slashes without changing the filesystem root into an empty path. - * - * Unlike rtrim($path, '/'), this returns `/` for both the filesystem root and - * an empty input. It only changes the lexical spelling; it does not validate - * the path or resolve dot segments and symlinks. - * - * Examples: - * - * trim_right_slash('/srv/site///'); // '/srv/site' - * trim_right_slash('/'); // '/' - * trim_right_slash(''); // '/' - * - * @param string $path Path whose trailing slashes to remove. - * @return string A path without trailing slashes, or `/` for the filesystem root. - */ -function trim_right_slash(string $path): string -{ - return rtrim($path, '/') ?: '/'; -} - /** * Canonicalizes an absolute path through the nearest ancestor realpath() can resolve. * @@ -531,4 +510,19 @@ function assert_valid_path(string $path, string $label = "path"): void } // !function_exists guard +// An older copy may have declared the original guard function before this +// version loads. Keep helpers added later available in that mixed-version case. +if (!function_exists(__NAMESPACE__ . '\\trim_right_slash')) { + /** + * Removes trailing slashes without changing the filesystem root into an empty path. + * + * @param string $path Path whose trailing slashes to remove. + * @return string A path without trailing slashes, or `/` for the filesystem root. + */ + function trim_right_slash(string $path): string + { + return rtrim($path, '/') ?: '/'; + } +} + } diff --git a/tests/ExportHttpServerTest.php b/tests/ExportHttpServerTest.php index a556511b3..c41d98a25 100644 --- a/tests/ExportHttpServerTest.php +++ b/tests/ExportHttpServerTest.php @@ -6,6 +6,39 @@ final class ExportHttpServerTest extends TestCase { + public function testNewUtilityLoadsAfterAnOlderUtilityCopy(): void + { + $utils_path = realpath(__DIR__ . '/../packages/reprint-server/src/utils.php'); + $this->assertNotFalse($utils_path, 'utils.php must exist'); + + $script = <<<'PHP' +namespace WordPress\Reprint\Exporter { + function build_pdo_dsn(string $db_host, string $db_name): string { + return ''; + } +} + +namespace { + require $argv[1]; + echo \WordPress\Reprint\Exporter\trim_right_slash('/srv/site/'); +} +PHP; + $process = proc_open( + [PHP_BINARY, '-r', $script, $utils_path], + [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], + $pipes + ); + $this->assertIsResource($process, 'Failed to start utility loader subprocess.'); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + $this->assertSame(0, proc_close($process), $stderr ?: 'Utility loader subprocess failed.'); + $this->assertSame('/srv/site', $stdout); + } + public function testParsesJsonBodyAndCastsKnownTypes(): void { $server = new Site_Export_HTTP_Server(); From 6b0e165e664a56c45165f8271f56990861b68100 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 18 Aug 2026 09:33:39 +0200 Subject: [PATCH 23/30] Revert "Fix mixed-version exporter utility loading" This reverts commit e194b6766d4e83fa18257f65f2d5796965cfb7b1. --- packages/reprint-server/src/utils.php | 36 ++++++++++++++++----------- tests/ExportHttpServerTest.php | 33 ------------------------ 2 files changed, 21 insertions(+), 48 deletions(-) diff --git a/packages/reprint-server/src/utils.php b/packages/reprint-server/src/utils.php index 602b8e571..93aaec1b2 100644 --- a/packages/reprint-server/src/utils.php +++ b/packages/reprint-server/src/utils.php @@ -183,6 +183,27 @@ function normalize_path(string $path): string return "/" . implode("/", $resolved); } +/** + * Removes trailing slashes without changing the filesystem root into an empty path. + * + * Unlike rtrim($path, '/'), this returns `/` for both the filesystem root and + * an empty input. It only changes the lexical spelling; it does not validate + * the path or resolve dot segments and symlinks. + * + * Examples: + * + * trim_right_slash('/srv/site///'); // '/srv/site' + * trim_right_slash('/'); // '/' + * trim_right_slash(''); // '/' + * + * @param string $path Path whose trailing slashes to remove. + * @return string A path without trailing slashes, or `/` for the filesystem root. + */ +function trim_right_slash(string $path): string +{ + return rtrim($path, '/') ?: '/'; +} + /** * Canonicalizes an absolute path through the nearest ancestor realpath() can resolve. * @@ -510,19 +531,4 @@ function assert_valid_path(string $path, string $label = "path"): void } // !function_exists guard -// An older copy may have declared the original guard function before this -// version loads. Keep helpers added later available in that mixed-version case. -if (!function_exists(__NAMESPACE__ . '\\trim_right_slash')) { - /** - * Removes trailing slashes without changing the filesystem root into an empty path. - * - * @param string $path Path whose trailing slashes to remove. - * @return string A path without trailing slashes, or `/` for the filesystem root. - */ - function trim_right_slash(string $path): string - { - return rtrim($path, '/') ?: '/'; - } -} - } diff --git a/tests/ExportHttpServerTest.php b/tests/ExportHttpServerTest.php index c41d98a25..a556511b3 100644 --- a/tests/ExportHttpServerTest.php +++ b/tests/ExportHttpServerTest.php @@ -6,39 +6,6 @@ final class ExportHttpServerTest extends TestCase { - public function testNewUtilityLoadsAfterAnOlderUtilityCopy(): void - { - $utils_path = realpath(__DIR__ . '/../packages/reprint-server/src/utils.php'); - $this->assertNotFalse($utils_path, 'utils.php must exist'); - - $script = <<<'PHP' -namespace WordPress\Reprint\Exporter { - function build_pdo_dsn(string $db_host, string $db_name): string { - return ''; - } -} - -namespace { - require $argv[1]; - echo \WordPress\Reprint\Exporter\trim_right_slash('/srv/site/'); -} -PHP; - $process = proc_open( - [PHP_BINARY, '-r', $script, $utils_path], - [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], - $pipes - ); - $this->assertIsResource($process, 'Failed to start utility loader subprocess.'); - fclose($pipes[0]); - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - - $this->assertSame(0, proc_close($process), $stderr ?: 'Utility loader subprocess failed.'); - $this->assertSame('/srv/site', $stdout); - } - public function testParsesJsonBodyAndCastsKnownTypes(): void { $server = new Site_Export_HTTP_Server(); From 3f6fb789d2ea087d0163410ff84676fe01acdca1 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 19 Aug 2026 11:07:52 +0200 Subject: [PATCH 24/30] Fix CLI argument count static analysis --- packages/reprint-client/src/import.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/reprint-client/src/import.php b/packages/reprint-client/src/import.php index fd916c8f7..114e2d7ea 100755 --- a/packages/reprint-client/src/import.php +++ b/packages/reprint-client/src/import.php @@ -12402,6 +12402,8 @@ function get_importer_version(): string { defined('IMPORTER_WRAPPER_ENTRY') ) ) { + $argument_count = count($argv); + // Handle --version before anything else. if (isset($argv[1]) && in_array($argv[1], ["--version", "-V"])) { echo get_importer_version() . "\n"; @@ -13728,7 +13730,7 @@ function _cli_option_usage(array $def): string ]; // Show main help when invoked with no arguments or just --help - if ($argc < 2 || (isset($argv[1]) && in_array($argv[1], ["--help", "-h", "help"]))) { + if ($argument_count < 2 || (isset($argv[1]) && in_array($argv[1], ["--help", "-h", "help"]))) { _cli_render_main_help($option_defs, $command_info); exit(1); } @@ -13779,7 +13781,7 @@ function _cli_option_usage(array $def): string $option_start_index = $reprint_has_remote_reprint_api_url ? 3 : 2; [$state_dir, $filesystem_root, $options] = _cli_parse_options( - $argv, $argc, $option_start_index, $option_defs + $argv, $argument_count, $option_start_index, $option_defs ); $options["command"] = $command; From 5a444bc3a7d49eb65fb07b83396cbe49918856fc Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 19 Aug 2026 14:53:55 +0200 Subject: [PATCH 25/30] Clarify file-index root handling --- packages/reprint-client/src/import.php | 12 +++---- packages/reprint-server/src/export.php | 48 +++++++++++++++++--------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/reprint-client/src/import.php b/packages/reprint-client/src/import.php index 114e2d7ea..1c338ab2e 100755 --- a/packages/reprint-client/src/import.php +++ b/packages/reprint-client/src/import.php @@ -7355,9 +7355,6 @@ private function fetch_file_batch( } $params = $this->get_tuned_params("file_fetch"); - // file_fetch retains its directory-only server contract. --only may - // name a file, so use the preflight directory roots rather than the - // scoped file-index roots used by fetch_next_remote_index(). $fetch_directories = $this->get_root_directories_from_preflight(); if (!empty($fetch_directories)) { $params["directory"] = $fetch_directories; @@ -7915,6 +7912,8 @@ private function compare_remote_indexes_and_build_fetch_list(): bool if ($transition === "deleted") { $remote_path_type = $index_diff->get_path_type_in_old_index(); + // This getter is nullable for `added` transitions. A + // null here means the diff contradicts `deleted`. if ($remote_path_type === null) { throw new LogicException( "Deleted remote index path is absent from the prior remote index: {$remote_absolute_path}" @@ -9913,10 +9912,9 @@ private function is_selected_for_pulling( $included_path_prefix ); if ($remainder === "") { - // Directory roots have no row in a freshly indexed tree, - // so their old row must survive a scoped delta. Named - // file and link roots do have one; their confirmed absence - // must remove the tracked local path. + // A directory selection applies to entries beneath the + // selected path. A file or link selection also applies to + // its exact entry. $selected = $path_type !== "dir"; break; } diff --git a/packages/reprint-server/src/export.php b/packages/reprint-server/src/export.php index 6ec18f51d..5bd21d329 100644 --- a/packages/reprint-server/src/export.php +++ b/packages/reprint-server/src/export.php @@ -1270,21 +1270,30 @@ function resolve_directories(array $config): array } /** - * Resolves the configured roots for the file-index endpoint. + * Builds file-index roots from the file_index request's `directory` parameter. * - * `directory[]` is the established wire parameter name, but file_index treats - * each value as a selected root: a directory, regular file, or symlink. - * Keep resolve_directories() for preflight and file_fetch, whose callers need - * actual directories. + * The `directory` parameter contains the selected paths. In spite of the + * parameter name, a selected path may name a directory, regular file, or + * symlink. * - * requested_path retains the caller's normalized spelling. resolved_path is - * the physical target used for walking and target de-duplication. + * Unlike resolve_directories(), this keeps one file-index root for every + * selected path. `requested_path` is the normalized spelling supplied by the + * client. `resolved_path` is its realpath() target. + * + * FileIndexProcessor adds a link entry at `requested_path` to the file index. + * It walks a followed directory target at `resolved_path`. It also uses + * `resolved_path` so aliases to the same target are indexed once. + * + * Example: for /site/theme -> /shared/theme, this returns a file-index root + * with `requested_path` /site/theme, `resolved_path` /shared/theme, and type + * symlink. With symlink following enabled, it adds the link entry at + * /site/theme and indexes the target tree at /shared/theme. * * @return array[] { * File-index roots. * - * @type string $requested_path Configured normalized root path. - * @type string|null $resolved_path Physical root path, when available. + * @type string $requested_path Normalized path supplied by the client. + * @type string|null $resolved_path realpath() target, when available. * @type string $type directory, file, symlink, or missing. * } */ @@ -1353,20 +1362,25 @@ function resolve_file_index_roots(array $config): array } /** - * Resolves the root scheduled first for a file-index request. + * Returns the file-index root for the file_index request's `list_dir` parameter. + * + * `list_dir` normally names a path from `directory[]`. When following symlinks, + * it may instead name a resolved directory found through a link below one of + * those selected paths. * - * A request normally starts from one of its selected roots. With symlink - * following enabled, the client may instead start a separate request at a - * physical directory reached from a selected link. + * Example: `directory[]` contains /site. If indexing /site finds a link from + * /site/theme to /shared/theme, the client later requests + * `list_dir=/shared/theme`. This function returns a file-index root for + * /shared/theme even though it is not in `directory[]`. * * @param array[] $roots File-index roots returned by resolve_file_index_roots(). - * @param string $list_directory Requested root where this traversal begins. - * @param bool $follow_symlinks Whether a followed target may begin a traversal. + * @param string $list_directory Value sent as `list_dir`. + * @param bool $follow_symlinks Whether `list_dir` may name a directory reached through a link. * @return array { - * Root scheduled first. + * File-index root for `list_dir`. * * @type string $requested_path Requested normalized root path. - * @type string|null $resolved_path Physical root path, when available. + * @type string|null $resolved_path Resolved root path, when available. * @type string $type directory, file, symlink, or missing. * } */ From 37cb8524551425a1b930f7a3649934173b75e898 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 19 Aug 2026 15:17:07 +0200 Subject: [PATCH 26/30] Fix E2E site port collisions --- tests/e2e/site-registry.json | 4 ++-- ...nly-file-path.test.js => import-62-only-file-path.test.js} | 2 +- ...est.js => import-63-only-symlinked-directory-root.test.js} | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename tests/e2e/tests/{import-60-only-file-path.test.js => import-62-only-file-path.test.js} (97%) rename tests/e2e/tests/{import-61-only-symlinked-directory-root.test.js => import-63-only-symlinked-directory-root.test.js} (97%) diff --git a/tests/e2e/site-registry.json b/tests/e2e/site-registry.json index 4ef58de2d..ea3732f37 100644 --- a/tests/e2e/site-registry.json +++ b/tests/e2e/site-registry.json @@ -141,10 +141,10 @@ "port": 8125 }, "only-file-path": { - "port": 8131 + "port": 8134 }, "only-symlinked-directory-root": { - "port": 8132 + "port": 8135 }, "mysql-session-settings-resume": { "port": 8126 diff --git a/tests/e2e/tests/import-60-only-file-path.test.js b/tests/e2e/tests/import-62-only-file-path.test.js similarity index 97% rename from tests/e2e/tests/import-60-only-file-path.test.js rename to tests/e2e/tests/import-62-only-file-path.test.js index cf9a3a62c..69e3b8011 100644 --- a/tests/e2e/tests/import-60-only-file-path.test.js +++ b/tests/e2e/tests/import-62-only-file-path.test.js @@ -1,4 +1,4 @@ -/** Test 60: `--only` accepts a single file, not just a directory (issue #539). */ +/** Test 62: `--only` accepts a single file, not just a directory (issue #539). */ import { describe, it, beforeAll, afterAll } from 'vitest'; import assert from 'node:assert/strict'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; diff --git a/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js b/tests/e2e/tests/import-63-only-symlinked-directory-root.test.js similarity index 97% rename from tests/e2e/tests/import-61-only-symlinked-directory-root.test.js rename to tests/e2e/tests/import-63-only-symlinked-directory-root.test.js index 93cef2103..a0ba514d0 100644 --- a/tests/e2e/tests/import-61-only-symlinked-directory-root.test.js +++ b/tests/e2e/tests/import-63-only-symlinked-directory-root.test.js @@ -1,4 +1,4 @@ -/** Test 61: `--only` preserves a selected symlinked directory. */ +/** Test 63: `--only` preserves a selected symlinked directory. */ import { describe, it, beforeAll, afterAll } from 'vitest'; import assert from 'node:assert/strict'; import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; From bf953a572bccb91737ef10a58105cea161d82794 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 20 Aug 2026 09:29:10 +0200 Subject: [PATCH 27/30] Simplify missing file-index root handling --- packages/reprint-server/src/export.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/reprint-server/src/export.php b/packages/reprint-server/src/export.php index 5bd21d329..7396847b1 100644 --- a/packages/reprint-server/src/export.php +++ b/packages/reprint-server/src/export.php @@ -1318,7 +1318,7 @@ function resolve_file_index_roots(array $config): array $missing_roots = isset($config["missing_roots"]) && is_array($config["missing_roots"]) ? $config["missing_roots"] : []; - if (in_array($requested_path, $missing_roots, true) && file_index_root_is_confirmed_absent($requested_path)) { + if (in_array($requested_path, $missing_roots, true)) { $roots[] = [ "requested_path" => $requested_path, "resolved_path" => null, @@ -1416,14 +1416,6 @@ function resolve_file_index_start_root( ]; } -/** Returns whether the parent can confirm that a selected name is absent. */ -function file_index_root_is_confirmed_absent(string $requested_path): bool -{ - $parent = dirname($requested_path); - $names = @scandir($parent, SCANDIR_SORT_NONE); - return is_array($names) && !in_array(basename($requested_path), $names, true); -} - /** Returns the first symlink in a requested root's parent path. */ function file_index_parent_symlink(string $requested_path): ?array { From 2aeb4a0de177d0cd23731ea2a27f908801db1da5 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 20 Aug 2026 11:07:44 +0200 Subject: [PATCH 28/30] [Pull] Stop deletions climbing outside the indexed scope derive_remote_deletion_root_from_sparse_index() reads the entries either side of a missing path to decide how much the source deleted. With no entry on either side it climbed to the first path component, so a vanished --only file, or a complete pull whose index came back empty, derived /var and removed everything below it. Pass the paths the pull sent as the file_index directory parameter and stop the climb there. That index describes what lies under those paths and says nothing about their parents. A missing path outside them keeps the older inference: --follow-symlinks also indexes link targets that sit under no export directory, and stopping those early leaves a deleted tree half removed, its emptied directories still on disk. previously_indexed_selected_roots() matched index entries by exact path, but the index lists a directory root's contents rather than the root itself, so a selected directory never reached missing_roots and the server rejected its deletion instead of syncing it. Match by prefix. Co-Authored-By: Claude Opus 5 (1M context) --- packages/reprint-client/src/import.php | 53 ++++++- tests/Import/OnlyFilesPathPrefixDiffTest.php | 150 +++++++++++++++++++ tests/Import/PullIndexWalTest.php | 6 +- 3 files changed, 202 insertions(+), 7 deletions(-) diff --git a/packages/reprint-client/src/import.php b/packages/reprint-client/src/import.php index 1c338ab2e..6dc24aae0 100755 --- a/packages/reprint-client/src/import.php +++ b/packages/reprint-client/src/import.php @@ -7896,6 +7896,7 @@ private function compare_remote_indexes_and_build_fetch_list(): bool ); } + $export_directories = $this->get_export_directories(); $has_path = $index_diff->next_path(); while ($has_path) { $paths_processed = 0; @@ -7932,7 +7933,8 @@ private function compare_remote_indexes_and_build_fetch_list(): bool $this->derive_remote_deletion_root_from_sparse_index( $remote_absolute_path, $index_diff->get_preceding_path_in_new_index(), - $index_diff->get_following_path_in_new_index() + $index_diff->get_following_path_in_new_index(), + $export_directories ); $local_absolute_path = $this->remove_remote_path_locally( @@ -8369,6 +8371,7 @@ private function remove_remote_path_locally( * * original missing path * * the nearest path before it in the new index, if any * * the nearest path after it in the new index, if any + * * the list of directories this pull asked the server to index * * For example: * @@ -8395,16 +8398,29 @@ private function remove_remote_path_locally( * The neighboring paths show that `/srv` and `/srv/site` still contain files, * but neither path is within `/srv/site/wp-content`. * + * However, a path can also be missing because this pull never asked about it. + * Run with `--only /srv/site/wp-config.php`, the pull sends that one path, so + * deleting the file leaves the new index empty and there are no neighbors to + * reason from. Nothing then shows that `/srv` and `/srv/site` still hold + * files, and the climb would return `/srv` and delete everything under it. + * + * $export_directories stops that. The climb never rises above the export + * directory holding the missing path, because the new index describes what + * lies under those directories and says nothing about their parents. An + * empty list stops nothing. + * * @param string $missing_remote_path Previously recorded path that is now missing. * @param string|null $nearest_existing_path_before Nearest existing path before the missing path, if any. * @param string|null $nearest_existing_path_after Nearest existing path after the missing path, if any. + * @param string[] $export_directories get_export_directories(): what this pull asked the server to index. * * @return string The shallowest missing parent, or the original path when every parent still contains an entry. */ private function derive_remote_deletion_root_from_sparse_index( string $missing_remote_path, ?string $nearest_existing_path_before, - ?string $nearest_existing_path_after + ?string $nearest_existing_path_after, + array $export_directories ): string { // Use an invalid path that cannot match any validated remote path so // both comparisons below always receive strings. @@ -8417,10 +8433,26 @@ private function derive_remote_deletion_root_from_sparse_index( $missing_remote_path_components = wp_unix_path_segments($missing_remote_path); $remote_parent_components = []; $remote_parent_component_count = count($missing_remote_path_components) - 1; + // With --follow-symlinks the pull also indexes wherever a link inside an + // export directory points, and that can be anywhere on the remote machine. + // No export directory sits above such a path, and nothing here can tell + // where its tree starts, so only a path that does sit under one stops early. + // Stopping the rest early would leave a deleted target tree half removed, + // its emptied directories still on disk. + $stop_at_export_directory = $export_directories !== [] + && path_is_same_as_or_descendant_of($missing_remote_path, $export_directories); // Find the shallowest parent absent from both neighboring entries. for ($component_index = 0; $component_index < $remote_parent_component_count; ++$component_index) { $remote_parent_components[] = $missing_remote_path_components[$component_index]; $path_prefix = wp_join_unix_paths("/", ...$remote_parent_components); + // A parent above every export directory proves nothing: the next remote + // index never covered it, so its absence is not evidence of deletion. + if ( + $stop_at_export_directory + && !path_is_same_as_or_descendant_of($path_prefix, $export_directories) + ) { + continue; + } if ( !path_is_same_as_or_descendant_of( $nearest_existing_path_before, @@ -9832,7 +9864,10 @@ private function resolve_remote_paths( * when it can confirm their current absence. A newly typed missing path is * still rejected at the endpoint. * - * @return string[] Selected roots present in the prior remote index. + * A root counts as tracked when the prior index holds it or anything under + * it, because a directory root appears there only through its contents. + * + * @return string[] Selected roots the prior remote index covers. */ private function previously_indexed_selected_roots(): array { @@ -9855,8 +9890,16 @@ private function previously_indexed_selected_roots(): array continue; } $path = base64_decode($entry['path'], true); - if ($path !== false) { - unset($remaining[$path]); + if ($path === false) { + continue; + } + // The index lists a directory root's contents rather than the root + // entry, so an exact match would never track a selected directory + // and its later deletion would be rejected instead of synced. + foreach (array_keys($remaining) as $selected_root) { + if (path_is_same_as_or_descendant_of($path, $selected_root)) { + unset($remaining[$selected_root]); + } } } } finally { diff --git a/tests/Import/OnlyFilesPathPrefixDiffTest.php b/tests/Import/OnlyFilesPathPrefixDiffTest.php index ec0806bc6..62041ca2f 100644 --- a/tests/Import/OnlyFilesPathPrefixDiffTest.php +++ b/tests/Import/OnlyFilesPathPrefixDiffTest.php @@ -137,6 +137,83 @@ private function prepareClient( return [$client, $r]; } + /** Load state for an unscoped sync whose enumerated roots come from preflight. */ + private function prepareCompleteSyncClient(array $preflightRoots): array + { + $roots = []; + foreach ($preflightRoots as $path) { + $roots[] = ["path" => $path]; + } + [$client, $reflection] = $this->prepareClient([]); + $client->get_state()->set_preflight_record([ + 'data' => ['wp_detect' => ['roots' => $roots]], + ]); + $reflection->getProperty('audit_log_file')->setValue($client, $this->tempDir . '/audit.log'); + return [$client, $reflection]; + } + + /** + * A complete sync is bounded too. When the whole source index comes back empty the + * deletion must stop at the enumerated root rather than climbing to /var. + */ + public function testCompleteSyncWithAnEmptyIndexStopsAtTheEnumeratedRoot(): void + { + $this->writeIndex( + 'remote-index.jsonl', + $this->indexLine('/var/www/html/wp-config.php', 1000, 10) + ); + $this->writeIndex('remote-index.next.jsonl', ''); + $inside = $this->seedLocalFile('/var/www/html/wp-config.php'); + $outside = $this->seedLocalFile('/var/backups/keep.txt'); + + [$client, $reflection] = $this->prepareCompleteSyncClient(['/var/www/html']); + $this->assertSame( + ['/var/www/html'], + $reflection->getMethod('get_export_directories')->invoke($client), + 'Test premise: preflight supplies the enumerated scope.' + ); + + $reflection->getMethod('compare_remote_indexes_and_build_fetch_list')->invoke($client); + $reflection->getProperty('pull_index_journal') + ->getValue($client) + ->apply_pending_records(); + + $this->assertFileDoesNotExist($inside); + $this->assertFileExists($outside); + } + + /** + * Following symlinks indexes targets outside every export directory. Bounding + * those would stop the climb collapsing a deleted target tree, leaving the + * emptied directories behind, so they keep the neighbor-only inference. + */ + public function testDeletedFollowedTargetOutsideExportDirectoriesStillCollapses(): void + { + $this->writeIndex( + 'remote-index.jsonl', + $this->indexLine('/shared/theme/style.css', 1000, 10) + . $this->indexLine('/shared/theme/sub/b.css', 1000, 10) + . $this->indexLine('/var/www/html/wp-config.php', 1000, 10) + ); + $this->writeIndex( + 'remote-index.next.jsonl', + $this->indexLine('/var/www/html/wp-config.php', 1000, 10) + ); + $this->seedLocalFile('/shared/theme/style.css'); + $this->seedLocalFile('/shared/theme/sub/b.css'); + $kept = $this->seedLocalFile('/var/www/html/wp-config.php'); + + [$client, $reflection] = $this->prepareCompleteSyncClient(['/var/www/html']); + $reflection->getMethod('compare_remote_indexes_and_build_fetch_list')->invoke($client); + $reflection->getProperty('pull_index_journal')->getValue($client)->apply_pending_records(); + + $this->assertDirectoryDoesNotExist( + $this->filesystem_root . '/shared/theme', + 'The emptied followed-target tree must go, not just its files.' + ); + $this->assertFileExists($kept); + } + public function testOnlyFilesPrefixDiffKeepsUnselectedAndDeletesSelectedOrphan(): void { // Remote index (sorted): an unselected entry, a matched selected file, @@ -347,4 +424,77 @@ public function testOnlyPreviouslyIndexedRootsMayBeReportedMissing(): void $reflection->getMethod('previously_indexed_selected_roots')->invoke($client) ); } + + /** + * A selected file that vanishes leaves the next remote index empty, so the + * deletion root deriver sees no neighboring entry on either side. It must not + * read that silence as permission to climb to the first path component. + */ + public function testVanishedSelectedFileRootDeletesNothingAboveItself(): void + { + $this->writeIndex( + 'remote-index.jsonl', + $this->indexLine('/var/www/html/wp-config.php', 1000, 10) + ); + $this->writeIndex('remote-index.next.jsonl', ''); + $selected = $this->seedLocalFile('/var/www/html/wp-config.php'); + $unrelated = $this->seedLocalFile('/var/www/html/wp-content/themes/x/style.css'); + + [$client, $reflection] = $this->prepareClient(['/var/www/html/wp-config.php']); + $reflection->getMethod('compare_remote_indexes_and_build_fetch_list')->invoke($client); + $reflection->getProperty('pull_index_journal') + ->getValue($client) + ->apply_pending_records(); + + $this->assertFileDoesNotExist($selected); + $this->assertFileExists($unrelated); + $this->assertDirectoryExists($this->filesystem_root . '/var/www/html'); + } + + /** + * The climb still collapses a wholly deleted selected directory into one + * deletion, since the selected root itself is the floor rather than the wall. + */ + public function testVanishedSelectedDirectoryRootIsDeletedAsOnePath(): void + { + $this->writeIndex( + 'remote-index.jsonl', + $this->indexLine('/var/www/html/wp-content/themes/x/style.css', 1000, 10) + ); + $this->writeIndex('remote-index.next.jsonl', ''); + $inside = $this->seedLocalFile('/var/www/html/wp-content/themes/x/style.css'); + $outside = $this->seedLocalFile('/var/www/html/wp-config.php'); + + [$client, $reflection] = $this->prepareClient(['/var/www/html/wp-content']); + $reflection->getMethod('compare_remote_indexes_and_build_fetch_list')->invoke($client); + $reflection->getProperty('pull_index_journal') + ->getValue($client) + ->apply_pending_records(); + + $this->assertFileDoesNotExist($inside); + $this->assertDirectoryDoesNotExist($this->filesystem_root . '/var/www/html/wp-content'); + $this->assertFileExists($outside); + } + + /** + * The index lists a directory root's contents, never the root entry, so a + * selected directory has to be recognised through what sits under it. + */ + public function testSelectedDirectoryRootCountsAsPreviouslyIndexed(): void + { + $this->writeIndex( + 'remote-index.jsonl', + $this->indexLine('/var/www/html/wp-content/themes/x/style.css', 1000, 10) + ); + + [$client, $reflection] = $this->prepareClient([ + '/var/www/html/wp-content', + '/var/www/html/never-indexed', + ]); + + $this->assertSame( + ['/var/www/html/wp-content'], + $reflection->getMethod('previously_indexed_selected_roots')->invoke($client) + ); + } } diff --git a/tests/Import/PullIndexWalTest.php b/tests/Import/PullIndexWalTest.php index 3ca7237a6..22339afe9 100644 --- a/tests/Import/PullIndexWalTest.php +++ b/tests/Import/PullIndexWalTest.php @@ -171,7 +171,8 @@ public function testDeletedFileDerivesTheAbsentDirectoryRoot(): void $this->client(), '/srv/site/gone/nested/file.txt', '/srv/site/kept.txt', - null + null, + ['/srv/site'] ); $this->assertSame('/srv/site/gone', $remoteAbsolutePathToDelete); @@ -186,7 +187,8 @@ public function testDeletedFileKeepsAParentWithAnotherNextIndexEntry(): void $this->client(), '/srv/site/kept/old.txt', '/srv/site/kept/current.txt', - null + null, + ['/srv/site'] ); $this->assertSame('/srv/site/kept/old.txt', $remoteAbsolutePathToDelete); From cd87a71a7e5f21d583b07025c5d2f79d7781219a Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 20 Aug 2026 11:22:16 +0200 Subject: [PATCH 29/30] Fix E2E collisions with new trunk sites Trunk added outbound-application-firewall and three no-pdo-mysql sites, taking ports 8134 to 8137 and the number 62. This branch had claimed 8134 and 8135, so the merge bound two sites to each port: requests reached the wrong site and failed with HTTP 404 for a missing plugin or HTTP 403 for a mismatched secret. Move the two sites to 8138 and 8139, and renumber their tests to 63 and 64. Co-Authored-By: Claude Opus 5 (1M context) --- tests/e2e/site-registry.json | 4 ++-- ...nly-file-path.test.js => import-63-only-file-path.test.js} | 2 +- ...est.js => import-64-only-symlinked-directory-root.test.js} | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename tests/e2e/tests/{import-62-only-file-path.test.js => import-63-only-file-path.test.js} (97%) rename tests/e2e/tests/{import-63-only-symlinked-directory-root.test.js => import-64-only-symlinked-directory-root.test.js} (97%) diff --git a/tests/e2e/site-registry.json b/tests/e2e/site-registry.json index ea3732f37..37d9ec6f6 100644 --- a/tests/e2e/site-registry.json +++ b/tests/e2e/site-registry.json @@ -141,10 +141,10 @@ "port": 8125 }, "only-file-path": { - "port": 8134 + "port": 8138 }, "only-symlinked-directory-root": { - "port": 8135 + "port": 8139 }, "mysql-session-settings-resume": { "port": 8126 diff --git a/tests/e2e/tests/import-62-only-file-path.test.js b/tests/e2e/tests/import-63-only-file-path.test.js similarity index 97% rename from tests/e2e/tests/import-62-only-file-path.test.js rename to tests/e2e/tests/import-63-only-file-path.test.js index 69e3b8011..c5dee6439 100644 --- a/tests/e2e/tests/import-62-only-file-path.test.js +++ b/tests/e2e/tests/import-63-only-file-path.test.js @@ -1,4 +1,4 @@ -/** Test 62: `--only` accepts a single file, not just a directory (issue #539). */ +/** Test 63: `--only` accepts a single file, not just a directory (issue #539). */ import { describe, it, beforeAll, afterAll } from 'vitest'; import assert from 'node:assert/strict'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; diff --git a/tests/e2e/tests/import-63-only-symlinked-directory-root.test.js b/tests/e2e/tests/import-64-only-symlinked-directory-root.test.js similarity index 97% rename from tests/e2e/tests/import-63-only-symlinked-directory-root.test.js rename to tests/e2e/tests/import-64-only-symlinked-directory-root.test.js index a0ba514d0..1341c3bb7 100644 --- a/tests/e2e/tests/import-63-only-symlinked-directory-root.test.js +++ b/tests/e2e/tests/import-64-only-symlinked-directory-root.test.js @@ -1,4 +1,4 @@ -/** Test 63: `--only` preserves a selected symlinked directory. */ +/** Test 64: `--only` preserves a selected symlinked directory. */ import { describe, it, beforeAll, afterAll } from 'vitest'; import assert from 'node:assert/strict'; import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; From c72242a1a36aeceeab4f30c9fd2e4ad2d2739b11 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 20 Aug 2026 12:04:01 +0200 Subject: [PATCH 30/30] [Pull] Name the pulled-before paths for what they assert The file_index request parameter was called missing_roots, which read as a statement that those paths are gone. It is the opposite: a list the client vouches for, so the server may find them absent without treating that as a bad path. Rename it to pulled_before, along with the client method, its memo property, and the local variable at both ends. The parameter is new on this branch and unreleased, so no shipped client or server speaks the old name. Explain the tie-break where it happens. The server rejects a path in the directory parameter when it does not exist, which is right for a typo. A selected path can also be absent because the source deleted it, and the two look identical from the server. For a path in pulled_before the server neither errors nor emits an index entry: the response says nothing about it, and the diff reads that silence as a deletion. Correct one claim while moving it: a directory with contents has no index entry of its own, but an empty or unreadable one is listed in its own right. Matching a selection therefore has to accept a descendant or the path itself. Co-Authored-By: Claude Opus 5 (1M context) --- packages/reprint-client/src/import.php | 51 ++++++++++++-------- packages/reprint-server/src/export.php | 10 ++-- tests/Import/OnlyFilesPathPrefixDiffTest.php | 8 +-- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/reprint-client/src/import.php b/packages/reprint-client/src/import.php index 6dc24aae0..612d17e83 100755 --- a/packages/reprint-client/src/import.php +++ b/packages/reprint-client/src/import.php @@ -409,8 +409,8 @@ class ImportClient */ private $pull_excluded_files_with_path_prefixes = []; - /** @var string[]|null Selected roots found in the current remote index. */ - private $previously_indexed_selected_root_paths = null; + /** @var string[]|null Memoized get_selected_paths_pulled_before() result. */ + private $selected_paths_pulled_before = null; /** @var AdaptiveTuner|null Adjusts request pacing based on server response times and errors. */ private $tuner = null; @@ -7664,9 +7664,9 @@ private function fetch_next_remote_index(?string $list_dir_override = null): boo if (!empty($export_dirs)) { $params["directory"] = $export_dirs; } - $missing_roots = $this->previously_indexed_selected_roots(); - if ($missing_roots !== []) { - $params["missing_roots"] = $missing_roots; + $paths_pulled_before = $this->get_selected_paths_pulled_before(); + if ($paths_pulled_before !== []) { + $params["pulled_before"] = $paths_pulled_before; } $url = $this->build_url("file_index", $cursor, $params); $context = new StreamingContext(); @@ -9858,30 +9858,41 @@ private function resolve_remote_paths( } /** - * Returns selected roots that the prior remote index confirms as tracked. + * Returns the selected paths an earlier pull of this site already saw. * - * The server may represent only these roots as an empty selected result - * when it can confirm their current absence. A newly typed missing path is - * still rejected at the endpoint. + * The server rejects a path in the `file_index` request's `directory` + * parameter when that path does not exist, which is what should happen for a + * typo like `--only /var/www/htmll`. But a selected path can also be absent + * because the source deleted it, and the pull should then remove it locally + * rather than fail. * - * A root counts as tracked when the prior index holds it or anything under - * it, because a directory root appears there only through its contents. + * Those two cases look identical to the server, so the client adds this list + * to the same request as its `pulled_before` parameter. For a path named there + * the server neither raises an error nor emits any index entry: the response + * simply says nothing about it, and the diff reads that silence as a deletion. + * A path absent from the list still raises an error. * - * @return string[] Selected roots the prior remote index covers. + * The saved remote index holds the paths earlier pulls already accounted for. + * Matching a selection against it has to accept a descendant, not just the + * path itself: a directory with contents has no entry of its own there, since + * its descendants already imply it. Only an empty or unreadable directory is + * listed in its own right. + * + * @return string[] Selected paths present in the saved remote index. */ - private function previously_indexed_selected_roots(): array + private function get_selected_paths_pulled_before(): array { - if ($this->previously_indexed_selected_root_paths !== null) { - return $this->previously_indexed_selected_root_paths; + if ($this->selected_paths_pulled_before !== null) { + return $this->selected_paths_pulled_before; } if ($this->pull_only_files_with_path_prefixes === [] || !is_file($this->remote_index_file)) { - $this->previously_indexed_selected_root_paths = []; - return $this->previously_indexed_selected_root_paths; + $this->selected_paths_pulled_before = []; + return $this->selected_paths_pulled_before; } $remaining = array_fill_keys($this->pull_only_files_with_path_prefixes, true); $handle = fopen($this->remote_index_file, 'r'); if (!is_resource($handle)) { - throw new RuntimeException("Failed to open the current remote index for selected roots."); + throw new RuntimeException("Failed to open the saved remote index for selected paths."); } try { while ($remaining !== [] && ($line = fgets($handle)) !== false) { @@ -9905,11 +9916,11 @@ private function previously_indexed_selected_roots(): array } finally { fclose($handle); } - $this->previously_indexed_selected_root_paths = array_values(array_diff( + $this->selected_paths_pulled_before = array_values(array_diff( $this->pull_only_files_with_path_prefixes, array_keys($remaining) )); - return $this->previously_indexed_selected_root_paths; + return $this->selected_paths_pulled_before; } /** diff --git a/packages/reprint-server/src/export.php b/packages/reprint-server/src/export.php index 7396847b1..2be0cebd5 100644 --- a/packages/reprint-server/src/export.php +++ b/packages/reprint-server/src/export.php @@ -1315,10 +1315,14 @@ function resolve_file_index_roots(array $config): array clearstatcache(true, $requested_path); $stat = @lstat($requested_path); if ($stat === false) { - $missing_roots = isset($config["missing_roots"]) && is_array($config["missing_roots"]) - ? $config["missing_roots"] + // The client sends `pulled_before` for selected paths an earlier pull + // already saw. Absence there means the source deleted the path, so it + // becomes a missing root instead of an error. Anything else absent is + // a bad path and still throws below. + $paths_pulled_before = isset($config["pulled_before"]) && is_array($config["pulled_before"]) + ? $config["pulled_before"] : []; - if (in_array($requested_path, $missing_roots, true)) { + if (in_array($requested_path, $paths_pulled_before, true)) { $roots[] = [ "requested_path" => $requested_path, "resolved_path" => null, diff --git a/tests/Import/OnlyFilesPathPrefixDiffTest.php b/tests/Import/OnlyFilesPathPrefixDiffTest.php index 62041ca2f..850d32a89 100644 --- a/tests/Import/OnlyFilesPathPrefixDiffTest.php +++ b/tests/Import/OnlyFilesPathPrefixDiffTest.php @@ -407,7 +407,7 @@ public function testExcludedSelectedLinkRootIsNotDeleted(): void $this->assertContains('/mnt/uploads', $this->readRemoteIndexEntryPaths()); } - public function testOnlyPreviouslyIndexedRootsMayBeReportedMissing(): void + public function testOnlyPathsPulledBeforeMayBeReportedMissing(): void { $this->writeIndex( 'remote-index.jsonl', @@ -421,7 +421,7 @@ public function testOnlyPreviouslyIndexedRootsMayBeReportedMissing(): void $this->assertSame( ['/wp-config.php'], - $reflection->getMethod('previously_indexed_selected_roots')->invoke($client) + $reflection->getMethod('get_selected_paths_pulled_before')->invoke($client) ); } @@ -480,7 +480,7 @@ public function testVanishedSelectedDirectoryRootIsDeletedAsOnePath(): void * The index lists a directory root's contents, never the root entry, so a * selected directory has to be recognised through what sits under it. */ - public function testSelectedDirectoryRootCountsAsPreviouslyIndexed(): void + public function testSelectedDirectoryCountsWhenTheIndexHoldsItsContents(): void { $this->writeIndex( 'remote-index.jsonl', @@ -494,7 +494,7 @@ public function testSelectedDirectoryRootCountsAsPreviouslyIndexed(): void $this->assertSame( ['/var/www/html/wp-content'], - $reflection->getMethod('previously_indexed_selected_roots')->invoke($client) + $reflection->getMethod('get_selected_paths_pulled_before')->invoke($client) ); } }