diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0413555..604f5ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,28 @@ jobs: with: name: coverage-${{ matrix.php-version }} path: coverage.xml + + test-windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + php-version: ["7.4", "8.1", "8.2"] + steps: + - uses: actions/checkout@v3 + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: ~\AppData\Local\Composer\Cache\files + key: ${{ runner.os }}-composer-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock', '**/composer.json') }} + restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php-version }}- + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest --ignore-platform-req=ext-dom --ignore-platform-req=ext-xml --ignore-platform-req=ext-xmlwriter + - name: Run PHPUnit + run: vendor\bin\phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa7dd3..01e684f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,3 +2,6 @@ ## Unreleased - Removed deprecated `SKIP_MISSED_TARGET` option. Use `SKIP_MISSING_TARGET` instead. +- Added Windows fallback strategies with configurable `windows-mode` (`symlink`, `junction`, `copy`). +- Improved error messages suggesting enabling Developer Mode or switching Windows strategies when symlinks fail. +- Documented Windows behaviours and added Windows CI coverage with end-to-end tests. diff --git a/README.md b/README.md index 686243b..0646dad 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The behaviour of the plugin can be tuned via the following configuration keys: | `throw-exception` | `true` | Throw an exception on errors instead of just printing the message. | | `force-create` | `false` | Remove any existing file or directory at the link path before creating the symlink. | | `cleanup` | `false` | Remove symlinks that were created previously but are no longer present in the configuration. | +| `windows-mode` | `junction` | Windows-only strategy: `symlink` (require Developer Mode/administrator), `junction` (fallback to junction/hardlink/copy), or `copy` (mirror the target without links). | You can set personal configs for any symlink. For personal configs `link` must be defined @@ -130,13 +131,25 @@ removed. | `The target path ... does not exists` | The target file or directory was not found. | | `Link ... already exists` | A file/directory already occupies the link path. | | `Cant unlink ...` | The plugin failed to remove a file when using `force-create`. | +| `Failed to create symlink ... Enable Windows Developer Mode ...` | Windows denied symlink creation. Enable Developer Mode or set `windows-mode` to `junction`/`copy`. | ### Windows compatibility On Windows, creating symlinks requires either Administrator privileges or that -the system is running in Developer Mode. The plugin itself works, but the -underlying operating system may refuse to create a link if permissions are -insufficient. Additionally, relative symlinks use Unix-style `/` separators +the system is running in Developer Mode. When native symlinks are not +available, the plugin falls back automatically (default `windows-mode` value) +to NTFS junctions for directories and hardlinks/copies for files. You can opt +into different behaviours by setting `extra.somework/composer-symlinks.windows-mode` +to one of: + +* `symlink` – always attempt a real symlink. Failures explicitly mention + enabling Developer Mode or switching to a fallback strategy. +* `junction` (default) – try a symlink first, then use junctions for directories + and hardlinks/copies for files when permissions prevent symlink creation. +* `copy` – skip symlinks altogether and mirror the target contents at the link + path. + +Regardless of the chosen mode, relative symlinks use Unix-style `/` separators internally which Windows resolves correctly. License diff --git a/src/Symlinks/Processor/AbstractLinkProcessor.php b/src/Symlinks/Processor/AbstractLinkProcessor.php new file mode 100644 index 0000000..6d22f6b --- /dev/null +++ b/src/Symlinks/Processor/AbstractLinkProcessor.php @@ -0,0 +1,69 @@ +filesystem = $filesystem; + } + + protected function createSymlink(Symlink $symlink): bool + { + if ($symlink->isAbsolutePath()) { + return symlink($symlink->getTarget(), $symlink->getLink()); + } + + return $this->filesystem->relativeSymlink($symlink->getTarget(), $symlink->getLink()); + } + + /** + * @param callable():bool $operation + * @return array{0: bool, 1: ?string} + */ + protected function callWithErrorCapture(callable $operation): array + { + $errorMessage = null; + set_error_handler(static function (int $severity, string $message) use (&$errorMessage): void { + $errorMessage = $message; + }); + + try { + $result = $operation(); + } finally { + restore_error_handler(); + } + + return [$result, $errorMessage]; + } + + protected function formatGenericSymlinkError(Symlink $symlink, ?string $errorMessage): string + { + return sprintf( + 'Failed to create symlink %s -> %s.%s', + $symlink->getLink(), + $symlink->getTarget(), + $this->formatDetails($errorMessage ? ['symlink: ' . $errorMessage] : []) + ); + } + + /** + * @param string[] $details + */ + protected function formatDetails(array $details): string + { + $filtered = array_filter($details); + if ($filtered === []) { + return ''; + } + + return ' Details: ' . implode('; ', $filtered) . '.'; + } +} diff --git a/src/Symlinks/Processor/LinkProcessorInterface.php b/src/Symlinks/Processor/LinkProcessorInterface.php new file mode 100644 index 0000000..cc84133 --- /dev/null +++ b/src/Symlinks/Processor/LinkProcessorInterface.php @@ -0,0 +1,11 @@ +callWithErrorCapture(function () use ($symlink): bool { + return $this->createSymlink($symlink); + }); + + if (!$result) { + throw new RuntimeException($this->formatGenericSymlinkError($symlink, $errorMessage)); + } + + return true; + } +} diff --git a/src/Symlinks/Processor/WindowsLinkProcessor.php b/src/Symlinks/Processor/WindowsLinkProcessor.php new file mode 100644 index 0000000..2ae5f6d --- /dev/null +++ b/src/Symlinks/Processor/WindowsLinkProcessor.php @@ -0,0 +1,169 @@ +getWindowsMode() === Symlink::WINDOWS_MODE_COPY) { + return $this->createCopy($symlink, Symlink::WINDOWS_MODE_COPY, null); + } + + [$result, $errorMessage] = $this->callWithErrorCapture(function () use ($symlink): bool { + return $this->createSymlink($symlink); + }); + + if ($result) { + return true; + } + + return $this->handleWindowsSymlinkFailure($symlink, $errorMessage); + } + + private function handleWindowsSymlinkFailure(Symlink $symlink, ?string $errorMessage): bool + { + $mode = $symlink->getWindowsMode(); + + if ($mode === Symlink::WINDOWS_MODE_SYMLINK) { + throw new RuntimeException($this->formatWindowsSymlinkError($symlink, $errorMessage)); + } + + if ($mode === Symlink::WINDOWS_MODE_COPY) { + return $this->createCopy($symlink, $mode, $errorMessage); + } + + if (is_dir($symlink->getTarget())) { + try { + $this->filesystem->junction($symlink->getTarget(), $symlink->getLink()); + + return true; + } catch (\Throwable $exception) { + throw new RuntimeException( + $this->formatWindowsJunctionError($symlink, $exception->getMessage(), $errorMessage), + (int) $exception->getCode(), + $exception + ); + } + } + + [$hardLinked, $hardLinkError] = $this->tryHardLink($symlink->getTarget(), $symlink->getLink()); + if ($hardLinked) { + return true; + } + + return $this->createCopy($symlink, $mode, $errorMessage, $hardLinkError); + } + + /** + * @return array{0: bool, 1: ?string} + */ + private function tryHardLink(string $target, string $link): array + { + if (!function_exists('link')) { + return [false, 'link() function is not available']; + } + + return $this->callWithErrorCapture(static function () use ($target, $link): bool { + return link($target, $link); + }); + } + + private function createCopy( + Symlink $symlink, + string $mode, + ?string $symlinkError, + ?string $hardLinkError = null + ): bool { + try { + if ($this->filesystem->copy($symlink->getTarget(), $symlink->getLink())) { + return true; + } + + $copyError = 'Filesystem::copy returned false'; + } catch (\Throwable $exception) { + throw new RuntimeException( + $this->formatWindowsCopyError( + $symlink, + $mode, + $symlinkError, + $hardLinkError, + $exception->getMessage() + ), + (int) $exception->getCode(), + $exception + ); + } + + throw new RuntimeException( + $this->formatWindowsCopyError($symlink, $mode, $symlinkError, $hardLinkError, $copyError) + ); + } + + private function formatWindowsSymlinkError(Symlink $symlink, ?string $errorMessage): string + { + return sprintf( + 'Failed to create symlink %s -> %s. Enable Windows Developer Mode or configure extra.somework/composer-symlinks.windows-mode to "junction" or "copy".%s', + $symlink->getLink(), + $symlink->getTarget(), + $this->formatDetails($errorMessage ? ['symlink: ' . $errorMessage] : []) + ); + } + + private function formatWindowsJunctionError(Symlink $symlink, string $junctionError, ?string $symlinkError): string + { + $details = [ + 'junction: ' . $junctionError, + ]; + + if ($symlinkError) { + $details[] = 'symlink: ' . $symlinkError; + } + + return sprintf( + 'Failed to create link %s -> %s using windows-mode "junction". Enable Windows Developer Mode or set windows-mode to "copy".%s', + $symlink->getLink(), + $symlink->getTarget(), + $this->formatDetails($details) + ); + } + + private function formatWindowsCopyError( + Symlink $symlink, + string $mode, + ?string $symlinkError, + ?string $hardLinkError, + ?string $copyError + ): string { + $details = []; + + if ($symlinkError) { + $details[] = 'symlink: ' . $symlinkError; + } + + if ($hardLinkError) { + $details[] = 'hardlink: ' . $hardLinkError; + } + + if ($copyError) { + $details[] = 'copy: ' . $copyError; + } + + $advice = $mode === Symlink::WINDOWS_MODE_COPY + ? 'Enable Windows Developer Mode to allow native symlinks.' + : 'Enable Windows Developer Mode or set windows-mode to "copy".'; + + return sprintf( + 'Failed to create link %s -> %s using windows-mode "%s". %s%s', + $symlink->getLink(), + $symlink->getTarget(), + $mode, + $advice, + $this->formatDetails($details) + ); + } +} diff --git a/src/Symlinks/Symlink.php b/src/Symlinks/Symlink.php index 5e0ebe1..d9ecef0 100644 --- a/src/Symlinks/Symlink.php +++ b/src/Symlinks/Symlink.php @@ -7,8 +7,13 @@ class Symlink { protected string $target = ''; protected string $link = ''; + public const WINDOWS_MODE_SYMLINK = 'symlink'; + public const WINDOWS_MODE_JUNCTION = 'junction'; + public const WINDOWS_MODE_COPY = 'copy'; + protected bool $absolutePath = false; protected bool $forceCreate = false; + protected string $windowsMode = self::WINDOWS_MODE_JUNCTION; public function getTarget(): string { @@ -53,4 +58,16 @@ public function setForceCreate(bool $forceCreate): Symlink $this->forceCreate = $forceCreate; return $this; } + + public function getWindowsMode(): string + { + return $this->windowsMode; + } + + public function setWindowsMode(string $windowsMode): self + { + $this->windowsMode = $windowsMode; + + return $this; + } } diff --git a/src/Symlinks/SymlinksFactory.php b/src/Symlinks/SymlinksFactory.php index 3d74ef7..e91a1b2 100644 --- a/src/Symlinks/SymlinksFactory.php +++ b/src/Symlinks/SymlinksFactory.php @@ -19,6 +19,7 @@ class SymlinksFactory const ABSOLUTE_PATH = 'absolute-path'; const THROW_EXCEPTION = 'throw-exception'; const FORCE_CREATE = 'force-create'; + const WINDOWS_MODE = 'windows-mode'; protected Filesystem $fileSystem; protected Event $event; @@ -144,10 +145,15 @@ protected function processSymlink(string $target, $linkData): ?Symlink throw new LinkDirectoryError($exception->getMessage(), $exception->getCode(), $exception); } - if ( - is_link($linkPath) && - realpath(dirname($linkPath) . DIRECTORY_SEPARATOR . readlink($linkPath)) === $targetPath - ) { + $resolvedLinkTarget = null; + if (is_link($linkPath)) { + $linkTarget = @readlink($linkPath); + if ($linkTarget !== false) { + $resolvedLinkTarget = realpath(dirname($linkPath) . DIRECTORY_SEPARATOR . $linkTarget); + } + } + + if (is_link($linkPath) && $resolvedLinkTarget === $targetPath) { $this->registerConfiguredSymlink($linkPath, $targetPath); $this->event->getIO()->write( sprintf( @@ -163,7 +169,8 @@ protected function processSymlink(string $target, $linkData): ?Symlink ->setTarget($targetPath) ->setLink($linkPath) ->setAbsolutePath($this->getConfig(static::ABSOLUTE_PATH, $linkData, false)) - ->setForceCreate($this->getConfig(static::FORCE_CREATE, $linkData, false)); + ->setForceCreate($this->getConfig(static::FORCE_CREATE, $linkData, false)) + ->setWindowsMode($this->getWindowsMode($linkData)); $this->registerConfiguredSymlink($linkPath, $targetPath); @@ -320,4 +327,53 @@ private function resolveVendorDir(): ?string return $vendorDir; } + + protected function getScalarConfig(string $name, $link = null, ?string $default = null): ?string + { + $value = null; + if (\is_array($link) && \array_key_exists($name, $link)) { + $value = $link[$name]; + } else { + $extras = $this->event->getComposer()->getPackage()->getExtra(); + if (!isset($extras[static::PACKAGE_NAME][$name])) { + return $default; + } + $value = $extras[static::PACKAGE_NAME][$name]; + } + + if ($value === null) { + return $default; + } + + if (!\is_scalar($value)) { + throw new InvalidArgumentException(sprintf( + 'The config option %s must be a string or scalar value.', + $name + )); + } + + return (string) $value; + } + + private function getWindowsMode($linkData): string + { + $mode = $this->getScalarConfig(static::WINDOWS_MODE, $linkData, Symlink::WINDOWS_MODE_JUNCTION); + $mode = strtolower($mode); + + $allowed = [ + Symlink::WINDOWS_MODE_SYMLINK, + Symlink::WINDOWS_MODE_JUNCTION, + Symlink::WINDOWS_MODE_COPY, + ]; + + if (!\in_array($mode, $allowed, true)) { + throw new InvalidArgumentException(sprintf( + 'Unknown windows-mode "%s". Allowed values are: %s.', + $mode, + implode(', ', $allowed) + )); + } + + return $mode; + } } diff --git a/src/Symlinks/SymlinksProcessor.php b/src/Symlinks/SymlinksProcessor.php index 1499281..1c76e25 100644 --- a/src/Symlinks/SymlinksProcessor.php +++ b/src/Symlinks/SymlinksProcessor.php @@ -4,16 +4,22 @@ namespace SomeWork\Symlinks; use Composer\Util\Filesystem; +use Composer\Util\Platform; +use SomeWork\Symlinks\Processor\LinkProcessorInterface; +use SomeWork\Symlinks\Processor\UnixLinkProcessor; +use SomeWork\Symlinks\Processor\WindowsLinkProcessor; class SymlinksProcessor { private Filesystem $filesystem; private bool $dryRun = false; + private LinkProcessorInterface $linkProcessor; - public function __construct(Filesystem $filesystem, bool $dryRun = false) + public function __construct(Filesystem $filesystem, bool $dryRun = false, ?LinkProcessorInterface $linkProcessor = null) { $this->filesystem = $filesystem; $this->dryRun = $dryRun; + $this->linkProcessor = $linkProcessor ?? $this->createDefaultLinkProcessor(); } public function setDryRun(bool $dryRun): void @@ -56,10 +62,7 @@ public function processSymlink(Symlink $symlink): bool throw new LinkDirectoryError('Link ' . $symlink->getLink() . ' already exists'); } - if ($symlink->isAbsolutePath()) { - return @symlink($symlink->getTarget(), $symlink->getLink()); - } - return $this->filesystem->relativeSymlink($symlink->getTarget(), $symlink->getLink()); + return $this->linkProcessor->create($symlink); } protected function isToUnlink(string $path): bool @@ -69,4 +72,13 @@ protected function isToUnlink(string $path): bool is_dir($path) || is_link($path); } + + private function createDefaultLinkProcessor(): LinkProcessorInterface + { + if (Platform::isWindows()) { + return new WindowsLinkProcessor($this->filesystem); + } + + return new UnixLinkProcessor($this->filesystem); + } } diff --git a/src/Symlinks/SymlinksRegistry.php b/src/Symlinks/SymlinksRegistry.php index 466eb91..90fbcbb 100644 --- a/src/Symlinks/SymlinksRegistry.php +++ b/src/Symlinks/SymlinksRegistry.php @@ -110,7 +110,7 @@ private function save(array $registry): void private function resolveTarget(string $link, string $fallback): ?string { if (is_link($link)) { - $target = readlink($link); + $target = @readlink($link); if ($target === false) { return $fallback; } diff --git a/tests/ComposerIntegrationTest.php b/tests/ComposerIntegrationTest.php index 41b8f95..5a21f3a 100644 --- a/tests/ComposerIntegrationTest.php +++ b/tests/ComposerIntegrationTest.php @@ -8,20 +8,20 @@ class ComposerIntegrationTest extends TestCase { public function testPackageCreatesSymlinksViaComposer(): void { - $tmp = sys_get_temp_dir() . '/project_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'project_' . uniqid(); mkdir($tmp); // prepare sources - mkdir($tmp . '/sourceA', 0777, true); - file_put_contents($tmp . '/sourceA/fileA.txt', 'A'); - mkdir($tmp . '/sourceB', 0777, true); - file_put_contents($tmp . '/sourceB/fileB.txt', 'B'); - mkdir($tmp . '/sourceC', 0777, true); - file_put_contents($tmp . '/sourceC/fileC.txt', 'C'); - mkdir($tmp . '/missing', 0777, true); + mkdir($tmp . DIRECTORY_SEPARATOR . 'sourceA', 0777, true); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'sourceA' . DIRECTORY_SEPARATOR . 'fileA.txt', 'A'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'sourceB', 0777, true); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'sourceB' . DIRECTORY_SEPARATOR . 'fileB.txt', 'B'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'sourceC', 0777, true); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'sourceC' . DIRECTORY_SEPARATOR . 'fileC.txt', 'C'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'missing', 0777, true); // existing file to be replaced - file_put_contents($tmp . '/replaceLink.txt', 'old'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'replaceLink.txt', 'old'); $pluginPath = realpath(__DIR__ . '/..'); @@ -60,7 +60,7 @@ public function testPackageCreatesSymlinksViaComposer(): void ] ] ]; - file_put_contents($tmp . '/composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); $cwd = getcwd(); chdir($tmp); @@ -69,36 +69,39 @@ public function testPackageCreatesSymlinksViaComposer(): void $this->assertSame(0, $code, implode("\n", $output)); - $this->assertTrue(is_link($tmp . '/linkA.txt')); - $this->assertSame( - realpath($tmp . '/sourceA/fileA.txt'), - realpath($tmp . '/' . readlink($tmp . '/linkA.txt')) + $linkA = $tmp . DIRECTORY_SEPARATOR . 'linkA.txt'; + $this->assertLinkOrMirror( + $linkA, + $tmp . DIRECTORY_SEPARATOR . 'sourceA' . DIRECTORY_SEPARATOR . 'fileA.txt', + $tmp, + true ); - $this->assertNotSame('/', substr(readlink($tmp . '/linkA.txt'), 0, 1)); - $this->assertTrue(is_link($tmp . '/linkB.txt')); - $this->assertSame( - realpath($tmp . '/sourceB/fileB.txt'), - readlink($tmp . '/linkB.txt') + $linkB = $tmp . DIRECTORY_SEPARATOR . 'linkB.txt'; + $this->assertLinkOrMirror( + $linkB, + $tmp . DIRECTORY_SEPARATOR . 'sourceB' . DIRECTORY_SEPARATOR . 'fileB.txt', + $tmp ); - $this->assertFalse(file_exists($tmp . '/missingLink.txt')); + $this->assertFalse(file_exists($tmp . DIRECTORY_SEPARATOR . 'missingLink.txt')); - $this->assertTrue(is_link($tmp . '/replaceLink.txt')); - $this->assertSame( - realpath($tmp . '/sourceC/fileC.txt'), - realpath($tmp . '/' . readlink($tmp . '/replaceLink.txt')) + $replaceLink = $tmp . DIRECTORY_SEPARATOR . 'replaceLink.txt'; + $this->assertLinkOrMirror( + $replaceLink, + $tmp . DIRECTORY_SEPARATOR . 'sourceC' . DIRECTORY_SEPARATOR . 'fileC.txt', + $tmp ); } public function testDryRunViaComposerDoesNotCreateLinks(): void { - $tmp = sys_get_temp_dir() . '/project_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'project_' . uniqid(); mkdir($tmp); // prepare sources - mkdir($tmp . '/sourceA', 0777, true); - file_put_contents($tmp . '/sourceA/fileA.txt', 'A'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'sourceA', 0777, true); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'sourceA' . DIRECTORY_SEPARATOR . 'fileA.txt', 'A'); $pluginPath = realpath(__DIR__ . '/..'); @@ -124,7 +127,7 @@ public function testDryRunViaComposerDoesNotCreateLinks(): void ] ] ]; - file_put_contents($tmp . '/composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); $cwd = getcwd(); chdir($tmp); @@ -135,18 +138,18 @@ public function testDryRunViaComposerDoesNotCreateLinks(): void $this->assertSame(0, $code, implode("\n", $output)); - $this->assertFalse(file_exists($tmp . '/linkA.txt')); + $this->assertFalse(file_exists($tmp . DIRECTORY_SEPARATOR . 'linkA.txt')); } public function testCleanupRemovesUnusedSymlinks(): void { - $tmp = sys_get_temp_dir() . '/project_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'project_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/sourceA', 0777, true); - file_put_contents($tmp . '/sourceA/fileA.txt', 'A'); - mkdir($tmp . '/sourceB', 0777, true); - file_put_contents($tmp . '/sourceB/fileB.txt', 'B'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'sourceA', 0777, true); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'sourceA' . DIRECTORY_SEPARATOR . 'fileA.txt', 'A'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'sourceB', 0777, true); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'sourceB' . DIRECTORY_SEPARATOR . 'fileB.txt', 'B'); $pluginPath = realpath(__DIR__ . '/..'); @@ -174,7 +177,7 @@ public function testCleanupRemovesUnusedSymlinks(): void ] ] ]; - file_put_contents($tmp . '/composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); $cwd = getcwd(); chdir($tmp); @@ -182,18 +185,19 @@ public function testCleanupRemovesUnusedSymlinks(): void chdir($cwd); $this->assertSame(0, $code, implode("\n", $output)); - $this->assertTrue(is_link($tmp . '/linkA.txt')); - $this->assertTrue(is_link($tmp . '/linkB.txt')); + $this->assertLinkExists($tmp . DIRECTORY_SEPARATOR . 'linkA.txt'); + $this->assertLinkExists($tmp . DIRECTORY_SEPARATOR . 'linkB.txt'); - $registryPath = $tmp . '/vendor/composer-symlinks-state.json'; + $registryPath = $tmp . DIRECTORY_SEPARATOR . 'vendor/composer-symlinks-state.json'; $this->assertFileExists($registryPath); $registry = json_decode((string)file_get_contents($registryPath), true); $this->assertIsArray($registry); - $this->assertArrayHasKey($tmp . '/linkA.txt', $registry); - $this->assertArrayHasKey($tmp . '/linkB.txt', $registry); + $registry = $this->canonicalizeRegistry($registry); + $this->assertArrayHasKey($this->canonicalizePath($tmp . '/linkA.txt'), $registry); + $this->assertArrayHasKey($this->canonicalizePath($tmp . '/linkB.txt'), $registry); unset($composerData['extra']['somework/composer-symlinks']['symlinks']['sourceB/fileB.txt']); - file_put_contents($tmp . '/composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); chdir($tmp); $output = []; @@ -201,12 +205,150 @@ public function testCleanupRemovesUnusedSymlinks(): void chdir($cwd); $this->assertSame(0, $code, implode("\n", $output)); - $this->assertTrue(is_link($tmp . '/linkA.txt')); - $this->assertFalse(file_exists($tmp . '/linkB.txt')); + $this->assertLinkExists($tmp . DIRECTORY_SEPARATOR . 'linkA.txt'); + $this->assertFalse(file_exists($tmp . DIRECTORY_SEPARATOR . 'linkB.txt')); $registry = json_decode((string)file_get_contents($registryPath), true); $this->assertIsArray($registry); - $this->assertArrayHasKey($tmp . '/linkA.txt', $registry); - $this->assertArrayNotHasKey($tmp . '/linkB.txt', $registry); + $registry = $this->canonicalizeRegistry($registry); + $this->assertArrayHasKey($this->canonicalizePath($tmp . '/linkA.txt'), $registry); + $this->assertArrayNotHasKey($this->canonicalizePath($tmp . '/linkB.txt'), $registry); + } + + private function resolveLinkTarget(string $link, string $baseDir, ?string $linkTarget = null): string + { + $target = $linkTarget ?? readlink($link); + $this->assertNotFalse($target); + + $normalized = $this->normalizePath($target); + + if ($this->isAbsolutePath($normalized)) { + $resolved = realpath($normalized); + $this->assertNotFalse($resolved); + + return $resolved; + } + + $resolved = realpath(dirname($link) . DIRECTORY_SEPARATOR . $normalized); + if ($resolved !== false) { + return $resolved; + } + + $resolved = realpath($baseDir . DIRECTORY_SEPARATOR . $normalized); + $this->assertNotFalse($resolved); + + return $resolved; + } + + private function assertLinkOrMirror(string $link, string $target, string $baseDir, bool $expectRelative = false): void + { + if (DIRECTORY_SEPARATOR === '\\') { + if (!is_link($link)) { + $this->assertFileMirrors($target, $link); + + return; + } + + $linkTarget = @readlink($link); + if ($linkTarget === false) { + $this->assertFileMirrors($target, $link); + + return; + } + } else { + $this->assertTrue(is_link($link)); + $linkTarget = readlink($link); + $this->assertNotFalse($linkTarget); + } + + $this->assertTrue(is_link($link)); + + if ($expectRelative) { + $this->assertNotSame('/', substr($linkTarget, 0, 1)); + } + + $this->assertSame( + realpath($target), + $this->resolveLinkTarget($link, $baseDir, $linkTarget) + ); + } + + private function assertLinkExists(string $path): void + { + if (DIRECTORY_SEPARATOR === '\\' && !is_link($path)) { + $this->assertFileExists($path); + + return; + } + + $this->assertTrue(is_link($path)); + } + + private function assertFileMirrors(string $target, string $path): void + { + $this->assertFileExists($path); + $targetContents = file_get_contents($target); + $pathContents = file_get_contents($path); + + $this->assertNotFalse($targetContents); + $this->assertNotFalse($pathContents); + $this->assertSame($targetContents, $pathContents); + } + + private function normalizePath(string $path): string + { + if (DIRECTORY_SEPARATOR === '\\') { + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + return $path; + } + + /** + * @param array $registry + * + * @return array + */ + private function canonicalizeRegistry(array $registry): array + { + if (DIRECTORY_SEPARATOR !== '\\') { + return $registry; + } + + $canonical = []; + foreach ($registry as $link => $target) { + $canonical[$this->canonicalizePath($link)] = $target; + } + + return $canonical; + } + + private function canonicalizePath(string $path): string + { + if (DIRECTORY_SEPARATOR !== '\\') { + return $path; + } + + $normalized = $this->normalizePath($path); + $resolved = realpath($normalized); + + if ($resolved !== false) { + return strtolower($resolved); + } + + return strtolower($normalized); + } + + private function isAbsolutePath(string $path): bool + { + if ($path === '') { + return false; + } + + if (DIRECTORY_SEPARATOR === '\\') { + return (bool) preg_match('{^(?:[A-Za-z]:\\\\|\\\\\\\\)}', $path); + } + + return $path[0] === DIRECTORY_SEPARATOR; } } diff --git a/tests/RefreshCommandTest.php b/tests/RefreshCommandTest.php index e3f7cb5..7328a46 100644 --- a/tests/RefreshCommandTest.php +++ b/tests/RefreshCommandTest.php @@ -15,10 +15,10 @@ class RefreshCommandTest extends TestCase { public function testCommandCreatesSymlinks(): void { - $tmp = sys_get_temp_dir() . '/command_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'command_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/source'); - file_put_contents($tmp . '/source/file.txt', 'data'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'source'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'source' . DIRECTORY_SEPARATOR . 'file.txt', 'data'); $cwd = getcwd(); chdir($tmp); @@ -33,21 +33,19 @@ public function testCommandCreatesSymlinks(): void $this->runCommand($composer); - $this->assertTrue(is_link($tmp . '/link.txt')); - $this->assertSame( - realpath($tmp . '/source/file.txt'), - realpath(dirname($tmp . '/link.txt') . '/' . readlink($tmp . '/link.txt')) - ); + $link = $tmp . DIRECTORY_SEPARATOR . 'link.txt'; + + $this->assertLinkOrMirror($link, $tmp . DIRECTORY_SEPARATOR . 'source' . DIRECTORY_SEPARATOR . 'file.txt'); chdir($cwd); } public function testDryRunOptionDoesNotCreateLinks(): void { - $tmp = sys_get_temp_dir() . '/command_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'command_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/source'); - file_put_contents($tmp . '/source/file.txt', 'data'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'source'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'source' . DIRECTORY_SEPARATOR . 'file.txt', 'data'); $cwd = getcwd(); chdir($tmp); @@ -62,7 +60,7 @@ public function testDryRunOptionDoesNotCreateLinks(): void $this->runCommand($composer, ['--dry-run' => true]); - $this->assertFalse(file_exists($tmp . '/link.txt')); + $this->assertFalse(file_exists($tmp . DIRECTORY_SEPARATOR . 'link.txt')); chdir($cwd); } @@ -97,5 +95,72 @@ private function runCommand(Composer $composer, array $input = []): void $tester = new CommandTester($application->find('symlinks:refresh')); $tester->execute(array_merge(['command' => 'symlinks:refresh'], $input)); } + + private function assertLinkOrMirror(string $link, string $target): void + { + if (DIRECTORY_SEPARATOR === '\\') { + if (!is_link($link)) { + $this->assertFileMirrors($target, $link); + + return; + } + + $linkTarget = @readlink($link); + if ($linkTarget === false) { + $this->assertFileMirrors($target, $link); + + return; + } + } else { + $this->assertTrue(is_link($link)); + $linkTarget = readlink($link); + $this->assertNotFalse($linkTarget); + } + + $this->assertTrue(is_link($link)); + + $normalizedLinkTarget = $this->normalizePath($linkTarget); + + $resolvedLinkTarget = $this->isAbsolutePath($normalizedLinkTarget) + ? realpath($normalizedLinkTarget) + : realpath(dirname($link) . DIRECTORY_SEPARATOR . $normalizedLinkTarget); + + $this->assertNotFalse($resolvedLinkTarget); + + $this->assertSame(realpath($target), $resolvedLinkTarget); + } + + private function normalizePath(string $path): string + { + if (DIRECTORY_SEPARATOR === '\\') { + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + return $path; + } + + private function assertFileMirrors(string $target, string $path): void + { + $this->assertFileExists($path); + $targetContents = file_get_contents($target); + $linkContents = file_get_contents($path); + + $this->assertNotFalse($targetContents); + $this->assertNotFalse($linkContents); + $this->assertSame($targetContents, $linkContents); + } + + private function isAbsolutePath(string $path): bool + { + if ($path === '') { + return false; + } + + if (DIRECTORY_SEPARATOR === '\\') { + return (bool) preg_match('{^(?:[A-Za-z]:\\\\|\\\\\\\\)}', $path); + } + + return $path[0] === DIRECTORY_SEPARATOR; + } } diff --git a/tests/SymlinksFactoryTest.php b/tests/SymlinksFactoryTest.php index c54c34a..2e26182 100644 --- a/tests/SymlinksFactoryTest.php +++ b/tests/SymlinksFactoryTest.php @@ -16,12 +16,13 @@ class SymlinksFactoryTest extends TestCase { public function testProcessCreatesSymlinkDefinition(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/target'); - file_put_contents($tmp . '/target/file.txt', 'content'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'target'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); $cwd = getcwd(); chdir($tmp); + $projectDir = $this->getResolvedCwd(); $composer = new Composer(); $dispatcher = new EventDispatcher($composer, new NullIO()); @@ -43,16 +44,17 @@ public function testProcessCreatesSymlinkDefinition(): void $this->assertCount(1, $symlinks); $symlink = $symlinks[0]; - $this->assertSame(realpath($tmp . '/target/file.txt'), $symlink->getTarget()); - $this->assertSame($tmp . '/link.txt', $symlink->getLink()); + $this->assertSame(realpath($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt'), $symlink->getTarget()); + $this->assertSamePath($projectDir . DIRECTORY_SEPARATOR . 'link.txt', $symlink->getLink()); $this->assertTrue($symlink->isAbsolutePath()); + $this->assertSame(\SomeWork\Symlinks\Symlink::WINDOWS_MODE_JUNCTION, $symlink->getWindowsMode()); chdir($cwd); } public function testProcessSkipsMissingTargetPerLink(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); $cwd = getcwd(); chdir($tmp); @@ -84,12 +86,12 @@ public function testProcessSkipsMissingTargetPerLink(): void public function testExistingRelativeSymlinkIsNotProcessed(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/target', 0777, true); - mkdir($tmp . '/dir', 0777, true); - file_put_contents($tmp . '/target/file.txt', 'content'); - symlink('../target/file.txt', $tmp . '/dir/link.txt'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'target', 0777, true); + mkdir($tmp . DIRECTORY_SEPARATOR . 'dir', 0777, true); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); + $this->createSymlinkOrSkip('../target/file.txt', $tmp . DIRECTORY_SEPARATOR . 'dir' . DIRECTORY_SEPARATOR . 'link.txt'); $cwd = getcwd(); chdir($tmp); @@ -246,7 +248,7 @@ public function testProcessReturnsEmptyWhenNoSymlinksConfigured(): void public function testProcessSkipsMissingTargetGlobally(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); $cwd = getcwd(); chdir($tmp); @@ -276,10 +278,10 @@ public function testProcessSkipsMissingTargetGlobally(): void public function testProcessSetsForceCreateFromConfig(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/target'); - file_put_contents($tmp . '/target/file.txt', 'content'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'target'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); $cwd = getcwd(); chdir($tmp); @@ -309,7 +311,7 @@ public function testProcessSetsForceCreateFromConfig(): void public function testProcessThrowsExceptionForMissingTarget(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); $cwd = getcwd(); chdir($tmp); @@ -342,10 +344,10 @@ public function testProcessThrowsExceptionForMissingTarget(): void public function testProcessThrowsLinkDirectoryErrorOnEnsureDirectoryFailure(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/target'); - file_put_contents($tmp . '/target/file.txt', 'content'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'target'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); $cwd = getcwd(); chdir($tmp); @@ -384,15 +386,18 @@ public function testProcessThrowsLinkDirectoryErrorOnEnsureDirectoryFailure(): v public function testProcessExpandsProjectDirAndEnvPlaceholders(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/env-dir'); - file_put_contents($tmp . '/env-dir/file.txt', 'content'); - mkdir($tmp . '/links'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'env-dir'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'env-dir' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'links'); $cwd = getcwd(); chdir($tmp); + $projectDir = $this->getResolvedCwd(); - putenv('SYMLINKS_CUSTOM_DIR=' . $tmp . '/env-dir'); + $envDir = realpath($tmp . DIRECTORY_SEPARATOR . 'env-dir'); + $this->assertNotFalse($envDir); + putenv('SYMLINKS_CUSTOM_DIR=' . $envDir); $composer = new Composer(); $dispatcher = new EventDispatcher($composer, new NullIO()); @@ -412,8 +417,8 @@ public function testProcessExpandsProjectDirAndEnvPlaceholders(): void $symlinks = $factory->process(); $this->assertCount(1, $symlinks); - $this->assertSame(realpath($tmp . '/env-dir/file.txt'), $symlinks[0]->getTarget()); - $this->assertSame($tmp . '/links/env-link.txt', $symlinks[0]->getLink()); + $this->assertSame(realpath($tmp . DIRECTORY_SEPARATOR . 'env-dir' . DIRECTORY_SEPARATOR . 'file.txt'), $symlinks[0]->getTarget()); + $this->assertSamePath($projectDir . DIRECTORY_SEPARATOR . 'links' . DIRECTORY_SEPARATOR . 'env-link.txt', $symlinks[0]->getLink()); putenv('SYMLINKS_CUSTOM_DIR'); chdir($cwd); @@ -421,13 +426,14 @@ public function testProcessExpandsProjectDirAndEnvPlaceholders(): void public function testProcessHandlesEmptyEnvPlaceholderExpansion(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/target', 0777, true); - file_put_contents($tmp . '/target/file.txt', 'content'); - mkdir($tmp . '/links', 0777, true); + mkdir($tmp . DIRECTORY_SEPARATOR . 'target', 0777, true); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'links', 0777, true); $cwd = getcwd(); chdir($tmp); + $projectDir = $this->getResolvedCwd(); $previousValue = getenv('SYMLINKS_OPTIONAL_SEGMENT'); putenv('SYMLINKS_OPTIONAL_SEGMENT'); @@ -450,8 +456,8 @@ public function testProcessHandlesEmptyEnvPlaceholderExpansion(): void $symlinks = $factory->process(); $this->assertCount(1, $symlinks); - $this->assertSame(realpath($tmp . '/target/file.txt'), $symlinks[0]->getTarget()); - $this->assertSame($tmp . '/links/file-link.txt', $symlinks[0]->getLink()); + $this->assertSame(realpath($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt'), $symlinks[0]->getTarget()); + $this->assertSamePath($projectDir . DIRECTORY_SEPARATOR . 'links' . DIRECTORY_SEPARATOR . 'file-link.txt', $symlinks[0]->getLink()); if ($previousValue === false) { putenv('SYMLINKS_OPTIONAL_SEGMENT'); @@ -464,15 +470,14 @@ public function testProcessHandlesEmptyEnvPlaceholderExpansion(): void public function testProcessExpandsVendorDirPlaceholder(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/target'); - file_put_contents($tmp . '/target/file.txt', 'content'); - $vendorDir = $tmp . '/custom-vendor'; + mkdir($tmp . DIRECTORY_SEPARATOR . 'target'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); + $vendorDir = $tmp . DIRECTORY_SEPARATOR . 'custom-vendor'; mkdir($vendorDir); $cwd = getcwd(); chdir($tmp); - $composer = new Composer(); $config = new Config(false, $tmp); $config->merge(['config' => ['vendor-dir' => $vendorDir]]); @@ -495,20 +500,23 @@ public function testProcessExpandsVendorDirPlaceholder(): void $this->assertCount(1, $symlinks); $this->assertSame(realpath($tmp . '/target/file.txt'), $symlinks[0]->getTarget()); - $this->assertSame($vendorDir . '/package/link.txt', $symlinks[0]->getLink()); + $vendorDirReal = realpath($vendorDir); + $this->assertNotFalse($vendorDirReal); + $this->assertSamePath($vendorDirReal . DIRECTORY_SEPARATOR . 'package' . DIRECTORY_SEPARATOR . 'link.txt', $symlinks[0]->getLink()); chdir($cwd); } public function testProcessExpandsVendorDirPlaceholderWithRelativeConfig(): void { - $tmp = sys_get_temp_dir() . '/factory_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/target'); - file_put_contents($tmp . '/target/file.txt', 'content'); - mkdir($tmp . '/build/vendor', 0777, true); + mkdir($tmp . DIRECTORY_SEPARATOR . 'target'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); + mkdir($tmp . DIRECTORY_SEPARATOR . 'build' . DIRECTORY_SEPARATOR . 'vendor', 0777, true); $cwd = getcwd(); chdir($tmp); + $projectDir = $this->getResolvedCwd(); $composer = new Composer(); $config = new Config(false, $tmp); @@ -532,8 +540,174 @@ public function testProcessExpandsVendorDirPlaceholderWithRelativeConfig(): void $this->assertCount(1, $symlinks); $this->assertSame(realpath($tmp . '/target/file.txt'), $symlinks[0]->getTarget()); - $this->assertSame($tmp . '/build/vendor/package/link.txt', $symlinks[0]->getLink()); + $vendorDirReal = realpath($projectDir . DIRECTORY_SEPARATOR . 'build' . DIRECTORY_SEPARATOR . 'vendor'); + $this->assertNotFalse($vendorDirReal); + $this->assertSamePath($vendorDirReal . DIRECTORY_SEPARATOR . 'package' . DIRECTORY_SEPARATOR . 'link.txt', $symlinks[0]->getLink()); chdir($cwd); } + + public function testProcessAllowsConfiguringWindowsMode(): void + { + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); + mkdir($tmp); + mkdir($tmp . DIRECTORY_SEPARATOR . 'target'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); + $cwd = getcwd(); + chdir($tmp); + + $composer = new Composer(); + $dispatcher = new EventDispatcher($composer, new NullIO()); + $composer->setEventDispatcher($dispatcher); + $package = new RootPackage('test/test', '1.0.0', '1.0.0'); + $package->setExtra([ + 'somework/composer-symlinks' => [ + 'windows-mode' => 'copy', + 'symlinks' => [ + 'target/file.txt' => [ + 'link' => 'link.txt', + 'windows-mode' => 'symlink' + ] + ] + ] + ]); + $composer->setPackage($package); + + $event = new Event('post-install-cmd', $composer, new NullIO()); + $factory = new SymlinksFactory($event, new Filesystem()); + $symlinks = $factory->process(); + + $this->assertCount(1, $symlinks); + $symlink = $symlinks[0]; + $this->assertSame(\SomeWork\Symlinks\Symlink::WINDOWS_MODE_SYMLINK, $symlink->getWindowsMode()); + + chdir($cwd); + } + + public function testProcessRejectsInvalidWindowsMode(): void + { + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); + mkdir($tmp); + mkdir($tmp . DIRECTORY_SEPARATOR . 'target'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); + $cwd = getcwd(); + chdir($tmp); + + $composer = new Composer(); + $dispatcher = new EventDispatcher($composer, new NullIO()); + $composer->setEventDispatcher($dispatcher); + $package = new RootPackage('test/test', '1.0.0', '1.0.0'); + $package->setExtra([ + 'somework/composer-symlinks' => [ + 'windows-mode' => 'invalid', + 'symlinks' => [ + 'target/file.txt' => 'link.txt' + ] + ] + ]); + $composer->setPackage($package); + + $event = new Event('post-install-cmd', $composer, new NullIO()); + $factory = new SymlinksFactory($event, new Filesystem()); + + $this->expectException(\SomeWork\Symlinks\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown windows-mode'); + + try { + $factory->process(); + } finally { + chdir($cwd); + } + } + + public function testProcessRejectsNonScalarWindowsMode(): void + { + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'factory_' . uniqid(); + mkdir($tmp); + mkdir($tmp . DIRECTORY_SEPARATOR . 'target'); + file_put_contents($tmp . DIRECTORY_SEPARATOR . 'target' . DIRECTORY_SEPARATOR . 'file.txt', 'content'); + $cwd = getcwd(); + chdir($tmp); + + $composer = new Composer(); + $dispatcher = new EventDispatcher($composer, new NullIO()); + $composer->setEventDispatcher($dispatcher); + $package = new RootPackage('test/test', '1.0.0', '1.0.0'); + $package->setExtra([ + 'somework/composer-symlinks' => [ + 'windows-mode' => ['array'], + 'symlinks' => [ + 'target/file.txt' => 'link.txt' + ] + ] + ]); + $composer->setPackage($package); + + $event = new Event('post-install-cmd', $composer, new NullIO()); + $factory = new SymlinksFactory($event, new Filesystem()); + + $this->expectException(\SomeWork\Symlinks\InvalidArgumentException::class); + $this->expectExceptionMessage('The config option windows-mode must be a string or scalar value.'); + + try { + $factory->process(); + } finally { + chdir($cwd); + } + } + + private function getResolvedCwd(): string + { + $cwd = getcwd(); + $this->assertNotFalse($cwd); + + $resolved = realpath($cwd); + + return $resolved === false ? $cwd : $resolved; + } + + private function normalizePath(string $path): string + { + if (DIRECTORY_SEPARATOR === '\\') { + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + return $path; + } + + private function assertSamePath(string $expected, string $actual): void + { + $this->assertSame( + $this->canonicalizePath($expected), + $this->canonicalizePath($actual) + ); + } + + private function canonicalizePath(string $path): string + { + $directory = dirname($path); + $basename = basename($path); + + $resolvedDirectory = realpath($directory); + if ($resolvedDirectory !== false) { + return $this->normalizePath($resolvedDirectory . DIRECTORY_SEPARATOR . $basename); + } + + return $this->normalizePath($path); + } + + private function createSymlinkOrSkip(string $target, string $link): void + { + error_clear_last(); + if (@symlink($target, $link)) { + return; + } + + $error = error_get_last(); + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Symlink creation is not available: ' . ($error['message'] ?? 'unknown error')); + } + + $this->fail('Failed to create symlink: ' . ($error['message'] ?? 'unknown error')); + } } diff --git a/tests/SymlinksProcessorTest.php b/tests/SymlinksProcessorTest.php index dd065e7..8501e88 100644 --- a/tests/SymlinksProcessorTest.php +++ b/tests/SymlinksProcessorTest.php @@ -11,12 +11,12 @@ class SymlinksProcessorTest extends TestCase { public function testProcessSymlinkCreatesLink(): void { - $tmp = sys_get_temp_dir() . '/processor_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'processor_' . uniqid(); mkdir($tmp); - $target = $tmp . '/target.txt'; + $target = $tmp . DIRECTORY_SEPARATOR . 'target.txt'; file_put_contents($target, 'data'); - $link = $tmp . '/link.txt'; + $link = $tmp . DIRECTORY_SEPARATOR . 'link.txt'; $symlink = (new Symlink()) ->setTarget($target) @@ -27,18 +27,19 @@ public function testProcessSymlinkCreatesLink(): void $result = $processor->processSymlink($symlink); $this->assertTrue($result); - $this->assertTrue(is_link($link)); - $this->assertSame(realpath($target), realpath(readlink($link))); + $this->assertLinkOrMirror($target, $link, function (string $linkPath) use ($target): string { + return $this->resolveLinkTarget($linkPath); + }); } public function testDryRunDoesNotCreateLink(): void { - $tmp = sys_get_temp_dir() . '/processor_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'processor_' . uniqid(); mkdir($tmp); - $target = $tmp . '/target.txt'; + $target = $tmp . DIRECTORY_SEPARATOR . 'target.txt'; file_put_contents($target, 'data'); - $link = $tmp . '/link.txt'; + $link = $tmp . DIRECTORY_SEPARATOR . 'link.txt'; $symlink = (new Symlink()) ->setTarget($target) @@ -54,12 +55,12 @@ public function testDryRunDoesNotCreateLink(): void public function testForceCreateReplacesExistingLink(): void { - $tmp = sys_get_temp_dir() . '/processor_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'processor_' . uniqid(); mkdir($tmp); - $target = $tmp . '/target.txt'; + $target = $tmp . DIRECTORY_SEPARATOR . 'target.txt'; file_put_contents($target, 'new'); - $link = $tmp . '/link.txt'; + $link = $tmp . DIRECTORY_SEPARATOR . 'link.txt'; file_put_contents($link, 'old'); $symlink = (new Symlink()) @@ -80,20 +81,21 @@ public function testForceCreateReplacesExistingLink(): void $result = $processor->processSymlink($symlink); $this->assertTrue($result); - $this->assertTrue(is_link($link)); - $this->assertSame(realpath($target), realpath(readlink($link))); + $this->assertLinkOrMirror($target, $link, function (string $linkPath) use ($target): string { + return $this->resolveLinkTarget($linkPath); + }); } public function testThrowsErrorWhenLinkExists(): void { $this->expectException(\SomeWork\Symlinks\LinkDirectoryError::class); - $tmp = sys_get_temp_dir() . '/processor_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'processor_' . uniqid(); mkdir($tmp); - $target = $tmp . '/target.txt'; + $target = $tmp . DIRECTORY_SEPARATOR . 'target.txt'; file_put_contents($target, 'data'); - $link = $tmp . '/link.txt'; + $link = $tmp . DIRECTORY_SEPARATOR . 'link.txt'; file_put_contents($link, 'old'); $symlink = (new Symlink()) @@ -109,12 +111,12 @@ public function testDryRunThrowsErrorWhenLinkExists(): void { $this->expectException(\SomeWork\Symlinks\LinkDirectoryError::class); - $tmp = sys_get_temp_dir() . '/processor_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'processor_' . uniqid(); mkdir($tmp); - $target = $tmp . '/target.txt'; + $target = $tmp . DIRECTORY_SEPARATOR . 'target.txt'; file_put_contents($target, 'data'); - $link = $tmp . '/link.txt'; + $link = $tmp . DIRECTORY_SEPARATOR . 'link.txt'; file_put_contents($link, 'old'); $symlink = (new Symlink()) @@ -128,13 +130,13 @@ public function testDryRunThrowsErrorWhenLinkExists(): void public function testProcessSymlinkCreatesRelativeLink(): void { - $tmp = sys_get_temp_dir() . '/processor_' . uniqid(); + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'processor_' . uniqid(); mkdir($tmp); - mkdir($tmp . '/dir', 0777, true); - $target = $tmp . '/target.txt'; + mkdir($tmp . DIRECTORY_SEPARATOR . 'dir', 0777, true); + $target = $tmp . DIRECTORY_SEPARATOR . 'target.txt'; file_put_contents($target, 'data'); - $link = $tmp . '/dir/link.txt'; + $link = $tmp . DIRECTORY_SEPARATOR . 'dir' . DIRECTORY_SEPARATOR . 'link.txt'; $symlink = (new Symlink()) ->setTarget($target) @@ -144,7 +146,199 @@ public function testProcessSymlinkCreatesRelativeLink(): void $result = $processor->processSymlink($symlink); $this->assertTrue($result); + $this->assertLinkOrMirror($target, $link, function (string $linkPath) use ($target): string { + return $this->resolveRelativeLinkTarget($linkPath); + }); + } + + /** + * @requires OSFAMILY Windows + */ + public function testWindowsFallbackCreatesJunctionWhenSymlinksUnavailable(): void + { + $tmp = sys_get_temp_dir() . '\\processor_' . uniqid(); + mkdir($tmp); + mkdir($tmp . '\\target'); + file_put_contents($tmp . '\\target\\file.txt', 'data'); + + $link = $tmp . '\\linkDir'; + + if (@symlink($tmp . '\\target', $link)) { + // Symlinks are available; cleanup and skip so that fallback is not exercised. + $filesystem = new Filesystem(); + $filesystem->removeDirectory($link); + $this->markTestSkipped('Native symlinks are available.'); + } + + $symlink = (new Symlink()) + ->setTarget($tmp . '\\target') + ->setLink($link) + ->setAbsolutePath(true) + ->setWindowsMode(Symlink::WINDOWS_MODE_JUNCTION); + + $processor = new SymlinksProcessor(new Filesystem()); + + try { + $result = $processor->processSymlink($symlink); + $this->assertTrue($result); + $this->assertDirectoryExists($link); + $this->assertFileExists($link . DIRECTORY_SEPARATOR . 'file.txt'); + $this->assertSame('data', file_get_contents($link . DIRECTORY_SEPARATOR . 'file.txt')); + } finally { + $filesystem = new Filesystem(); + $filesystem->removeDirectory($tmp); + } + } + + /** + * @requires OSFAMILY Windows + */ + public function testWindowsFallbackCreatesCopyWhenHardlinksUnavailable(): void + { + $tmp = sys_get_temp_dir() . '\\processor_' . uniqid(); + mkdir($tmp); + file_put_contents($tmp . '\\target.txt', 'payload'); + + $link = $tmp . '\\link.txt'; + + if (@symlink($tmp . '\\target.txt', $link)) { + unlink($link); + $this->markTestSkipped('Native symlinks are available.'); + } + + $symlink = (new Symlink()) + ->setTarget($tmp . '\\target.txt') + ->setLink($link) + ->setAbsolutePath(true) + ->setWindowsMode(Symlink::WINDOWS_MODE_JUNCTION); + + $processor = new SymlinksProcessor(new Filesystem()); + + try { + $result = $processor->processSymlink($symlink); + $this->assertTrue($result); + $this->assertFileExists($link); + $this->assertSame('payload', file_get_contents($link)); + } finally { + $filesystem = new Filesystem(); + $filesystem->removeDirectory($tmp); + } + } + + /** + * @requires OSFAMILY Windows + */ + public function testWindowsCopyModeSkipsSymlinks(): void + { + $tmp = sys_get_temp_dir() . '\\processor_' . uniqid(); + mkdir($tmp); + file_put_contents($tmp . '\\target.txt', 'payload'); + + $link = $tmp . '\\copy.txt'; + + $symlink = (new Symlink()) + ->setTarget($tmp . '\\target.txt') + ->setLink($link) + ->setAbsolutePath(true) + ->setWindowsMode(Symlink::WINDOWS_MODE_COPY); + + $processor = new SymlinksProcessor(new Filesystem()); + + try { + $result = $processor->processSymlink($symlink); + $this->assertTrue($result); + $this->assertFileExists($link); + $this->assertFalse(is_link($link)); + $this->assertSame('payload', file_get_contents($link)); + } finally { + $filesystem = new Filesystem(); + $filesystem->removeDirectory($tmp); + } + } + + private function assertLinkOrMirror(string $target, string $link, callable $resolver): void + { + if (DIRECTORY_SEPARATOR === '\\') { + if (!is_link($link)) { + $this->assertFileMirrorsTarget($target, $link); + + return; + } + + if (@readlink($link) === false) { + $this->assertFileMirrorsTarget($target, $link); + + return; + } + } else { + $this->assertTrue(is_link($link)); + } + $this->assertTrue(is_link($link)); - $this->assertSame(realpath($target), realpath(dirname($link) . '/' . readlink($link))); + $this->assertSame(realpath($target), $resolver($link)); + } + + private function assertFileMirrorsTarget(string $target, string $link): void + { + $this->assertFileExists($link); + $targetContents = file_get_contents($target); + $linkContents = file_get_contents($link); + + $this->assertNotFalse($targetContents); + $this->assertNotFalse($linkContents); + $this->assertSame($targetContents, $linkContents); + } + + private function resolveLinkTarget(string $link): string + { + $target = readlink($link); + $this->assertNotFalse($target); + + $normalized = $this->normalizePath($target); + + if ($this->isAbsolutePath($normalized)) { + $resolved = realpath($normalized); + $this->assertNotFalse($resolved); + + return $resolved; + } + + $resolved = realpath(dirname($link) . DIRECTORY_SEPARATOR . $normalized); + $this->assertNotFalse($resolved); + + return $resolved; + } + + private function resolveRelativeLinkTarget(string $link): string + { + $target = readlink($link); + $this->assertNotFalse($target); + + $resolved = realpath(dirname($link) . DIRECTORY_SEPARATOR . $this->normalizePath($target)); + $this->assertNotFalse($resolved); + + return $resolved; + } + + private function normalizePath(string $path): string + { + if (DIRECTORY_SEPARATOR === '\\') { + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + return $path; + } + + private function isAbsolutePath(string $path): bool + { + if ($path === '') { + return false; + } + + if (DIRECTORY_SEPARATOR === '\\') { + return (bool) preg_match('{^(?:[A-Za-z]:\\\\|\\\\\\\\)}', $path); + } + + return $path[0] === DIRECTORY_SEPARATOR; } }