From 77a916450713e1dee495668e21cf4ffff162dfa6 Mon Sep 17 00:00:00 2001 From: tedbow <tedbow@240860.no-reply.drupal.org> Date: Fri, 10 Dec 2021 20:46:46 +0000 Subject: [PATCH] Issue #3252126 by tedbow, phenaproxima: Do not apply updates during cron if there are DB updates in the staging area --- automatic_updates.module | 4 +- automatic_updates.services.yml | 20 ++ src/CronUpdater.php | 38 +--- .../StagedDatabaseUpdateValidator.php | 160 ++++++++++++++ tests/src/Kernel/CronUpdaterTest.php | 28 +-- .../StagedDatabaseUpdateValidatorTest.php | 205 ++++++++++++++++++ 6 files changed, 409 insertions(+), 46 deletions(-) create mode 100644 src/Validator/StagedDatabaseUpdateValidator.php create mode 100644 tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php diff --git a/automatic_updates.module b/automatic_updates.module index ba246224e3..7ed25a0bd8 100644 --- a/automatic_updates.module +++ b/automatic_updates.module @@ -73,9 +73,7 @@ function automatic_updates_module_implements_alter(&$implementations, $hook) { * Implements hook_cron(). */ function automatic_updates_cron() { - /** @var \Drupal\automatic_updates\CronUpdater $cron_updater */ - $cron_updater = \Drupal::classResolver(CronUpdater::class); - $cron_updater->handleCron(); + \Drupal::service('automatic_updates.cron_updater')->handleCron(); /** @var \Drupal\automatic_updates\Validation\ReadinessValidationManager $checker_manager */ $checker_manager = \Drupal::service('automatic_updates.readiness_validation_manager'); diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index a4cc7ecd94..d5b1c8c5a4 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -18,6 +18,18 @@ services: - '@file_system' - '@event_dispatcher' - '@tempstore.shared' + automatic_updates.cron_updater: + class: Drupal\automatic_updates\CronUpdater + arguments: + - '@config.factory' + - '@logger.factory' + - '@package_manager.path_locator' + - '@package_manager.beginner' + - '@package_manager.stager' + - '@package_manager.committer' + - '@file_system' + - '@event_dispatcher' + - '@tempstore.shared' automatic_updates.excluded_paths_subscriber: class: Drupal\automatic_updates\Event\ExcludedPathsSubscriber arguments: @@ -86,3 +98,11 @@ services: - '@string_translation' tags: - { name: event_subscriber } + automatic_updates.validator.staged_database_updates: + class: Drupal\automatic_updates\Validator\StagedDatabaseUpdateValidator + arguments: + - '@package_manager.path_locator' + - '@extension.list.module' + - '@string_translation' + tags: + - { name: event_subscriber } diff --git a/src/CronUpdater.php b/src/CronUpdater.php index f989c58556..d6ca031824 100644 --- a/src/CronUpdater.php +++ b/src/CronUpdater.php @@ -3,9 +3,7 @@ namespace Drupal\automatic_updates; use Drupal\Core\Config\ConfigFactoryInterface; -use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; -use Symfony\Component\DependencyInjection\ContainerInterface; /** * Defines a service that updates via cron. @@ -14,7 +12,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * This class implements logic specific to Automatic Updates' cron hook * implementation. It should not be called directly. */ -class CronUpdater implements ContainerInjectionInterface { +class CronUpdater extends Updater { /** * All automatic updates are disabled. @@ -37,13 +35,6 @@ class CronUpdater implements ContainerInjectionInterface { */ public const ALL = 'patch'; - /** - * The updater service. - * - * @var \Drupal\automatic_updates\Updater - */ - protected $updater; - /** * The config factory service. * @@ -61,30 +52,19 @@ class CronUpdater implements ContainerInjectionInterface { /** * Constructs a CronUpdater object. * - * @param \Drupal\automatic_updates\Updater $updater - * The updater service. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The config factory service. * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory * The logger channel factory. + * @param mixed ...$arguments + * Additional arguments to pass to the parent constructor. */ - public function __construct(Updater $updater, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory) { - $this->updater = $updater; + public function __construct(ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, ...$arguments) { + parent::__construct(...$arguments); $this->configFactory = $config_factory; $this->logger = $logger_factory->get('automatic_updates'); } - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container) { - return new static( - $container->get('automatic_updates.updater'), - $container->get('config.factory'), - $container->get('logger.factory') - ); - } - /** * Handles updates during cron. */ @@ -127,12 +107,12 @@ class CronUpdater implements ContainerInjectionInterface { // cron runs. $recommended_version = $recommended_release->getVersion(); try { - $this->updater->begin([ + $this->begin([ 'drupal' => $recommended_version, ]); - $this->updater->stage(); - $this->updater->apply(); - $this->updater->destroy(); + $this->stage(); + $this->apply(); + $this->destroy(); } catch (\Throwable $e) { $this->logger->error($e->getMessage()); diff --git a/src/Validator/StagedDatabaseUpdateValidator.php b/src/Validator/StagedDatabaseUpdateValidator.php new file mode 100644 index 0000000000..0fea7e697c --- /dev/null +++ b/src/Validator/StagedDatabaseUpdateValidator.php @@ -0,0 +1,160 @@ +<?php + +namespace Drupal\automatic_updates\Validator; + +use Drupal\automatic_updates\CronUpdater; +use Drupal\Core\Extension\Extension; +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that there are no database updates in a staged update. + */ +class StagedDatabaseUpdateValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + + /** + * The module list service. + * + * @var \Drupal\Core\Extension\ModuleExtensionList + */ + protected $moduleList; + + /** + * Constructs a StagedDatabaseUpdateValidator object. + * + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param \Drupal\Core\Extension\ModuleExtensionList $module_list + * The module list service. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The string translation service. + */ + public function __construct(PathLocator $path_locator, ModuleExtensionList $module_list, TranslationInterface $translation) { + $this->pathLocator = $path_locator; + $this->moduleList = $module_list; + $this->setStringTranslation($translation); + } + + /** + * Checks that the staged update does not have changes to its install files. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event object. + */ + public function checkUpdateHooks(PreApplyEvent $event): void { + $stage = $event->getStage(); + if (!$stage instanceof CronUpdater) { + return; + } + + $active_dir = $this->pathLocator->getActiveDirectory(); + $stage_dir = $stage->getStageDirectory(); + + $web_root = $this->pathLocator->getWebRoot(); + if ($web_root) { + $active_dir .= DIRECTORY_SEPARATOR . $web_root; + $stage_dir .= DIRECTORY_SEPARATOR . $web_root; + } + + $invalid_modules = []; + // Although \Drupal\automatic_updates\Validator\StagedProjectsValidator + // should prevent non-core modules from being added, updated, or removed in + // the staging area, we check all installed modules so as not to rely on the + // presence of StagedProjectsValidator. + foreach ($this->moduleList->getAllInstalledInfo() as $name => $info) { + if ($this->hasStagedUpdates($active_dir, $stage_dir, $this->moduleList->get($name))) { + $invalid_modules[] = $info['name']; + } + } + + if ($invalid_modules) { + $event->addError($invalid_modules, $this->t('The update cannot proceed because possible database updates have been detected in the following modules.')); + } + } + + /** + * Determines if a staged extension has changed update functions. + * + * @param string $active_dir + * The path of the running Drupal code base. + * @param string $stage_dir + * The path of the staging area. + * @param \Drupal\Core\Extension\Extension $extension + * The extension to check. + * + * @return bool + * TRUE if the staged copy of the extension has changed update functions + * compared to the active copy, FALSE otherwise. + * + * @todo Use a more sophisticated method to detect changes in the staged + * extension. Right now, we just compare hashes of the .install and + * .post_update.php files in both copies of the given extension, but this + * will cause false positives for changes to comments, whitespace, or + * runtime code like requirements checks. It would be preferable to use a + * static analyzer to detect new or changed functions that are actually + * executed during an update. No matter what, this method must NEVER cause + * false negatives, since that could result in code which is incompatible + * with the current database schema being copied to the active directory. + * + * @see https://www.drupal.org/project/automatic_updates/issues/3253828 + */ + protected function hasStagedUpdates(string $active_dir, string $stage_dir, Extension $extension): bool { + $active_hashes = $this->getHashes($active_dir, $extension); + $staged_hashes = $this->getHashes($stage_dir, $extension); + + return $active_hashes !== $staged_hashes; + } + + /** + * Returns hashes of the .install and .post-update.php files for a module. + * + * @param string $root_dir + * The root directory of the Drupal code base. + * @param \Drupal\Core\Extension\Extension $module + * The module to check. + * + * @return string[] + * The hashes of the module's .install and .post_update.php files, in that + * order, if they exist. The array will be keyed by file extension. + */ + protected function getHashes(string $root_dir, Extension $module): array { + $path = implode(DIRECTORY_SEPARATOR, [ + $root_dir, + $module->getPath(), + $module->getName(), + ]); + $hashes = []; + + foreach (['.install', '.post_update.php'] as $suffix) { + $file = $path . $suffix; + + if (file_exists($file)) { + $hashes[$suffix] = hash_file('sha256', $file); + } + } + return $hashes; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreApplyEvent::class => 'checkUpdateHooks', + ]; + } + +} diff --git a/tests/src/Kernel/CronUpdaterTest.php b/tests/src/Kernel/CronUpdaterTest.php index 78dc4d7d13..413e42fbfe 100644 --- a/tests/src/Kernel/CronUpdaterTest.php +++ b/tests/src/Kernel/CronUpdaterTest.php @@ -4,8 +4,8 @@ namespace Drupal\Tests\automatic_updates\Kernel; use Drupal\automatic_updates\CronUpdater; use Drupal\Core\Form\FormState; -use Drupal\package_manager\ComposerUtility; use Drupal\update\UpdateSettingsForm; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * @covers \Drupal\automatic_updates\CronUpdater @@ -21,6 +21,7 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase { protected static $modules = [ 'automatic_updates', 'package_manager', + 'package_manager_bypass', ]; /** @@ -97,21 +98,20 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase { $form_state->setValue('automatic_updates_cron', $setting); $form_builder->submitForm(UpdateSettingsForm::class, $form_state); - // Mock the updater so we can assert that its methods are called or bypassed - // depending on configuration. - $will_update = (int) $will_update; - $updater = $this->prophesize('\Drupal\automatic_updates\Updater'); - - $composer = ComposerUtility::createForDirectory(__DIR__ . '/../../fixtures/fake-site'); - $updater->getActiveComposer()->willReturn($composer); - - $updater->begin(['drupal' => '9.8.1'])->shouldBeCalledTimes($will_update); - $updater->stage()->shouldBeCalledTimes($will_update); - $updater->apply()->shouldBeCalledTimes($will_update); - $updater->destroy()->shouldBeCalledTimes($will_update); - $this->container->set('automatic_updates.updater', $updater->reveal()); + // Since we're just trying to ensure that all of Package Manager's services + // are called as expected, disable validation by replacing the event + // dispatcher with a dummy version. + $event_dispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->container->set('event_dispatcher', $event_dispatcher->reveal()); + // Run cron and ensure that Package Manager's services were called or + // bypassed depending on configuration. $this->container->get('cron')->run(); + + $will_update = (int) $will_update; + $this->assertCount($will_update, $this->container->get('package_manager.beginner')->getInvocationArguments()); + $this->assertCount($will_update, $this->container->get('package_manager.stager')->getInvocationArguments()); + $this->assertCount($will_update, $this->container->get('package_manager.committer')->getInvocationArguments()); } } diff --git a/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php b/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php new file mode 100644 index 0000000000..5b0ce6962f --- /dev/null +++ b/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php @@ -0,0 +1,205 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation; + +use Drupal\automatic_updates\CronUpdater; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Exception\StageValidationException; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase; + +/** + * @covers \Drupal\automatic_updates\Validator\StagedDatabaseUpdateValidator + * + * @group automatic_updates + */ +class StagedDatabaseUpdateValidatorTest extends AutomaticUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'automatic_updates', + 'package_manager', + 'package_manager_bypass', + ]; + + /** + * The suffixes of the files that can contain database updates. + * + * @var string[] + */ + private const SUFFIXES = ['install', 'post_update.php']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + TestCronUpdater::$stagingRoot = $this->vfsRoot->url(); + + /** @var \Drupal\Tests\automatic_updates\Kernel\ReadinessValidation\TestCronUpdater $updater */ + $updater = $this->container->get('automatic_updates.cron_updater'); + $updater->begin(['drupal' => '9.8.1']); + $updater->stage(); + + $stage_dir = $updater->getStageDirectory(); + mkdir($stage_dir); + + // To satisfy StagedProjectsValidator, copy the active Composer files into + // the staging area. + $active_dir = $this->getDrupalRoot(); + @copy("$active_dir/composer.json", "$stage_dir/composer.json"); + @copy("$active_dir/composer.lock", "$stage_dir/composer.lock"); + + // Copy the .install and .post_update.php files from every installed module + // into the staging directory. + $module_list = $this->container->get('module_handler')->getModuleList(); + foreach ($module_list as $name => $module) { + $path = $module->getPath(); + mkdir("$stage_dir/$path", 0777, TRUE); + + foreach (static::SUFFIXES as $suffix) { + // If the source file doesn't exist, silence the warning it raises. + @copy("$active_dir/$path/$name.$suffix", "$stage_dir/$path/$name.$suffix"); + } + } + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + $container->getDefinition('automatic_updates.cron_updater') + ->setClass(TestCronUpdater::class); + } + + /** + * Tests that no errors are raised if staged files have no DB updates. + */ + public function testNoUpdates(): void { + // Since we're testing with a modified version of Views, it should not be + // installed. + $this->assertFalse($this->container->get('module_handler')->moduleExists('views')); + + // Create bogus staged versions of Views' .install and .post_update.php + // files. Since it's not installed, the changes should not raise any + // validation errors. + $updater = $this->container->get('automatic_updates.cron_updater'); + $module_dir = $updater->getStageDirectory() . '/core/modules/views'; + mkdir($module_dir, 0777, TRUE); + foreach (static::SUFFIXES as $suffix) { + file_put_contents("$module_dir/views.$suffix", $this->randomString()); + } + + $updater->apply(); + } + + /** + * Data provider for ::testFileChanged(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerFileChanged(): array { + $scenarios = []; + foreach (static::SUFFIXES as $suffix) { + $scenarios[] = [$suffix, FALSE]; + $scenarios[] = [$suffix, TRUE]; + } + return $scenarios; + } + + /** + * Tests that an error is raised if install or post-update files are changed. + * + * @param string $suffix + * The suffix of the file to change. Can be either 'install' or + * 'post_update.php'. + * @param bool $delete + * Whether or not to delete the file. + * + * @dataProvider providerFileChanged + */ + public function testFileChanged(string $suffix, bool $delete): void { + /** @var \Drupal\Tests\automatic_updates\Kernel\ReadinessValidation\TestCronUpdater $updater */ + $updater = $this->container->get('automatic_updates.cron_updater'); + + $file = $updater->getStageDirectory() . "/core/modules/system/system.$suffix"; + if ($delete) { + unlink($file); + } + else { + file_put_contents($file, $this->randomString()); + } + + $expected_results = [ + ValidationResult::createError(['System'], t('The update cannot proceed because possible database updates have been detected in the following modules.')), + ]; + + try { + $updater->apply(); + $this->fail('Expected a validation error.'); + } + catch (StageValidationException $e) { + $this->assertValidationResultsEqual($expected_results, $e->getResults()); + } + } + + /** + * Tests that an error is raised if install or post-update files are added. + */ + public function testUpdatesAddedInStage(): void { + $module = $this->container->get('module_handler') + ->getModule('package_manager_bypass'); + + /** @var \Drupal\Tests\automatic_updates\Kernel\ReadinessValidation\TestCronUpdater $updater */ + $updater = $this->container->get('automatic_updates.cron_updater'); + + foreach (static::SUFFIXES as $suffix) { + $file = sprintf('%s/%s/%s.%s', $updater->getStageDirectory(), $module->getPath(), $module->getName(), $suffix); + // The file we're creating shouldn't already exist in the staging area + // unless it's a file we actually ship, which is a scenario covered by + // ::testFileChanged(). + $this->assertFileNotExists($file); + file_put_contents($file, $this->randomString()); + } + + $expected_results = [ + ValidationResult::createError(['Package Manager Bypass'], t('The update cannot proceed because possible database updates have been detected in the following modules.')), + ]; + + try { + $updater->apply(); + $this->fail('Expected a validation error.'); + } + catch (StageValidationException $e) { + $this->assertValidationResultsEqual($expected_results, $e->getResults()); + } + } + +} + +/** + * A test-only version of the cron updater. + */ +class TestCronUpdater extends CronUpdater { + + /** + * The directory where staging areas will be created. + * + * @var string + */ + public static $stagingRoot; + + /** + * {@inheritdoc} + */ + protected static function getStagingRoot(): string { + return static::$stagingRoot ?: parent::getStagingRoot(); + } + +} -- GitLab