Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions tests/Import/FilesSyncStateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
}
}
88 changes: 88 additions & 0 deletions tests/Import/ImportSymlinkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}
}
Loading