diff --git a/src/Composer/Downloader/PathDownloader.php b/src/Composer/Downloader/PathDownloader.php index 7de750db6..ae36336ed 100644 --- a/src/Composer/Downloader/PathDownloader.php +++ b/src/Composer/Downloader/PathDownloader.php @@ -13,6 +13,7 @@ namespace Composer\Downloader; use Composer\Package\PackageInterface; +use Composer\Util\Platform; use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; @@ -54,9 +55,16 @@ class PathDownloader extends FileDownloader )); try { - $shortestPath = $this->filesystem->findShortestPath($path, $realUrl); - $fileSystem->symlink($shortestPath, $path); - $this->io->writeError(sprintf(' Symlinked from %s', $url)); + if (Platform::isWindows()) { + // Implement symlinks as NTFS junctions on Windows + $this->filesystem->junction($realUrl, $path); + $this->io->writeError(sprintf(' Junctioned from %s', $url)); + + } else { + $shortestPath = $this->filesystem->findShortestPath($path, $realUrl); + $fileSystem->symlink($shortestPath, $path); + $this->io->writeError(sprintf(' Symlinked from %s', $url)); + } } catch (IOException $e) { $fileSystem->mirror($realUrl, $path); $this->io->writeError(sprintf(' Mirrored from %s', $url)); diff --git a/src/Composer/Util/Filesystem.php b/src/Composer/Util/Filesystem.php index e29a60673..4b69a85c9 100644 --- a/src/Composer/Util/Filesystem.php +++ b/src/Composer/Util/Filesystem.php @@ -14,6 +14,7 @@ namespace Composer\Util; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Finder\Finder; /** @@ -98,6 +99,10 @@ class Filesystem return $this->unlinkSymlinkedDirectory($directory); } + if ($this->isJunction($directory)) { + return $this->removeJunction($directory); + } + if (!file_exists($directory) || !is_dir($directory)) { return true; } @@ -576,4 +581,64 @@ class Filesystem return $resolved; } + + /** + * Creates an NTFS junction. + * + * @param string $target + * @param string $junction + */ + public function junction($target, $junction) + { + if (!Platform::isWindows()) { + throw new \LogicException(sprintf('Function %s is not available on non-Windows platform', __CLASS__)); + } + if (!is_dir($target)) { + throw new IOException(sprintf('Cannot junction to "%s" as it is not a directory.', $target), 0, null, $target); + } + $cmd = sprintf('mklink /J %s %s', + ProcessExecutor::escape(str_replace('/', DIRECTORY_SEPARATOR, $junction)), + ProcessExecutor::escape(realpath($target))); + if ($this->getProcess()->execute($cmd, $output) !== 0) { + throw new IOException(sprintf('Failed to create junction to "%s" at "%s".', $target, $junction), 0, null, $target); + } + } + + /** + * Returns whether the target directory is a Windows NTFS Junction. + * + * @param string $junction Path to check. + * @return bool + */ + public function isJunction($junction) + { + if (!Platform::isWindows()) { + return false; + } + if (!is_dir($junction) || is_link($junction)) { + return false; + } + // Junctions have no link stat but are otherwise indistinguishable from real directories + $stat = lstat($junction); + return ($stat['mode'] === 0); + } + + /** + * Removes a Windows NTFS junction. + * + * @param string $junction + * @return bool + */ + public function removeJunction($junction) + { + if (!Platform::isWindows()) { + return false; + } + $junction = rtrim(str_replace('/', DIRECTORY_SEPARATOR, $junction), DIRECTORY_SEPARATOR); + if (!$this->isJunction($junction)) { + throw new IOException(sprintf('%s is not a junction and thus cannot be removed as one', $junction)); + } + $cmd = sprintf('rmdir /S /Q %s', ProcessExecutor::escape($junction)); + return ($this->getProcess()->execute($cmd, $output) === 0); + } } diff --git a/tests/Composer/Test/Util/FilesystemTest.php b/tests/Composer/Test/Util/FilesystemTest.php index 969572036..550b0e3ce 100644 --- a/tests/Composer/Test/Util/FilesystemTest.php +++ b/tests/Composer/Test/Util/FilesystemTest.php @@ -266,4 +266,33 @@ class FilesystemTest extends TestCase $this->assertFalse(file_exists($symlinkedTrailingSlash)); $this->assertFalse(file_exists($symlinked)); } + + public function testJunctions() + { + @mkdir($this->workingDir . '/real/nesting/testing', 0777, true); + $fs = new Filesystem(); + + // Non-Windows systems do not support this and will return false on all tests, and an exception on creation + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->assertFalse($fs->isJunction($this->workingDir)); + $this->assertFalse($fs->removeJunction($this->workingDir)); + $this->setExpectedException('LogicException', 'not available on non-Windows platform'); + } + + $target = $this->workingDir . '/real/../real/nesting'; + $junction = $this->workingDir . '/junction'; + + // Create and detect junction + $fs->junction($target, $junction); + $this->assertTrue($fs->isJunction($junction)); + $this->assertFalse($fs->isJunction($target)); + $this->assertTrue($fs->isJunction($target . '/../../junction')); + $this->assertFalse($fs->isJunction($junction . '/../real')); + $this->assertTrue($fs->isJunction($junction . '/../junction')); + + // Remove junction + $this->assertTrue(is_dir($junction)); + $this->assertTrue($fs->removeJunction($junction)); + $this->assertFalse(is_dir($junction)); + } }