diff --git a/packages/reprint-server/src/class-file-index-processor.php b/packages/reprint-server/src/class-file-index-processor.php index 48b51a006..1e43ff88f 100644 --- a/packages/reprint-server/src/class-file-index-processor.php +++ b/packages/reprint-server/src/class-file-index-processor.php @@ -99,10 +99,24 @@ public static function start( bool $include_caches, string $storage_path ): self { + // A selected directory may itself be a symlink. Preserve it before + // realpath() collapses its spelling into the directory it targets. + $initial_index_entries = []; + clearstatcache(true, $index_directory); + $index_directory_stat = @lstat($index_directory); + if ( + $follow_symlinks + && is_array($index_directory_stat) + && ( $index_directory_stat["mode"] & self::STAT_TYPE_MASK ) === self::STAT_TYPE_LINK + ) { + // find_parent_symlinks() also inspects its final component, so this + // emits the selected link with the exact readlink() target spelling. + $initial_index_entries = self::find_parent_symlinks($index_directory); + } + // 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)) { throw new InvalidArgumentException( @@ -155,7 +169,6 @@ public static function start( // Keep parent-link discovery as the first traversal event. This // preserves the endpoint's established ordering: any link entries // found here must precede ordinary directory entries. - $initial_index_entries = []; if ($follow_symlinks) { foreach ($ordered_directories as $directory) { $initial_index_entries = array_merge( diff --git a/tests/FileIndexProcessorTest.php b/tests/FileIndexProcessorTest.php index d8f69043d..2da81e69f 100644 --- a/tests/FileIndexProcessorTest.php +++ b/tests/FileIndexProcessorTest.php @@ -191,6 +191,111 @@ public function testFollowedRelativeSymlinkIndexesAnIntermediateLink(): void ); } + public function testFollowedDirectorySymlinkRootPreservesTheSelectedLink(): void + { + $site = $this->tempDir . '/site'; + $target = $this->tempDir . '/shared/akismet-5.7'; + $link = $site . '/wp-content/plugins/akismet'; + mkdir($target, 0755, true); + mkdir(dirname($link), 0755, true); + file_put_contents($target . '/akismet.php', 'next_index_step()) { + foreach ($processor->get_index_entries() as $entry) { + $entries[] = $entry; + } + } + $processor->close(); + + $root_entries = array_values(array_filter( + $entries, + static fn(array $entry): bool => $entry['path'] === $canonical_link + )); + $this->assertSame( + [[ + 'path' => $canonical_link, + 'ctime' => (int) lstat($canonical_link)['ctime'], + 'size' => 0, + 'type' => 'link', + 'target' => '../../../shared/akismet-5.7', + 'intermediate' => true, + ]], + $root_entries + ); + $this->assertContains( (string) realpath($target) . '/akismet.php', array_column($entries, 'path') ); + } + + public function testRealDirectoryRootDoesNotAddAnIndexEntry(): void + { + $directory = $this->tempDir . '/site/wp-content/plugins/akismet'; + mkdir($directory, 0755, true); + file_put_contents($directory . '/akismet.php', 'next_index_step()) { + foreach ($processor->get_index_entries() as $entry) { + $entries[] = $entry; + } + } + $processor->close(); + + $this->assertSame( [ (string) realpath($directory) . '/akismet.php' ], array_column($entries, 'path') ); + } + + public function testUnscopedDirectoryIndexKeepsItsExistingSymlinkEntry(): void + { + $site = $this->tempDir . '/site'; + $target = $this->tempDir . '/shared/akismet-5.7'; + $link = $site . '/wp-content/plugins/akismet'; + mkdir($target, 0755, true); + mkdir(dirname($link), 0755, true); + file_put_contents($target . '/akismet.php', 'next_index_step()) { + foreach ($processor->get_index_entries() as $entry) { + $entries[] = $entry; + } + } + $processor->close(); + + $link_entries = array_values(array_filter( + $entries, + static fn(array $entry): bool => $entry['path'] === $canonical_link + )); + $this->assertSame( + (string) realpath($target), + $link_entries[0]['target'] + ); + $this->assertArrayNotHasKey('intermediate', $link_entries[0]); + } + public function testResumeWithACompletedCursorRemainsComplete(): void { $docroot = $this->tempDir . '/site'; diff --git a/tests/e2e/site-registry.json b/tests/e2e/site-registry.json index 612d523d7..759b85cf9 100644 --- a/tests/e2e/site-registry.json +++ b/tests/e2e/site-registry.json @@ -154,6 +154,9 @@ }, "files-pull-mirror": { "port": 8130 + }, + "only-symlinked-directory-root": { + "port": 8131 } } } diff --git a/tests/e2e/tests/import-60-only-symlinked-directory-root.test.js b/tests/e2e/tests/import-60-only-symlinked-directory-root.test.js new file mode 100644 index 000000000..e2ba27bcf --- /dev/null +++ b/tests/e2e/tests/import-60-only-symlinked-directory-root.test.js @@ -0,0 +1,65 @@ +/** Test 60: `--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/reprint-scoped-plugin-5.7'; + let tempDir; + let siteDir; + + beforeAll(async () => { + await ensureSite(site, { + afterCreate: async (remoteSiteDir) => { + const target = join(remoteSiteDir, 'shared', 'reprint-scoped-plugin-5.7'); + const link = join(remoteSiteDir, 'wp-content', 'plugins', 'reprint-scoped-plugin'); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, 'reprint-scoped-plugin.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', 'reprint-scoped-plugin')], + }); + 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', 'reprint-scoped-plugin'); + 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', 'reprint-scoped-plugin-5.7', 'reprint-scoped-plugin.php'))); + assert.ok(!existsSync(join(importedRoot, 'wp-admin'))); + assert.ok(!existsSync(join(importedRoot, 'wp-includes'))); + }); +}); diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 543a99923..c958f7649 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -26,6 +26,7 @@ ExportHttpServerTest.php FileIndexDedupTest.php FileIndexSkipDefaultsTest.php + FileIndexProcessorTest.php HmacServerTest.php MultipartProcessorTest.php PushEndpointsTest.php