diff --git a/composer.json b/composer.json index d04cc56ba0..dd9641ea81 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,8 @@ "require": { "composer/installers": "^1.0.24", "wikimedia/composer-merge-plugin": "^1.4", - "drupal/core": "self.version" + "drupal/core": "self.version", + "drupal/core-vendor-hardening": "^8.7" }, "require-dev": { "behat/mink": "1.7.x-dev", @@ -71,9 +72,6 @@ "pre-install-cmd": "Drupal\\Core\\Composer\\Composer::ensureComposerVersion", "pre-update-cmd": "Drupal\\Core\\Composer\\Composer::ensureComposerVersion", "pre-autoload-dump": "Drupal\\Core\\Composer\\Composer::preAutoloadDump", - "post-autoload-dump": "Drupal\\Core\\Composer\\Composer::ensureHtaccess", - "post-package-install": "Drupal\\Core\\Composer\\Composer::vendorTestCodeCleanup", - "post-package-update": "Drupal\\Core\\Composer\\Composer::vendorTestCodeCleanup", "phpcs": "phpcs --standard=core/phpcs.xml.dist --runtime-set installed_paths $($COMPOSER_BINARY config vendor-dir)/drupal/coder/coder_sniffer --", "phpcbf": "phpcbf --standard=core/phpcs.xml.dist --runtime-set installed_paths $($COMPOSER_BINARY config vendor-dir)/drupal/coder/coder_sniffer --" }, @@ -85,6 +83,10 @@ { "type": "path", "url": "core" + }, + { + "type": "path", + "url": "composer/Plugin/VendorHardening" } ] } diff --git a/composer.lock b/composer.lock index 2fd89e373a..88e1afcd1a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7c19b29738cf44de507d0baa3bd31665", + "content-hash": "c02f933242e0fa2402cda05686845e48", "packages": [ { "name": "asm89/stack-cors", @@ -902,6 +902,36 @@ ], "description": "Drupal is an open source content management platform powering millions of websites and applications." }, + { + "name": "drupal/core-vendor-hardening", + "version": "8.8.x-dev", + "dist": { + "type": "path", + "url": "composer/Plugin/VendorHardening", + "reference": "2db54f089065dedbe4a040b01f7b527f2bad68f6" + }, + "require": { + "composer-plugin-api": "^1.1", + "php": ">=7.0.8" + }, + "type": "composer-plugin", + "extra": { + "class": "Drupal\\Composer\\Plugin\\VendorHardening\\VendorHardeningPlugin" + }, + "autoload": { + "psr-4": { + "Drupal\\Composer\\Plugin\\VendorHardening\\": "." + } + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Hardens the vendor directory for when it's in the docroot.", + "homepage": "https://www.drupal.org/project/drupal", + "keywords": [ + "drupal" + ] + }, { "name": "easyrdf/easyrdf", "version": "0.9.1", diff --git a/composer/Plugin/VendorHardening/VendorHardeningPlugin.php b/composer/Plugin/VendorHardening/VendorHardeningPlugin.php index 7e36e0e9e1..1de19be205 100644 --- a/composer/Plugin/VendorHardening/VendorHardeningPlugin.php +++ b/composer/Plugin/VendorHardening/VendorHardeningPlugin.php @@ -5,12 +5,13 @@ use Composer\Composer; use Composer\EventDispatcher\EventSubscriberInterface; use Composer\Installer\PackageEvent; +use Composer\Installer\PackageEvents; use Composer\IO\IOInterface; +use Composer\Package\CompletePackage; use Composer\Plugin\PluginInterface; +use Composer\Script\Event; use Composer\Script\ScriptEvents; use Composer\Util\Filesystem; -use Composer\Script\Event; -use Composer\Installer\PackageEvents; /** * A Composer plugin to clean out your project's vendor directory. @@ -70,6 +71,8 @@ public static function getSubscribedEvents() { ScriptEvents::POST_AUTOLOAD_DUMP => 'onPostAutoloadDump', ScriptEvents::POST_UPDATE_CMD => 'onPostCmd', ScriptEvents::POST_INSTALL_CMD => 'onPostCmd', + PackageEvents::PRE_PACKAGE_INSTALL => 'onPrePackageInstall', + PackageEvents::PRE_PACKAGE_UPDATE => 'onPrePackageUpdate', PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall', PackageEvents::POST_PACKAGE_UPDATE => 'onPostPackageUpdate', ]; @@ -95,6 +98,28 @@ public function onPostCmd(Event $event) { $this->cleanAllPackages($this->composer->getConfig()->get('vendor-dir')); } + /** + * PRE_PACKAGE_INSTALL event handler. + * + * @param \Composer\Installer\PackageEvent $event + */ + public function onPrePackageInstall(PackageEvent $event) { + /** @var \Composer\Package\CompletePackage $package */ + $package = $event->getOperation()->getPackage(); + $this->removeBinBeforeCleanup($package); + } + + /** + * PRE_PACKAGE_UPDATE event handler. + * + * @param \Composer\Installer\PackageEvent $event + */ + public function onPrePackageUpdate(PackageEvent $event) { + /** @var \Composer\Package\CompletePackage $package */ + $package = $event->getOperation()->getTargetPackage(); + $this->removeBinBeforeCleanup($package); + } + /** * POST_PACKAGE_INSTALL event handler. * @@ -119,6 +144,80 @@ public function onPostPackageUpdate(PackageEvent $event) { $this->cleanPackage($this->composer->getConfig()->get('vendor-dir'), $package_name); } + /** + * Remove bin config for packages that would have the bin file removed. + * + * @param \Composer\Package\CompletePackage $package + * + * @see https://www.drupal.org/project/drupal/issues/3082866 + */ + protected function removeBinBeforeCleanup(CompletePackage $package) { + // Only do this if there are binaries and cleanup paths. + $binaries = $package->getBinaries(); + $clean_paths = $this->config->getPathsForPackage($package->getName()); + if (!$binaries || !$clean_paths) { + return; + } + if ($unset_these_binaries = $this->findBinOverlap($binaries, $clean_paths)) { + $this->io->writeError( + sprintf('%sModifying bin config for %s which overlaps with cleanup directories.', str_repeat(' ', 4), $package->getName()), + TRUE, + IOInterface::VERBOSE + ); + $modified_binaries = []; + foreach ($binaries as $binary) { + if (!in_array($binary, $unset_these_binaries)) { + $modified_binaries[] = $binary; + } + } + $package->setBinaries($modified_binaries); + } + } + + /** + * Find bin files which are inside cleanup directories. + * + * @param string[] $binaries + * @param string[] $clean_paths + * + * @return string[] + * Bin files to remove, with the file as both the key and the value. + */ + protected function findBinOverlap($binaries, $clean_paths) { + // Make a filesystem model to explore. This is a keyed array that looks like + // all the places that will be removed by cleanup. 'tests/src' becomes + // $filesystem['tests']['src'] = TRUE; + $filesystem = []; + foreach ($clean_paths as $clean_path) { + $clean_pieces = explode("/", $clean_path); + $current = &$filesystem; + foreach ($clean_pieces as $clean_piece) { + $current = &$current[$clean_piece]; + } + $current = TRUE; + } + // Explore the filesystem with our bin config. + $unset_these_binaries = []; + foreach ($binaries as $binary) { + $binary_pieces = explode('/', $binary); + $current = &$filesystem; + foreach ($binary_pieces as $binary_piece) { + if (!isset($current[$binary_piece])) { + break; + } + else { + // Value of TRUE means we're at the end of the path. + if ($current[$binary_piece] === TRUE) { + $unset_these_binaries[$binary] = $binary; + break; + } + } + $current = &$filesystem[$binary_piece]; + } + } + return $unset_these_binaries; + } + /** * Gets a list of all installed packages from Composer. * diff --git a/core/tests/Drupal/Tests/Composer/Plugin/VendorHardening/VendorHardeningPluginTest.php b/core/tests/Drupal/Tests/Composer/Plugin/VendorHardening/VendorHardeningPluginTest.php index cb894bc574..f98dd25b2d 100644 --- a/core/tests/Drupal/Tests/Composer/Plugin/VendorHardening/VendorHardeningPluginTest.php +++ b/core/tests/Drupal/Tests/Composer/Plugin/VendorHardening/VendorHardeningPluginTest.php @@ -122,6 +122,9 @@ public function testCleanAllPackages() { $this->assertFileNotExists(vfsStream::url('vendor/drupal/package/tests')); } + /** + * @covers ::writeAccessRestrictionFiles + */ public function testWriteAccessRestrictionFiles() { $dir = vfsStream::url('vendor'); @@ -148,4 +151,64 @@ public function testWriteAccessRestrictionFiles() { $this->assertFileExists($dir . '/web.config'); } + public function providerFindBinOverlap() { + return [ + [ + [], + ['bin/script'], + ['tests'], + ], + [ + ['bin/composer' => 'bin/composer'], + ['bin/composer'], + ['bin', 'tests'], + ], + [ + ['bin/composer' => 'bin/composer'], + ['bin/composer'], + ['bin/composer'], + ], + [ + [], + ['bin/composer'], + ['bin/something_else'], + ], + [ + [], + ['test/script'], + ['test/longer'], + ], + [ + ['bin/very/long/path/script' => 'bin/very/long/path/script'], + ['bin/very/long/path/script'], + ['bin'], + ], + [ + ['bin/bin/bin' => 'bin/bin/bin'], + ['bin/bin/bin'], + ['bin/bin'], + ], + [ + [], + ['bin/bin'], + ['bin/bin/bin'], + ], + ]; + } + + /** + * @covers ::findBinOverlap + * @dataProvider providerFindBinOverlap + */ + public function testFindBinOverlap($expected, $binaries, $clean_paths) { + $plugin = $this->getMockBuilder(VendorHardeningPlugin::class) + ->disableOriginalConstructor() + ->getMock(); + + $ref_find_bin_overlap = new \ReflectionMethod($plugin, 'findBinOverlap'); + $ref_find_bin_overlap->setAccessible(TRUE); + + $this->assertSame($expected, $ref_find_bin_overlap->invokeArgs($plugin, [$binaries, $clean_paths])); + } + }