From e515eb84e93b4bc2c3ab711b452c7e0ae4f05438 Mon Sep 17 00:00:00 2001 From: Niels Keurentjes Date: Thu, 28 Jan 2016 00:33:11 +0100 Subject: [PATCH 1/5] Add NTFS junction support to Util\Filesystem. --- src/Composer/Util/Filesystem.php | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/Composer/Util/Filesystem.php b/src/Composer/Util/Filesystem.php index f7d812de8..7380f8ab8 100644 --- a/src/Composer/Util/Filesystem.php +++ b/src/Composer/Util/Filesystem.php @@ -98,6 +98,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 +580,36 @@ class Filesystem return $resolved; } + + /** + * Returns whether the target directory is a Windows NTFS Junction. + * + * @param string $junction Path to check. + * @return bool + */ + public function isJunction($junction) + { + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + return false; + } + $normalized = rtrim(str_replace('/', DIRECTORY_SEPARATOR, $junction), DIRECTORY_SEPARATOR); + $real = rtrim(realpath($normalized), DIRECTORY_SEPARATOR); + return is_dir($normalized) && ($normalized !== $real); + } + + /** + * Removes a Windows NTFS junction. + * + * @param string $junction + * @return bool + */ + public function removeJunction($junction) + { + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + return false; + } + $junction = rtrim(str_replace('/', DIRECTORY_SEPARATOR, $junction), DIRECTORY_SEPARATOR); + $cmd = sprintf('rmdir /S /Q %s', ProcessExecutor::escape($junction)); + return $this->getProcess()->execute($cmd, $output) === 0; + } } From 5489586436209e171b351653d85c1dd8483de7d0 Mon Sep 17 00:00:00 2001 From: Niels Keurentjes Date: Thu, 28 Jan 2016 00:56:02 +0100 Subject: [PATCH 2/5] Fully implemented junctioning on Windows for path repositories. --- src/Composer/Downloader/PathDownloader.php | 13 ++++++++++--- src/Composer/Util/Filesystem.php | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/Composer/Downloader/PathDownloader.php b/src/Composer/Downloader/PathDownloader.php index 3ff47da8e..b4f52f0b0 100644 --- a/src/Composer/Downloader/PathDownloader.php +++ b/src/Composer/Downloader/PathDownloader.php @@ -48,9 +48,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 (defined('PHP_WINDOWS_VERSION_BUILD')) { + // Implement symlinks as junctions on Windows, with magic shell hackery + $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 7380f8ab8..2f8a7cf4c 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; /** @@ -581,6 +582,24 @@ class Filesystem return $resolved; } + /** + * Creates an NTFS junction. + * + * @param string $originDir + * @param string $targetDir + */ + public function junction($originDir, $targetDir) + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $cmd = sprintf('mklink /J %s %s', + ProcessExecutor::escape(str_replace('/', DIRECTORY_SEPARATOR, $targetDir)), + ProcessExecutor::escape(realpath($originDir))); + if ($this->getProcess()->execute($cmd, $output) === 0) + return; + } + throw new IOException(sprintf('Failed to create symbolic link from "%s" to "%s".', $originDir, $targetDir), 0, null, $targetDir); + } + /** * Returns whether the target directory is a Windows NTFS Junction. * From 358cb3f4fc2f7dc6ae2a1ac69c1a9f788bf35f74 Mon Sep 17 00:00:00 2001 From: Niels Keurentjes Date: Thu, 28 Jan 2016 01:06:05 +0100 Subject: [PATCH 3/5] Fixed exception text and some warnings. --- src/Composer/Util/Filesystem.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Composer/Util/Filesystem.php b/src/Composer/Util/Filesystem.php index 2f8a7cf4c..e8334d0a2 100644 --- a/src/Composer/Util/Filesystem.php +++ b/src/Composer/Util/Filesystem.php @@ -594,10 +594,10 @@ class Filesystem $cmd = sprintf('mklink /J %s %s', ProcessExecutor::escape(str_replace('/', DIRECTORY_SEPARATOR, $targetDir)), ProcessExecutor::escape(realpath($originDir))); - if ($this->getProcess()->execute($cmd, $output) === 0) + if ($this->getProcess()->execute($cmd) === 0) return; } - throw new IOException(sprintf('Failed to create symbolic link from "%s" to "%s".', $originDir, $targetDir), 0, null, $targetDir); + throw new IOException(sprintf('Failed to create junction from "%s" to "%s".', $originDir, $targetDir), 0, null, $targetDir); } /** @@ -629,6 +629,6 @@ class Filesystem } $junction = rtrim(str_replace('/', DIRECTORY_SEPARATOR, $junction), DIRECTORY_SEPARATOR); $cmd = sprintf('rmdir /S /Q %s', ProcessExecutor::escape($junction)); - return $this->getProcess()->execute($cmd, $output) === 0; + return $this->getProcess()->execute($cmd) === 0; } } From b71c67239d8a4e91dd158a06b24833ca70853013 Mon Sep 17 00:00:00 2001 From: Niels Keurentjes Date: Tue, 2 Feb 2016 23:44:01 +0100 Subject: [PATCH 4/5] Made NTFS junction detection more reliable and added unit tests for the junction functions. --- src/Composer/Util/Filesystem.php | 38 +++++++++++++-------- tests/Composer/Test/Util/FilesystemTest.php | 29 ++++++++++++++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/Composer/Util/Filesystem.php b/src/Composer/Util/Filesystem.php index e8334d0a2..c3b888163 100644 --- a/src/Composer/Util/Filesystem.php +++ b/src/Composer/Util/Filesystem.php @@ -585,19 +585,23 @@ class Filesystem /** * Creates an NTFS junction. * - * @param string $originDir - * @param string $targetDir + * @param string $target + * @param string $junction */ - public function junction($originDir, $targetDir) + public function junction($target, $junction) { - if (defined('PHP_WINDOWS_VERSION_BUILD')) { - $cmd = sprintf('mklink /J %s %s', - ProcessExecutor::escape(str_replace('/', DIRECTORY_SEPARATOR, $targetDir)), - ProcessExecutor::escape(realpath($originDir))); - if ($this->getProcess()->execute($cmd) === 0) - return; + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + 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); } - throw new IOException(sprintf('Failed to create junction from "%s" to "%s".', $originDir, $targetDir), 0, null, $targetDir); } /** @@ -611,9 +615,12 @@ class Filesystem if (!defined('PHP_WINDOWS_VERSION_BUILD')) { return false; } - $normalized = rtrim(str_replace('/', DIRECTORY_SEPARATOR, $junction), DIRECTORY_SEPARATOR); - $real = rtrim(realpath($normalized), DIRECTORY_SEPARATOR); - return is_dir($normalized) && ($normalized !== $real); + 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); } /** @@ -628,7 +635,10 @@ class Filesystem 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) === 0; + return ($this->getProcess()->execute($cmd) === 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)); + } } From 54c079b559b150185bf482acb8800c44dcef6e75 Mon Sep 17 00:00:00 2001 From: Niels Keurentjes Date: Fri, 5 Feb 2016 11:27:41 +0100 Subject: [PATCH 5/5] Fixed Windows detection based on #4873 and suppressed some console output in removeJunction. --- src/Composer/Downloader/PathDownloader.php | 5 +++-- src/Composer/Util/Filesystem.php | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Composer/Downloader/PathDownloader.php b/src/Composer/Downloader/PathDownloader.php index b4f52f0b0..6a65e89af 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; @@ -48,8 +49,8 @@ class PathDownloader extends FileDownloader } try { - if (defined('PHP_WINDOWS_VERSION_BUILD')) { - // Implement symlinks as junctions on Windows, with magic shell hackery + if (Platform::isWindows()) { + // Implement symlinks as NTFS junctions on Windows $this->filesystem->junction($realUrl, $path); $this->io->writeError(sprintf(' Junctioned from %s', $url)); diff --git a/src/Composer/Util/Filesystem.php b/src/Composer/Util/Filesystem.php index 8e7ac3d04..4b69a85c9 100644 --- a/src/Composer/Util/Filesystem.php +++ b/src/Composer/Util/Filesystem.php @@ -590,7 +590,7 @@ class Filesystem */ public function junction($target, $junction) { - if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + if (!Platform::isWindows()) { throw new \LogicException(sprintf('Function %s is not available on non-Windows platform', __CLASS__)); } if (!is_dir($target)) { @@ -612,7 +612,7 @@ class Filesystem */ public function isJunction($junction) { - if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + if (!Platform::isWindows()) { return false; } if (!is_dir($junction) || is_link($junction)) { @@ -631,7 +631,7 @@ class Filesystem */ public function removeJunction($junction) { - if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + if (!Platform::isWindows()) { return false; } $junction = rtrim(str_replace('/', DIRECTORY_SEPARATOR, $junction), DIRECTORY_SEPARATOR); @@ -639,6 +639,6 @@ class Filesystem 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) === 0); + return ($this->getProcess()->execute($cmd, $output) === 0); } }