From 27bee216d558b35de4de84c3e198c54e132d7a42 Mon Sep 17 00:00:00 2001 From: phenaproxima <phenaproxima@205645.no-reply.drupal.org> Date: Wed, 12 Jan 2022 19:25:56 +0000 Subject: [PATCH] Issue #3258048 by phenaproxima: Ensure the stage is marked as available even if all stage directories can't be deleted --- package_manager/src/Stage.php | 18 +++-- .../PackageManagerBypassServiceProvider.php | 38 ++++------ .../tests/src/Kernel/ExcludedPathsTest.php | 17 +++-- .../tests/src/Kernel/StageOwnershipTest.php | 72 +++++++++++++++++++ 4 files changed, 105 insertions(+), 40 deletions(-) diff --git a/package_manager/src/Stage.php b/package_manager/src/Stage.php index 6d46783830..711da3a019 100644 --- a/package_manager/src/Stage.php +++ b/package_manager/src/Stage.php @@ -4,6 +4,7 @@ namespace Drupal\package_manager; use Drupal\Component\FileSystem\FileSystem; use Drupal\Component\Utility\Crypt; +use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\TempStore\SharedTempStoreFactory; use Drupal\package_manager\Event\PostApplyEvent; @@ -290,12 +291,17 @@ class Stage { // Delete all directories in parent staging directory. $parent_stage_dir = static::getStagingRoot(); if (is_dir($parent_stage_dir)) { - // @todo Ensure that even if attempting to delete this directory throws an - // exception the stage is still marked as available in - // https://www.drupal.org/i/3258048. - $this->fileSystem->deleteRecursive($parent_stage_dir, function (string $path): void { - $this->fileSystem->chmod($path, 0777); - }); + try { + $this->fileSystem->deleteRecursive($parent_stage_dir, function (string $path): void { + $this->fileSystem->chmod($path, 0777); + }); + } + catch (FileException $e) { + // Deliberately swallow the exception so that the stage will be marked + // as available and the post-destroy event will be fired, even if the + // staging area can't actually be deleted. The file system service logs + // the exception, so we don't need to do anything else here. + } } $this->markAsAvailable(); $this->dispatch(new PostDestroyEvent($this)); diff --git a/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php b/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php index 3394cf4e15..3f7bf47d38 100644 --- a/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php +++ b/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php @@ -4,7 +4,6 @@ namespace Drupal\package_manager_bypass; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\DependencyInjection\ServiceProviderBase; -use Drupal\Core\Site\Settings; use Symfony\Component\DependencyInjection\Reference; /** @@ -18,30 +17,19 @@ class PackageManagerBypassServiceProvider extends ServiceProviderBase { public function alter(ContainerBuilder $container) { parent::alter($container); - // By default, bypass the Composer Stager library. This can be disabled for - // tests that want to use the real library, but only need to disable - // validators. - if (Settings::get('package_manager_bypass_stager', TRUE)) { - $container->getDefinition('package_manager.beginner') - ->setClass(Beginner::class); - $container->getDefinition('package_manager.stager') - ->setClass(Stager::class); - - $container->register('package_manager_bypass.committer') - ->setClass(Committer::class) - ->setPublic(FALSE) - ->setDecoratedService('package_manager.committer') - ->setArguments([ - new Reference('package_manager_bypass.committer.inner'), - ]) - ->setProperty('_serviceId', 'package_manager.committer'); - } - - // Allow functional tests to disable specific validators as necessary. - // Kernel tests can override the ::register() method and modify the - // container directly. - $validators = Settings::get('package_manager_bypass_validators', []); - array_walk($validators, [$container, 'removeDefinition']); + $container->getDefinition('package_manager.beginner') + ->setClass(Beginner::class); + $container->getDefinition('package_manager.stager') + ->setClass(Stager::class); + + $container->register('package_manager_bypass.committer') + ->setClass(Committer::class) + ->setPublic(FALSE) + ->setDecoratedService('package_manager.committer') + ->setArguments([ + new Reference('package_manager_bypass.committer.inner'), + ]) + ->setProperty('_serviceId', 'package_manager.committer'); } } diff --git a/package_manager/tests/src/Kernel/ExcludedPathsTest.php b/package_manager/tests/src/Kernel/ExcludedPathsTest.php index 776c0ff9a0..33dd8f5e16 100644 --- a/package_manager/tests/src/Kernel/ExcludedPathsTest.php +++ b/package_manager/tests/src/Kernel/ExcludedPathsTest.php @@ -26,18 +26,17 @@ class ExcludedPathsTest extends PackageManagerKernelTestBase { protected function setUp(): void { parent::setUp(); - // Normally, package_manager_bypass will disable all the actual staging - // operations. In this case, we want to perform them so that we can be sure - // that files are staged as expected. - $this->setSetting('package_manager_bypass_stager', FALSE); // The private stream wrapper is only registered if this setting is set. // @see \Drupal\Core\CoreServiceProvider::register() $this->setSetting('file_private_path', 'private'); - - // Rebuild the container to make the new settings take effect. - $kernel = $this->container->get('kernel'); - $kernel->rebuildContainer(); - $this->container = $kernel->getContainer(); + // In this test, we want to perform the actual staging operations so that we + // can be sure that files are staged as expected. This will also rebuild + // the container, enabling the private stream wrapper. + $this->container->get('module_installer')->uninstall([ + 'package_manager_bypass', + ]); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->getContainer(); // Mock a SQLite database connection so we can test that the subscriber will // exclude the database files. diff --git a/package_manager/tests/src/Kernel/StageOwnershipTest.php b/package_manager/tests/src/Kernel/StageOwnershipTest.php index 99339a9696..a5c76c7b7a 100644 --- a/package_manager/tests/src/Kernel/StageOwnershipTest.php +++ b/package_manager/tests/src/Kernel/StageOwnershipTest.php @@ -2,11 +2,16 @@ namespace Drupal\Tests\package_manager\Kernel; +use Drupal\Core\File\FileSystem; +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\package_manager\Event\PostDestroyEvent; use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\Exception\StageException; use Drupal\package_manager\Exception\StageOwnershipException; use Drupal\package_manager_test_validation\TestSubscriber; use Drupal\Tests\user\Traits\UserCreationTrait; +use Prophecy\Argument; +use Psr\Log\LoggerInterface; /** * Tests that ownership of the stage is enforced. @@ -32,6 +37,7 @@ class StageOwnershipTest extends PackageManagerKernelTestBase { protected function setUp(): void { parent::setUp(); $this->installSchema('system', ['sequences']); + $this->installSchema('user', ['users_data']); $this->installEntitySchema('user'); $this->registerPostUpdateFunctions(); } @@ -221,4 +227,70 @@ class StageOwnershipTest extends PackageManagerKernelTestBase { $not_owned->destroy(TRUE); } + /** + * Tests that the stage is available if ::destroy() has a file system error. + */ + public function testStageDestroyedWithFileSystemError(): void { + // Enable the Composer Stager library, since we will actually want to create + // the stage directory. + $this->container->get('module_installer')->uninstall([ + 'package_manager_bypass', + ]); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->getContainer(); + $this->createTestProject(); + + $logger_channel = $this->container->get('logger.channel.file'); + $arguments = [ + $this->container->get('stream_wrapper_manager'), + $this->container->get('settings'), + $logger_channel, + ]; + $this->container->set('file_system', new class (...$arguments) extends FileSystem { + + /** + * {@inheritdoc} + */ + public function chmod($uri, $mode = NULL) { + // Normally, the stage will call this method as it tries to make + // everything in the staging area writable so it can be deleted. We + // don't wan't to do that in this test, since we're specifically testing + // what happens when we try to delete a staging area with unwritable + // files. + } + + }); + + $stage = $this->createStage(); + $this->assertTrue($stage->isAvailable()); + $stage->create(); + $this->assertFalse($stage->isAvailable()); + + // Make the stage directory unwritable, which should prevent files in it + // from being deleted. + $dir = $stage->getStageDirectory(); + chmod($dir, 0400); + $this->assertDirectoryIsNotWritable($dir); + + // Mock a logger to prove that a file system error was raised while trying + // to delete the stage directory. + $logger = $this->prophesize(LoggerInterface::class); + $logger->log( + RfcLogLevel::ERROR, + "Failed to unlink file '%path'.", + Argument::withEntry('%path', "$dir/composer.json") + )->shouldBeCalled(); + $logger_channel->addLogger($logger->reveal()); + + // Listen to the post-destroy event so we can confirm that it was fired, and + // the stage was made available, despite the file system error. + $stage_available = NULL; + $this->container->get('event_dispatcher') + ->addListener(PostDestroyEvent::class, function (PostDestroyEvent $event) use (&$stage_available): void { + $stage_available = $event->getStage()->isAvailable(); + }); + $stage->destroy(); + $this->assertTrue($stage_available); + } + } -- GitLab