From 0ed875191aa538de4d08c5d08a2b3c4ab5e75615 Mon Sep 17 00:00:00 2001 From: tedbow <tedbow@240860.no-reply.drupal.org> Date: Wed, 3 Nov 2021 16:25:21 +0000 Subject: [PATCH] Issue #3246420 by phenaproxima, tedbow: Create a validator to ensure that active composer.lock file has not changed since the stage was created when applying updates --- package_manager/package_manager.services.yml | 8 + .../src/EventSubscriber/LockFileValidator.php | 145 ++++++++++++++ .../src/Kernel/DiskSpaceValidatorTest.php | 11 ++ .../src/Kernel/LockFileValidatorTest.php | 183 ++++++++++++++++++ .../Kernel/PackageManagerKernelTestBase.php | 10 +- .../WritableFileSystemValidatorTest.php | 6 +- .../StagedProjectsValidatorTest.php | 12 ++ tests/src/Kernel/UpdaterTest.php | 3 + 8 files changed, 374 insertions(+), 4 deletions(-) create mode 100644 package_manager/src/EventSubscriber/LockFileValidator.php create mode 100644 package_manager/tests/src/Kernel/LockFileValidatorTest.php diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml index 07241aab23..60a8d6d519 100644 --- a/package_manager/package_manager.services.yml +++ b/package_manager/package_manager.services.yml @@ -108,6 +108,14 @@ services: - '@string_translation' tags: - { name: event_subscriber } + package_manager.validator.lock_file: + class: Drupal\package_manager\EventSubscriber\LockFileValidator + arguments: + - '@state' + - '@package_manager.path_locator' + - '@string_translation' + tags: + - { name: event_subscriber } package_manager.validator.file_system: class: Drupal\package_manager\EventSubscriber\WritableFileSystemValidator arguments: diff --git a/package_manager/src/EventSubscriber/LockFileValidator.php b/package_manager/src/EventSubscriber/LockFileValidator.php new file mode 100644 index 0000000000..154e137a8c --- /dev/null +++ b/package_manager/src/EventSubscriber/LockFileValidator.php @@ -0,0 +1,145 @@ +<?php + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\package_manager\Event\PostDestroyEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; + +/** + * Checks that the active lock file is unchanged during stage operations. + */ +class LockFileValidator implements StageValidatorInterface { + + use StringTranslationTrait; + + /** + * The state key under which to store the hash of the active lock file. + * + * @var string + */ + protected const STATE_KEY = 'package_manager.lock_hash'; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + + /** + * Constructs a LockFileValidator object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The string translation service. + */ + public function __construct(StateInterface $state, PathLocator $path_locator, TranslationInterface $translation) { + $this->state = $state; + $this->pathLocator = $path_locator; + $this->setStringTranslation($translation); + } + + /** + * Returns the current hash of the active directory's lock file. + * + * @return string|false + * The hash of the active directory's lock file, or FALSE if the lock file + * does not exist. + */ + protected function getHash() { + $file = $this->pathLocator->getActiveDirectory() . DIRECTORY_SEPARATOR . 'composer.lock'; + // We want to directly hash the lock file itself, rather than look at its + // content-hash value, which is actually a hash of the relevant parts of + // composer.json. We're trying to verify that the actual installed packages + // have not changed; we don't care about the constraints in composer.json. + try { + return hash_file('sha256', $file); + } + catch (\Throwable $exception) { + return FALSE; + } + } + + /** + * Stores the current lock file hash. + */ + public function storeHash(PreCreateEvent $event): void { + $hash = $this->getHash(); + if ($hash) { + $this->state->set(static::STATE_KEY, $hash); + } + else { + $result = ValidationResult::createError([ + $this->t('Could not hash the active lock file.'), + ]); + $event->addValidationResult($result); + } + } + + /** + * {@inheritdoc} + */ + public function validateStage(StageEvent $event): void { + // Ensure we can get a current hash of the lock file. + $hash = $this->getHash(); + if (empty($hash)) { + $error = $this->t('Could not hash the active lock file.'); + } + + // Ensure we also have a stored hash of the lock file. + $stored_hash = $this->state->get(static::STATE_KEY); + if (empty($stored_hash)) { + $error = $this->t('Could not retrieve stored hash of the active lock file.'); + } + + // If we have both hashes, ensure they match. + if ($hash && $stored_hash && hash_equals($stored_hash, $hash) == FALSE) { + $error = $this->t('Stored lock file hash does not match the active lock file.'); + } + + // @todo Let the validation result carry all the relevant messages in + // https://www.drupal.org/i/3247479. + if (isset($error)) { + $result = ValidationResult::createError([$error]); + $event->addValidationResult($result); + } + } + + /** + * Deletes the stored lock file hash. + */ + public function deleteHash(): void { + $this->state->delete(static::STATE_KEY); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'storeHash', + PreRequireEvent::class => 'validateStage', + PreApplyEvent::class => 'validateStage', + PostDestroyEvent::class => 'deleteHash', + ]; + } + +} diff --git a/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php b/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php index 8e3a46c306..bb969e1a71 100644 --- a/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php +++ b/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php @@ -27,6 +27,17 @@ class DiskSpaceValidatorTest extends PackageManagerKernelTestBase { ->setClass(TestDiskSpaceValidator::class); } + /** + * {@inheritdoc} + */ + protected function disableValidators(ContainerBuilder $container): void { + parent::disableValidators($container); + + // Disable the lock file validator, since in this test we are validating an + // imaginary file system which doesn't have any lock files. + $container->removeDefinition('package_manager.validator.lock_file'); + } + /** * Data provider for ::testDiskSpaceValidation(). * diff --git a/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/package_manager/tests/src/Kernel/LockFileValidatorTest.php new file mode 100644 index 0000000000..298c103ad7 --- /dev/null +++ b/package_manager/tests/src/Kernel/LockFileValidatorTest.php @@ -0,0 +1,183 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\EventSubscriber\LockFileValidator; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use org\bovigo\vfs\vfsStream; + +/** + * @coversDefaultClass \Drupal\package_manager\EventSubscriber\LockFileValidator + * + * @group package_manager + */ +class LockFileValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $vendor = vfsStream::newDirectory('vendor'); + $this->vfsRoot->addChild($vendor); + $stage = vfsStream::newDirectory('stage'); + $this->vfsRoot->addChild($stage); + + $path_locator = $this->prophesize(PathLocator::class); + $path_locator->getActiveDirectory()->willReturn($this->vfsRoot->url()); + $path_locator->getProjectRoot()->willReturn($this->vfsRoot->url()); + $path_locator->getStageDirectory()->willReturn($stage->url()); + $path_locator->getWebRoot()->willReturn(''); + $path_locator->getVendorDirectory()->willReturn($vendor->url()); + $this->container->set('package_manager.path_locator', $path_locator->reveal()); + } + + /** + * {@inheritdoc} + */ + protected function disableValidators(ContainerBuilder $container): void { + parent::disableValidators($container); + + // Disable the disk space validator, since it tries to inspect the file + // system in ways that vfsStream doesn't support, like calling stat() and + // disk_free_space(). + $container->removeDefinition('package_manager.validator.disk_space'); + } + + /** + * Tests that if no active lock file exists, a stage cannot be created. + * + * @covers ::storeHash + */ + public function testCreateWithNoLock(): void { + $no_lock = ValidationResult::createError(['Could not hash the active lock file.']); + $this->assertResults([$no_lock], PreCreateEvent::class); + } + + /** + * Tests that if an active lock file exists, a stage can be created. + * + * @covers ::storeHash + * @covers ::deleteHash + */ + public function testCreateWithLock(): void { + $this->createActiveLockFile(); + $this->assertResults([]); + + // Change the lock file to ensure the stored hash of the previous version + // has been deleted. + $this->vfsRoot->getChild('composer.lock')->setContent('"changed"'); + $this->assertResults([]); + } + + /** + * Tests validation when the lock file has changed. + * + * @dataProvider providerValidateStageEvents + */ + public function testLockFileChanged(string $event_class): void { + $this->createActiveLockFile(); + + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener changes lock file before the validator + // runs. + $this->addListener($event_class, function () { + $this->vfsRoot->getChild('composer.lock')->setContent('"changed"'); + }); + $result = ValidationResult::createError([ + 'Stored lock file hash does not match the active lock file.', + ]); + $this->assertResults([$result], $event_class); + } + + /** + * Tests validation when the lock file is deleted. + * + * @dataProvider providerValidateStageEvents + */ + public function testLockFileDeleted(string $event_class): void { + $this->createActiveLockFile(); + + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener deletes lock file before the validator + // runs. + $this->addListener($event_class, function () { + $this->vfsRoot->removeChild('composer.lock'); + }); + $result = ValidationResult::createError([ + 'Could not hash the active lock file.', + ]); + $this->assertResults([$result], $event_class); + } + + /** + * Tests validation when a stored hash of the active lock file is unavailable. + * + * @dataProvider providerValidateStageEvents + */ + public function testNoStoredHash(string $event_class): void { + $this->createActiveLockFile(); + + $reflector = new \ReflectionClassConstant(LockFileValidator::class, 'STATE_KEY'); + $state_key = $reflector->getValue(); + + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener deletes stored hash before the validator + // runs. + $this->addListener($event_class, function () use ($state_key) { + $this->container->get('state')->delete($state_key); + }); + $result = ValidationResult::createError([ + 'Could not retrieve stored hash of the active lock file.', + ]); + $this->assertResults([$result], $event_class); + } + + /** + * Data provider for test methods that validate the staging area. + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerValidateStageEvents(): array { + return [ + 'pre-require' => [ + PreRequireEvent::class, + ], + 'pre-apply' => [ + PreApplyEvent::class, + ], + ]; + } + + /** + * Creates a 'composer.lock' file in the active directory. + */ + private function createActiveLockFile(): void { + $lock_file = vfsStream::newFile('composer.lock')->setContent('{}'); + $this->vfsRoot->addChild($lock_file); + } + + /** + * Adds an event listener with the highest possible priority. + * + * @param string $event_class + * The event class to listen for. + * @param callable $listener + * The listener to add. + */ + private function addListener(string $event_class, callable $listener): void { + $this->container->get('event_dispatcher') + ->addListener($event_class, $listener, PHP_INT_MAX); + } + +} diff --git a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php index 7e5fdfdf87..431220461f 100644 --- a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -50,10 +50,11 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { * * @param \Drupal\package_manager\ValidationResult[] $expected_results * The expected validation results. - * @param string $event_class - * The class of the event which should return the results. + * @param string|null $event_class + * (optional) The class of the event which should return the results. Must + * be passed if $expected_results is not empty. */ - protected function assertResults(array $expected_results, string $event_class): void { + protected function assertResults(array $expected_results, string $event_class = NULL): void { $stage = new TestStage( $this->container->get('package_manager.path_locator'), $this->container->get('package_manager.beginner'), @@ -62,6 +63,7 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { $this->container->get('package_manager.cleaner'), $this->container->get('event_dispatcher'), ); + try { $stage->create(); $stage->require(['drupal/core:9.8.1']); @@ -72,9 +74,11 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { $this->assertEmpty($expected_results); } catch (StageException $e) { + $this->assertNotEmpty($expected_results); $this->assertValidationResultsEqual($expected_results, $e->getResults()); // TestStage::dispatch() attaches the event object to the exception so // that we can analyze it. + $this->assertNotEmpty($event_class); $this->assertInstanceOf($event_class, $e->event); } } diff --git a/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php b/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php index 9ba1284b2d..0b2398d6a1 100644 --- a/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php +++ b/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php @@ -43,6 +43,10 @@ class WritableFileSystemValidatorTest extends PackageManagerKernelTestBase { // system in ways that vfsStream doesn't support, like calling stat() and // disk_free_space(). $container->removeDefinition('package_manager.validator.disk_space'); + + // Disable the lock file validator, since the mock file system we create in + // this test doesn't have any lock files to validate. + $container->removeDefinition('package_manager.validator.lock_file'); } /** @@ -112,7 +116,7 @@ class WritableFileSystemValidatorTest extends PackageManagerKernelTestBase { $path_locator->getVendorDirectory()->willReturn($vendor->url()); $this->container->set('package_manager.path_locator', $path_locator->reveal()); - /** @var \Drupal\Tests\package_manager\Kernel\TestValidator $validator */ + /** @var \Drupal\Tests\package_manager\Kernel\TestWritableFileSystemValidator $validator */ $validator = $this->container->get('package_manager.validator.file_system'); $validator->appRoot = $root->url(); diff --git a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php index 3bb3721f94..82e0feed52 100644 --- a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php +++ b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation; +use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\PathLocator; use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase; @@ -21,6 +22,17 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase { 'package_manager', ]; + /** + * {@inheritdoc} + */ + protected function disableValidators(ContainerBuilder $container): void { + parent::disableValidators($container); + + // This test deals with fake sites that don't necessarily have lock files, + // so disable lock file validation. + $container->removeDefinition('package_manager.validator.lock_file'); + } + /** * Runs the validator under test against an arbitrary pair of directories. * diff --git a/tests/src/Kernel/UpdaterTest.php b/tests/src/Kernel/UpdaterTest.php index add52e11db..541e16e354 100644 --- a/tests/src/Kernel/UpdaterTest.php +++ b/tests/src/Kernel/UpdaterTest.php @@ -48,6 +48,9 @@ class UpdaterTest extends AutomaticUpdatesKernelTestBase { $kernel = $this->container->get('kernel'); $kernel->rebuildContainer(); $this->container = $kernel->getContainer(); + // Keep using the mocked path locator. + $this->container->set('package_manager.path_locator', $locator->reveal()); + // When we call Updater::stage(), the stored project versions should be // read from state and passed to Composer Stager's Stager service, in the // form of a Composer command. We set up a mock here to ensure that those -- GitLab