diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index b86c6c02771ed31aee16ccebf285d6f790606d23..cdd5e311cba2caa025fc31357b03e1c0cf3c8662 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -1597,6 +1597,7 @@ untrusted unvalidated unversioned unwrapper +unwritable upcasted upcasting updateprogress diff --git a/core/modules/auto_updates/tests/src/Functional/FileSystemOperationsTest.php b/core/modules/auto_updates/tests/src/Functional/FileSystemOperationsTest.php deleted file mode 100644 index dcf116bd827b8fcbee21792a1e0cc0052992cab4..0000000000000000000000000000000000000000 --- a/core/modules/auto_updates/tests/src/Functional/FileSystemOperationsTest.php +++ /dev/null @@ -1,148 +0,0 @@ -<?php - -namespace Drupal\Tests\auto_updates\Functional; - -use Drupal\auto_updates\Updater; -use Drupal\Core\Site\Settings; -use Drupal\package_manager\PathLocator; - -/** - * Tests handling of files and directories during an update. - * - * @group auto_updates - */ -class FileSystemOperationsTest extends AutoUpdatesFunctionalTestBase { - - /** - * {@inheritdoc} - */ - protected static $modules = ['auto_updates_test']; - - /** - * {@inheritdoc} - */ - protected $defaultTheme = 'stark'; - - /** - * The updater service under test. - * - * @var \Drupal\Tests\auto_updates\Functional\TestUpdater - */ - private $updater; - - /** - * The full path of the staging directory. - * - * @var string - */ - protected $stageDir; - - /** - * {@inheritdoc} - */ - protected function setUp(): void { - parent::setUp(); - - // Create a mocked path locator that uses the fake site fixture as its - // active directory, and has a staging area within the site directory for - // this test. - $drupal_root = $this->getDrupalRoot(); - /** @var \Drupal\package_manager\PathLocator|\Prophecy\Prophecy\ObjectProphecy $locator */ - $locator = $this->prophesize(PathLocator::class); - $locator->getActiveDirectory()->willReturn(__DIR__ . '/../../fixtures/fake-site'); - $this->stageDir = implode(DIRECTORY_SEPARATOR, [ - $drupal_root, - $this->siteDirectory, - 'stage', - ]); - $locator->getProjectRoot()->willReturn($drupal_root); - $locator->getWebRoot()->willReturn(''); - - $this->updater = new TestUpdater( - $locator->reveal(), - $this->container->get('package_manager.beginner'), - $this->container->get('package_manager.stager'), - $this->container->get('package_manager.committer'), - $this->container->get('file_system'), - $this->container->get('event_dispatcher'), - $this->container->get('tempstore.shared') - ); - $this->updater::$stagingRoot = $this->stageDir; - - // Use the public and private files directories in the fake site fixture. - $settings = Settings::getAll(); - $settings['file_public_path'] = 'files/public'; - $settings['file_private_path'] = 'files/private'; - new Settings($settings); - - // Updater::begin() will trigger update validators, such as - // \Drupal\auto_updates\Validator\UpdateVersionValidator, that need to - // fetch release metadata. We need to ensure that those HTTP request(s) - // succeed, so set them up to point to our fake release metadata. - $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml'); - $this->setCoreVersion('9.8.0'); - - $this->drupalLogin($this->rootUser); - $this->checkForUpdates(); - $this->drupalLogout(); - } - - /** - * Tests that certain files and directories are not staged. - * - * @covers \Drupal\auto_updates\Updater::getExclusions - */ - public function testExclusions(): void { - $stage_id = $this->updater->begin(['drupal' => '9.8.1']); - $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/settings.php"); - $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/settings.local.php"); - $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/services.yml"); - // A file in sites/default, that isn't one of the site-specific settings - // files, should be staged. - $this->assertFileExists("$this->stageDir/$stage_id/sites/default/staged.txt"); - $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/sites/simpletest"); - $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/files/public"); - $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/files/private"); - // A file that's in the general files directory, but not in the public or - // private directories, should be staged. - $this->assertFileExists("$this->stageDir/$stage_id/files/staged.txt"); - } - - /** - * Tests that the staging directory is properly cleaned up. - * - * @covers \Drupal\auto_updates\Cleaner - */ - public function testClean(): void { - $stage_id = $this->updater->begin(['drupal' => '9.8.1']); - // Make the staged site directory read-only, so we can test that it will be - // made writable on clean-up. - $this->assertTrue(chmod("$this->stageDir/$stage_id/sites/default", 0400)); - $this->assertIsNotWritable("$this->stageDir/$stage_id/sites/default/staged.txt"); - // If the site directory is not writable, this will throw an exception. - $this->updater->destroy(); - $this->assertDirectoryDoesNotExist($this->stageDir); - } - -} - -/** - * A test-only version of the updater. - */ -class TestUpdater extends Updater { - - /** - * The directory where staging areas will be created. - * - * @var string - */ - public static $stagingRoot; - - /** - * {@inheritdoc} - */ - protected static function getStagingRoot(): string { - return static::$stagingRoot ?: parent::getStagingRoot(); - } - -} diff --git a/core/modules/package_manager/src/Stage.php b/core/modules/package_manager/src/Stage.php index 6d4678383085849e26e49e7c0fb4d27c2beb4545..711da3a019d7204793ef8712db41ac73e5f1a488 100644 --- a/core/modules/package_manager/src/Stage.php +++ b/core/modules/package_manager/src/Stage.php @@ -4,6 +4,7 @@ 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 @@ public function destroy(bool $force = FALSE): void { // 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/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php index 3394cf4e159b39d300273529e119dc94929346c3..3f7bf47d387368d0b1fbd280f46d62929dfd35ec 100644 --- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php @@ -4,7 +4,6 @@ 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/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php index 776c0ff9a0b1737a4c9d78f5ebcbd3528ea682ec..33dd8f5e169025836ddd8bd21efcb2c9fbfc5428 100644 --- a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php +++ b/core/modules/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/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php b/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php index 99339a969614da6e496a947e2f282abb6ba5779b..a5c76c7b7af7abe78f3865dd6915770b29ae99a6 100644 --- a/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php +++ b/core/modules/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 @@ public function testForceDestroy(): void { $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); + } + }