diff --git a/tests/Import/FilesSyncStateTest.php b/tests/Import/FilesSyncStateTest.php index 7052c7e5..5af3071e 100644 --- a/tests/Import/FilesSyncStateTest.php +++ b/tests/Import/FilesSyncStateTest.php @@ -368,4 +368,92 @@ public function testFetchStageOverwritesPreviouslySyncedFile() "Fetch stage must overwrite existing files that were placed in the download list", ); } + + // --------------------------------------------------------------- + // Symlink in site-owned dir (WP Cloud Atomic theme layout) + // --------------------------------------------------------------- + + /** + * A symlink entry at a site-owned path (no symlinks in the parent + * chain) should be added to the download list in preserve-local + * mode, even when its target points through a shared directory. + * + * WP Cloud Atomic layout: wp-content/themes/indice is the per-site + * symlink (site-owned), pointing to ../../wordpress/themes/pub/indice + * (shared infrastructure). The parent wp-content/themes/ has no + * symlinks in its path, so preserve-local allows it. + */ + public function testDeltaDiffIncludesSymlinkInSiteOwnedDir() + { + // Site-owned directory — no symlinks in path + mkdir($this->fs_root . '/wp-content/themes', 0755, true); + + // Shared infrastructure (not traversed for the symlink's parent) + mkdir($this->fs_root . '/wp-shared/themes/pub/indice', 0755, true); + symlink('wp-shared', $this->fs_root . '/wordpress'); + + $localIndex = $this->stateDir . '/.import-index.jsonl'; + file_put_contents($localIndex, ''); + + $remoteIndex = $this->stateDir . '/.import-remote-index.jsonl'; + file_put_contents($remoteIndex, $this->indexLine('/wp-content/themes/indice', 1000, 0, 'link')); + + $this->writeState([ + "command" => "files-pull", + "status" => "in_progress", + "stage" => "diff", + ]); + + [$client, $reflection] = $this->prepareClient(); + $reflection->getMethod('diff_indexes_and_build_fetch_list')->invoke($client); + + $downloads = $this->readDownloadList(); + $this->assertContains( + '/wp-content/themes/indice', + $downloads, + "Symlink in site-owned directory must be added to the download list", + ); + } + + /** + * Entries whose parent path traverses a symlink should be blocked + * regardless of entry type — the parent directory is shared + * infrastructure. + */ + public function testDeltaDiffSkipsEntriesInsideSharedDir() + { + mkdir($this->fs_root . '/wp-shared/themes/pub', 0755, true); + symlink('wp-shared', $this->fs_root . '/wordpress'); + + $localIndex = $this->stateDir . '/.import-index.jsonl'; + file_put_contents($localIndex, ''); + + $remoteIndex = $this->stateDir . '/.import-remote-index.jsonl'; + file_put_contents( + $remoteIndex, + $this->indexLine('/wordpress/themes/pub/indice', 1000, 0, 'link') . + $this->indexLine('/wordpress/themes/pub/indice/style.css', 1000, 500, 'file'), + ); + + $this->writeState([ + "command" => "files-pull", + "status" => "in_progress", + "stage" => "diff", + ]); + + [$client, $reflection] = $this->prepareClient(); + $reflection->getMethod('diff_indexes_and_build_fetch_list')->invoke($client); + + $downloads = $this->readDownloadList(); + $this->assertNotContains( + '/wordpress/themes/pub/indice', + $downloads, + "Symlink inside shared directory must be skipped", + ); + $this->assertNotContains( + '/wordpress/themes/pub/indice/style.css', + $downloads, + "File inside shared directory must be skipped", + ); + } } diff --git a/tests/Import/ImportSymlinkTest.php b/tests/Import/ImportSymlinkTest.php index e7153b4f..b74d9dfa 100644 --- a/tests/Import/ImportSymlinkTest.php +++ b/tests/Import/ImportSymlinkTest.php @@ -289,4 +289,92 @@ public function testSymlinkReplacesExistingFile() $this->assertTrue(is_link($filePath), 'File should be replaced with symlink'); $this->assertEquals('target', readlink($filePath)); } + + /** + * In preserve-local mode, a symlink whose parent directory is + * site-owned (no symlinks in the path) should be created even when + * its target points through a symlinked shared directory. + * + * This is the WP Cloud Atomic theme layout: the per-site symlink + * lives at wp-content/themes/indice (site-owned) and points to + * ../../wordpress/themes/pub/indice (shared infrastructure reached + * through the /wordpress/ symlink). The target path is validated + * by normalize_path (string-only, no symlink resolution), so it + * stays within the fs-root and passes. + */ + public function testPreserveLocalAllowsSymlinkPointingThroughSharedDir() + { + $fsRoot = $this->tempDir . '/fs-root'; + + // Site-owned themes directory (no symlinks in path) + mkdir($fsRoot . '/wp-content/themes', 0755, true); + + // Shared infrastructure: wordpress/ is a symlink + mkdir($fsRoot . '/wp-shared/themes/pub/indice', 0755, true); + symlink('wp-shared', $fsRoot . '/wordpress'); + + $client = new \ImportClient('http://fake.url', $this->tempDir, $fsRoot); + + $reflection = new \ReflectionClass($client); + $behaviorProp = $reflection->getProperty('fs_root_nonempty_behavior'); + $behaviorProp->setValue($client, 'preserve-local'); + + $method = $reflection->getMethod('handle_symlink_chunk'); + + // Target points through the shared /wordpress/ symlink + $chunk = [ + 'headers' => [ + 'x-symlink-path' => base64_encode('/wp-content/themes/indice'), + 'x-symlink-target' => base64_encode('../../wordpress/themes/pub/indice'), + 'x-symlink-ctime' => '1234567890' + ] + ]; + + $method->invoke($client, $chunk); + + $symlinkPath = $fsRoot . '/wp-content/themes/indice'; + $this->assertTrue(is_link($symlinkPath), 'Symlink in site-owned dir should be created'); + $this->assertEquals( + '../../wordpress/themes/pub/indice', + readlink($symlinkPath), + ); + } + + /** + * In preserve-local mode, a symlink whose parent IS a shared + * directory (reached through a symlink) should be blocked — both + * files and symlinks modify the shared directory equally. + */ + public function testPreserveLocalBlocksSymlinkInsideSharedDir() + { + $fsRoot = $this->tempDir . '/fs-root'; + + mkdir($fsRoot . '/wp-shared/themes/pub', 0755, true); + symlink('wp-shared', $fsRoot . '/wordpress'); + + $client = new \ImportClient('http://fake.url', $this->tempDir, $fsRoot); + + $reflection = new \ReflectionClass($client); + $behaviorProp = $reflection->getProperty('fs_root_nonempty_behavior'); + $behaviorProp->setValue($client, 'preserve-local'); + + $method = $reflection->getMethod('handle_symlink_chunk'); + + // Parent path traverses /wordpress/ symlink → shared territory + $chunk = [ + 'headers' => [ + 'x-symlink-path' => base64_encode('/wordpress/themes/pub/indice'), + 'x-symlink-target' => base64_encode('indice-1.0'), + 'x-symlink-ctime' => '1234567890' + ] + ]; + + $method->invoke($client, $chunk); + + $symlinkPath = $fsRoot . '/wordpress/themes/pub/indice'; + $this->assertFalse( + is_link($symlinkPath), + 'Symlink inside shared directory (through symlink) must be blocked', + ); + } }