Skip to content
Closed
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
17 changes: 15 additions & 2 deletions packages/reprint-server/src/class-file-index-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
105 changes: 105 additions & 0 deletions tests/FileIndexProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<?php // akismet');
symlink('../../../shared/akismet-5.7', $link);
$canonical_link = (string) realpath(dirname($link)) . '/akismet';

$processor = FileIndexProcessor::start(
[ (string) realpath($target) ],
$link,
true,
true,
''
);
$entries = [];
while ($processor->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', '<?php // akismet');

$processor = FileIndexProcessor::start(
[ (string) realpath($directory) ],
$directory,
true,
true,
''
);
$entries = [];
while ($processor->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', '<?php // akismet');
symlink('../../../shared/akismet-5.7', $link);
$canonical_link = (string) realpath(dirname($link)) . '/akismet';

$processor = FileIndexProcessor::start(
[ (string) realpath($site) ],
$site,
true,
true,
''
);
$entries = [];
while ($processor->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';
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/site-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@
},
"files-pull-mirror": {
"port": 8130
},
"only-symlinked-directory-root": {
"port": 8131
}
}
}
65 changes: 65 additions & 0 deletions tests/e2e/tests/import-60-only-symlinked-directory-root.test.js
Original file line number Diff line number Diff line change
@@ -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 <symlinked directory>', { 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'), '<?php // reprint scoped plugin\n');
symlinkSync(linkTarget, link);
},
});
siteDir = getSiteDir(site);
tempDir = createTempDir('e2e-only-symlinked-directory-root');
});

afterAll(() => {
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'), '<?php // reprint scoped plugin\n');

Check failure on line 55 in tests/e2e/tests/import-60-only-symlinked-directory-root.test.js

View workflow job for this annotation

GitHub Actions / E2E (exporter PHP 8.2, importer PHP 8.2)

tests/import-60-only-symlinked-directory-root.test.js > Import: files-pull --only <symlinked directory> > recreates the selected symlink with its original target spelling

Error: EISDIR: illegal operation on a directory, read ❯ tests/import-60-only-symlinked-directory-root.test.js:55:34 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -21, code: 'EISDIR', syscall: 'read' }

Check failure on line 55 in tests/e2e/tests/import-60-only-symlinked-directory-root.test.js

View workflow job for this annotation

GitHub Actions / E2E (exporter PHP 7.4, importer PHP 7.4)

tests/import-60-only-symlinked-directory-root.test.js > Import: files-pull --only <symlinked directory> > recreates the selected symlink with its original target spelling

Error: EISDIR: illegal operation on a directory, read ❯ tests/import-60-only-symlinked-directory-root.test.js:55:34 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -21, code: 'EISDIR', syscall: 'read' }

Check failure on line 55 in tests/e2e/tests/import-60-only-symlinked-directory-root.test.js

View workflow job for this annotation

GitHub Actions / E2E (exporter PHP 8.0, importer PHP 8.0)

tests/import-60-only-symlinked-directory-root.test.js > Import: files-pull --only <symlinked directory> > recreates the selected symlink with its original target spelling

Error: EISDIR: illegal operation on a directory, read ❯ tests/import-60-only-symlinked-directory-root.test.js:55:34 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -21, code: 'EISDIR', syscall: 'read' }

Check failure on line 55 in tests/e2e/tests/import-60-only-symlinked-directory-root.test.js

View workflow job for this annotation

GitHub Actions / E2E (exporter PHP 8.4, importer PHP 8.4)

tests/import-60-only-symlinked-directory-root.test.js > Import: files-pull --only <symlinked directory> > recreates the selected symlink with its original target spelling

Error: EISDIR: illegal operation on a directory, read ❯ tests/import-60-only-symlinked-directory-root.test.js:55:34 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -21, code: 'EISDIR', syscall: 'read' }

Check failure on line 55 in tests/e2e/tests/import-60-only-symlinked-directory-root.test.js

View workflow job for this annotation

GitHub Actions / E2E (exporter PHP 8.3, importer PHP 8.3)

tests/import-60-only-symlinked-directory-root.test.js > Import: files-pull --only <symlinked directory> > recreates the selected symlink with its original target spelling

Error: EISDIR: illegal operation on a directory, read ❯ tests/import-60-only-symlinked-directory-root.test.js:55:34 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -21, code: 'EISDIR', syscall: 'read' }

Check failure on line 55 in tests/e2e/tests/import-60-only-symlinked-directory-root.test.js

View workflow job for this annotation

GitHub Actions / E2E (exporter PHP 8.1, importer PHP 8.1)

tests/import-60-only-symlinked-directory-root.test.js > Import: files-pull --only <symlinked directory> > recreates the selected symlink with its original target spelling

Error: EISDIR: illegal operation on a directory, read ❯ tests/import-60-only-symlinked-directory-root.test.js:55:34 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -21, code: 'EISDIR', syscall: 'read' }

Check failure on line 55 in tests/e2e/tests/import-60-only-symlinked-directory-root.test.js

View workflow job for this annotation

GitHub Actions / E2E (exporter PHP 8.5, importer PHP 8.5)

tests/import-60-only-symlinked-directory-root.test.js > Import: files-pull --only <symlinked directory> > recreates the selected symlink with its original target spelling

Error: EISDIR: illegal operation on a directory, read ❯ tests/import-60-only-symlinked-directory-root.test.js:55:34 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -21, code: 'EISDIR', syscall: 'read' }

Check failure on line 55 in tests/e2e/tests/import-60-only-symlinked-directory-root.test.js

View workflow job for this annotation

GitHub Actions / E2E (Playground CLI)

tests/import-60-only-symlinked-directory-root.test.js > Import: files-pull --only <symlinked directory> > recreates the selected symlink with its original target spelling

Error: EISDIR: illegal operation on a directory, read ❯ tests/import-60-only-symlinked-directory-root.test.js:55:34 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -21, code: 'EISDIR', syscall: 'read' }
assert.equal(readlinkSync(link), linkTarget);
});

it('pulls the symlink target without unrelated site files', () => {
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')));
});
});
1 change: 1 addition & 0 deletions tests/phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<file>ExportHttpServerTest.php</file>
<file>FileIndexDedupTest.php</file>
<file>FileIndexSkipDefaultsTest.php</file>
<file>FileIndexProcessorTest.php</file>
<file>HmacServerTest.php</file>
<file>MultipartProcessorTest.php</file>
<file>PushEndpointsTest.php</file>
Expand Down
Loading