From 9bfc8acef93018df32e78842854da99532dcc086 Mon Sep 17 00:00:00 2001 From: Wim Leers <44946-wimleers@users.noreply.drupalcode.org> Date: Mon, 6 Feb 2023 19:56:25 +0000 Subject: [PATCH] Issue #3331168 by Wim Leers, tedbow: Limit trusted Composer plugins to a known list, allow user to add more --- .../install/package_manager.settings.yml | 1 + .../config/schema/package_manager.schema.yml | 14 + package_manager/package_manager.api.php | 8 + package_manager/package_manager.install | 11 + package_manager/package_manager.module | 7 + package_manager/package_manager.services.yml | 8 + .../src/Event/PreOperationStageEvent.php | 2 +- .../Validator/ComposerPluginsValidator.php | 227 ++++++++++++ .../src/FixtureManipulator.php | 47 ++- .../Kernel/ComposerPluginsValidatorTest.php | 331 ++++++++++++++++++ tests/src/Functional/UpdatePathTest.php | 4 + 11 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 package_manager/src/Validator/ComposerPluginsValidator.php create mode 100644 package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php diff --git a/package_manager/config/install/package_manager.settings.yml b/package_manager/config/install/package_manager.settings.yml index bbe571a9cb..49b030016d 100644 --- a/package_manager/config/install/package_manager.settings.yml +++ b/package_manager/config/install/package_manager.settings.yml @@ -2,3 +2,4 @@ file_syncer: php executables: composer: ~ rsync: ~ +additional_trusted_composer_plugins: [] diff --git a/package_manager/config/schema/package_manager.schema.yml b/package_manager/config/schema/package_manager.schema.yml index 1d02937177..618bb4c26c 100644 --- a/package_manager/config/schema/package_manager.schema.yml +++ b/package_manager/config/schema/package_manager.schema.yml @@ -1,3 +1,11 @@ +package_name: + type: string + label: 'Package name' + constraints: + Regex: + # @see https://getcomposer.org/schema.json + pattern: '/^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$/' + package_manager.settings: type: config_object label: 'Package Manager settings' @@ -11,3 +19,9 @@ package_manager.settings: sequence: type: string label: 'Absolute path to executable, or NULL' + additional_trusted_composer_plugins: + type: sequence + label: 'Additional trusted composer plugins' + sequence: + type: package_name + label: 'Trusted composer plugin' diff --git a/package_manager/package_manager.api.php b/package_manager/package_manager.api.php index 12d02c7dd4..83feb8d1e3 100644 --- a/package_manager/package_manager.api.php +++ b/package_manager/package_manager.api.php @@ -135,6 +135,14 @@ * package) in the active directory after a stage directory has been created * ,Package Manager will refuse to make any further changes to the stage * directory or apply the staged changes to the active directory. + * - Composer plugins are able to perform arbitrary file system operations, and + * hence could perform actions that make it impossible for Package Manager to + * guarantee the Drupal site will continue to work correctly. For that reason, + * Package Manager will refuse to make any further changes if untrusted + * composer plugins are installed or staged. Additional composer plugins are + * vetted over time. If you know what you are doing, it is possible to trust + * additional composer plugins by modifying package_manager.settings's + * "additional_trusted_composer_plugins" setting. * - The Drupal site must not have any pending database updates. * - Composer must use HTTPS to download packages and metadata (i.e., Composer's * secure-http configuration option must be enabled). This is the default diff --git a/package_manager/package_manager.install b/package_manager/package_manager.install index 7ae42818d1..fa864d70da 100644 --- a/package_manager/package_manager.install +++ b/package_manager/package_manager.install @@ -41,3 +41,14 @@ function package_manager_requirements(string $phase) { } return $requirements; } + +/** + * Sets default value for new additional_trusted_composer_plugins configuration. + * + * @see https://www.drupal.org/project/automatic_updates/issues/3331168 + */ +function package_manager_update_9001(): void { + /** @var \Drupal\Core\Config\Config $config */ + $config = \Drupal::service('config.factory')->getEditable('package_manager.settings'); + $config->set('additional_trusted_composer_plugins', [])->save(); +} diff --git a/package_manager/package_manager.module b/package_manager/package_manager.module index 880d39a59b..889f3e2966 100644 --- a/package_manager/package_manager.module +++ b/package_manager/package_manager.module @@ -33,6 +33,7 @@ function package_manager_help($route_name, RouteMatchInterface $route_match) { $output .= '<ul>'; $output .= ' <li>' . t('It does not support Drupal multi-site installations.') . '</li>'; $output .= ' <li>' . t('It does not support symlinks. If you have any, see <a href="#package-manager-faq-composer-not-found">What if it says I have symlinks in my codebase?</a>.') . '</li>'; + $output .= ' <li>' . t('It only allows supported composer plugins. If you have any, see <a href="#package-manager-faq-unsupported-composer-plugin">What if it says I have unsupported composer plugins in my codebase?</a>.') . '</li>'; $output .= ' <li>' . t('It does not automatically perform version control operations, e.g., with Git. Site administrators are responsible for committing updates.') . '</li>'; $output .= ' <li>' . t('It can only maintain one copy of the site at any given time. If a copy of the site already exists, another one cannot be created until the existing copy is destroyed.') . '</li>'; $output .= ' <li>' . t('It associates the temporary copy of the site with the user or session that originally created it, and only that user or session can make changes to it.') . '</li>'; @@ -80,6 +81,12 @@ function package_manager_help($route_name, RouteMatchInterface $route_match) { $output .= '<p>' . t('The new configuration will take effect on the next Composer install or update event. Do this to apply it immediately:') . '</p>'; $output .= '<pre><code>composer install</code></pre>'; + $output .= '<h4 id="package-manager-faq-unsupported-composer-plugin">' . t('What if it says I have unsupported composer plugins in my codebase?') . '</h4>'; + $output .= '<p>' . t('A fresh Drupal installation only uses supported composer plugins, but some modules or themes may depend on additional composer plugins. Please <a href=":new-issue">create a new issue</a> when you encounter this.', [ + ':new-issue' => 'https://www.drupal.org/node/add/project-issue/automatic_updates', + ]) . '</p>'; + $output .= '<p>' . t('It is possible to <em>trust</em> additional composer plugins, but this requires significant expertise: understanding the code of that composer plugin, what the effects on the file system are and how it affects the Package Manager module. Some composer plugins could result in a broken site!') . '</p>'; + $output .= '<h5>' . t('Custom code') . '</h5>'; $output .= '<p>' . t('Symlinks are seldom truly necessary and should be avoided in your own code. No solution currently exists to get around them--they must be removed in order to use Automatic Updates.') . '</p>'; diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml index b6ba0accff..f4cf9785d4 100644 --- a/package_manager/package_manager.services.yml +++ b/package_manager/package_manager.services.yml @@ -217,6 +217,14 @@ services: - '@string_translation' tags: - { name: event_subscriber } + package_manager.validator.composer_plugins: + class: Drupal\package_manager\Validator\ComposerPluginsValidator + arguments: + - '@config.factory' + - '@package_manager.composer_inspector' + - '@package_manager.path_locator' + tags: + - { name: event_subscriber } package_manager.validator.patches: class: Drupal\package_manager\Validator\ComposerPatchesValidator tags: diff --git a/package_manager/src/Event/PreOperationStageEvent.php b/package_manager/src/Event/PreOperationStageEvent.php index c493c2a44a..b5c9a51545 100644 --- a/package_manager/src/Event/PreOperationStageEvent.php +++ b/package_manager/src/Event/PreOperationStageEvent.php @@ -49,7 +49,7 @@ abstract class PreOperationStageEvent extends StageEvent { * is more than one message. */ public function addError(array $messages, ?TranslatableMarkup $summary = NULL): void { - $this->results[] = ValidationResult::createError($messages, $summary); + $this->results[] = ValidationResult::createError(array_values($messages), $summary); } /** diff --git a/package_manager/src/Validator/ComposerPluginsValidator.php b/package_manager/src/Validator/ComposerPluginsValidator.php new file mode 100644 index 0000000000..aa2ab693d5 --- /dev/null +++ b/package_manager/src/Validator/ComposerPluginsValidator.php @@ -0,0 +1,227 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager\Validator; + +use Composer\Package\Package; +use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\PathExcluder\VendorHardeningExcluder; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates the allowed composer plugins, both in active and stage. + * + * Composer plugins can make far-reaching changes on the filesystem. That is why + * they can cause Package Manager (more specifically the infrastructure it uses: + * php-tuf/composer-stager) to not work reliably; potentially even break a site! + * + * This validator restricts the use of composer plugins: + * - using arbitrary composer plugins is discouraged by composer, but disallowed + * by this module (it is too risky): + * @code config.allowed-plugins = true @endcode is forbidden. + * - installed composer plugins that are not allowed (in composer.json's + * @code config.allowed-plugins @endcode) are not executed by composer, so + * these are safe + * - installed composer plugins that are allowed need to be either explicitly + * supported by this validator (and they may need their own validation to + * ensure their configuration is safe, for example Drupal core's vendor + * hardening plugin), or explicitly trusted, by adding it to the + * @code package_manager.settings @endcode configuration's + * @code additional_trusted_composer_plugins @endcode list. + * + * @todo Determine how other Composer plugins will be supported in + * https://drupal.org/i/3339417. + * + * @see https://getcomposer.org/doc/04-schema.md#type + * @see https://getcomposer.org/doc/articles/plugins.md + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ComposerPluginsValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Composer plugins known to modify other packages, but that are validated. + * + * (The validation guarantees they are safe to use.) + * + * Keys are composer plugin package names, values are associated validators or + * excluders necessary to make those composer plugins work reliably with the + * Package Manager module. + * + * @var string[][] + */ + private const SUPPORTED_PLUGINS_THAT_DO_MODIFY = [ + // cSpell:disable + 'cweagans/composer-patches' => ComposerPatchesValidator::class, + 'drupal/core-vendor-hardening' => VendorHardeningExcluder::class, + // cSpell:enable + ]; + + /** + * The composer plugins are known not to modify other packages. + * + * @var string[] + */ + private const SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY = [ + // cSpell:disable + 'composer/installers', + 'dealerdirect/phpcodesniffer-composer-installer', + 'drupal/core-composer-scaffold', + 'drupal/core-project-message', + 'phpstan/extension-installer', + // cSpell:enable + ]; + + /** + * The additional trusted composer plugin package names. + * + * Note: these are normalized package names. + * + * @var string[] + * @see \Composer\Package\PackageInterface::getName() + * @see \Composer\Package\PackageInterface::getPrettyName() + */ + protected array $additionalTrustedComposerPlugins; + + /** + * The Composer inspector service. + * + * @var \Drupal\package_manager\ComposerInspector + */ + protected ComposerInspector $inspector; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected PathLocator $pathLocator; + + /** + * Constructs a new ComposerPluginsValidator. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory. + * @param \Drupal\package_manager\ComposerInspector $inspector + * The Composer inspector service. + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + */ + public function __construct(ConfigFactoryInterface $config_factory, ComposerInspector $inspector, PathLocator $path_locator) { + $settings = $config_factory->get('package_manager.settings'); + $this->additionalTrustedComposerPlugins = array_map( + [__CLASS__, 'normalizePackageName'], + $settings->get('additional_trusted_composer_plugins') + ); + $this->inspector = $inspector; + $this->pathLocator = $path_locator; + } + + /** + * Normalizes a package name. + * + * @param string $package_name + * A package name. + * + * @return string + * The normalized package name. + */ + private static function normalizePackageName(string $package_name): string { + // Normalize the configured package names using Composer's own logic. + return (new Package($package_name, 'irrelevant', 'irrelevant'))->getName(); + } + + /** + * @return string[] + */ + private function getSupportedPlugins(): array { + return array_merge( + array_keys(self::SUPPORTED_PLUGINS_THAT_DO_MODIFY), + self::SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY, + $this->additionalTrustedComposerPlugins, + ); + } + + /** + * {@inheritdoc} + */ + public function validateStagePreOperation(PreOperationStageEvent $event): void { + $stage = $event->getStage(); + + // When about to copy the changes from the stage directory to the active + // directory, use the stage directory's composer instead of the active. + // Because composer plugins may be added or removed; the only thing that + // matters is the set of composer plugins that *will* apply — if a composer + // plugin is being removed, that's fine. + $dir = $event instanceof PreApplyEvent + ? $stage->getStageDirectory() + : $this->pathLocator->getProjectRoot(); + try { + // @see https://getcomposer.org/doc/06-config.md#allow-plugins + $value = $this->inspector->getConfig('allow-plugins', $dir); + } + catch (\Exception $exception) { + $event->addErrorFromThrowable($exception, $this->t('Unable to determine Composer <code>allow-plugins/code> setting.')); + return; + } + + if ($value === 1) { + $event->addError([$this->t('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.')]); + return; + } + + // Only packages with `true` as a value are actually executed by composer. + assert(is_array($value)); + $allowed_plugins = array_keys(array_filter($value)); + // Normalized allowed plugins: keys are normalized package names, values are + // the original package names. + $normalized_allowed_plugins = array_combine( + array_map([__CLASS__, 'normalizePackageName'], $allowed_plugins), + $allowed_plugins + ); + $unsupported_plugins = array_diff_key($normalized_allowed_plugins, array_flip($this->getSupportedPlugins())); + if ($unsupported_plugins) { + $unsupported_plugins_messages = array_map( + fn (string $raw_allowed_plugin_name) => new FormattableMarkup( + "<code>@package_name</code>", + [ + '@package_name' => $raw_allowed_plugin_name, + ] + ), + $unsupported_plugins + ); + $summary = $this->formatPlural( + count($unsupported_plugins), + 'An unsupported Composer plugin was detected.', + 'Unsupported Composer plugins were detected.', + ); + $event->addError($unsupported_plugins_messages, $summary); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => 'validateStagePreOperation', + PreApplyEvent::class => 'validateStagePreOperation', + StatusCheckEvent::class => 'validateStagePreOperation', + ]; + } + +} diff --git a/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php b/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php index ddd9ab635a..d79016f6dd 100644 --- a/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php +++ b/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php @@ -144,7 +144,7 @@ class FixtureManipulator { * This function is internal and should not be called directly. Use * ::addPackage(), ::modifyPackage(), and ::removePackage() instead. * - * @param string $name + * @param string $pretty_name * The name of the package to add, update, or remove. * @param array|null $package * The package information to be set in installed.json and installed.php, or @@ -155,7 +155,10 @@ class FixtureManipulator { * @param bool|null $is_dev_requirement * Whether or not the package is a developer requirement. */ - private function setPackage(string $name, ?array $package, bool $should_exist, ?bool $is_dev_requirement = NULL): void { + private function setPackage(string $pretty_name, ?array $package, bool $should_exist, ?bool $is_dev_requirement = NULL): void { + // @see \Composer\Package\BasePackage::__construct() + $name = strtolower($pretty_name); + if ($should_exist && isset($is_dev_requirement)) { throw new \LogicException('Changing an existing project to a dev requirement is not supported'); } @@ -178,14 +181,14 @@ class FixtureManipulator { // Ensure that we actually expect to find the package already installed (or // not). $expected_package_message = $should_exist - ? "Expected package '$name' to be installed, but it wasn't." - : "Expected package '$name' to not be installed, but it was."; + ? "Expected package '$pretty_name' to be installed, but it wasn't." + : "Expected package '$pretty_name' to not be installed, but it was."; if ($should_exist !== isset($position)) { throw new \LogicException($expected_package_message); } if ($package) { - $package = ['name' => $name] + $package; + $package = ['name' => $pretty_name] + $package; $install_json_package = array_diff_key($package, array_flip(['install_path'])); } @@ -299,6 +302,40 @@ class FixtureManipulator { return $this; } + /** + * Modifies a package's installed info. + * + * See ::addPackage() for information on how the `install_path` key is + * handled, if $package has it. + * + * @param array $additional_config + * The configuration to add. + */ + public function addConfig(array $additional_config): self { + if (empty($additional_config)) { + throw new \InvalidArgumentException('No config to add.'); + } + + if (!$this->committingChanges) { + $this->queueManipulation('addConfig', func_get_args()); + return $this; + } + + $file = $this->dir . '/composer.json'; + self::ensureFilePathIsWritable($file); + + $data = file_get_contents($file); + $data = json_decode($data, TRUE, 512, JSON_THROW_ON_ERROR); + + $config = $data['config'] ?? []; + $data['config'] = NestedArray::mergeDeep($config, $additional_config); + + file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + self::ensureFilePathIsWritable($file); + + return $this; + } + /** * Commits the changes to the directory. */ diff --git a/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php b/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php new file mode 100644 index 0000000000..5552d76deb --- /dev/null +++ b/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php @@ -0,0 +1,331 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\ComposerPluginsValidator + * @group package_manager + * @internal + */ +class ComposerPluginsValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests `config.allow-plugins: true` fails validation during pre-create. + */ + public function testInsecureConfigurationFailsValidationPreCreate(): void { + $active_manipulator = new ActiveFixtureManipulator(); + $active_manipulator->addConfig(['allow-plugins' => TRUE]); + $active_manipulator->commitChanges(); + + $expected_results = [ + ValidationResult::createError( + [ + new TranslatableMarkup('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.'), + ], + ), + ]; + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests `config.allow-plugins: true` fails validation during pre-apply. + */ + public function testInsecureConfigurationFailsValidationPreApply(): void { + $stage_manipulator = $this->getStageFixtureManipulator(); + $stage_manipulator->addConfig(['allow-plugins' => TRUE]); + + $expected_results = [ + ValidationResult::createError( + [ + new TranslatableMarkup('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.'), + ], + ), + ]; + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Tests composer plugins are validated during pre-create. + * + * @dataProvider providerSimpleValidCases + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationDuringPreCreate(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $active_manipulator = new ActiveFixtureManipulator(); + if ($composer_config_to_add) { + $active_manipulator->addConfig($composer_config_to_add); + } + foreach ($packages_to_add as $package) { + $active_manipulator->addPackage($package); + } + $active_manipulator->commitChanges(); + + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests composer plugins are validated during pre-apply. + * + * @dataProvider providerSimpleValidCases + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationDuringPreApply(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $stage_manipulator = $this->getStageFixtureManipulator(); + if ($composer_config_to_add) { + $stage_manipulator->addConfig($composer_config_to_add); + } + foreach ($packages_to_add as $package) { + $stage_manipulator->addPackage($package); + } + + // Ensure \Drupal\package_manager\Validator\SupportedReleaseValidator does + // not complain. + $release_fixture_folder = __DIR__ . '/../../fixtures/release-history'; + $this->setReleaseMetadata([ + 'semver_test' => "$release_fixture_folder/semver_test.1.1.xml", + ]); + + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Tests additional composer plugins can be trusted during pre-create. + * + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationAfterTrustingDuringPreCreate(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $expected_results_without_composer_plugin_violations = array_filter( + $expected_results, + fn (ValidationResult $v) => !$v->getSummary() || !str_contains(strtolower($v->getSummary()->getUntranslatedString()), 'unsupported composer plugin'), + ); + + // Trust all added packages. + $this->config('package_manager.settings') + ->set('additional_trusted_composer_plugins', array_map(fn (array $package) => $package['name'], $packages_to_add)) + ->save(); + + // Reuse the test logic that does not trust additional packages, but with + // updated expected results. + $this->testValidationDuringPreCreate($composer_config_to_add, $packages_to_add, $expected_results_without_composer_plugin_violations); + } + + /** + * Tests additional composer plugins can be trusted during pre-apply. + * + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationAfterTrustingDuringPreApply(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $expected_results_without_composer_plugin_violations = array_filter( + $expected_results, + fn (ValidationResult $v) => !$v->getSummary() || !str_contains(strtolower($v->getSummary()->getUntranslatedString()), 'unsupported composer plugin'), + ); + + // Trust all added packages. + $this->config('package_manager.settings') + ->set('additional_trusted_composer_plugins', array_map(fn (array $package) => $package['name'], $packages_to_add)) + ->save(); + + // Reuse the test logic that does not trust additional packages, but with + // updated expected results. + $this->testValidationDuringPreApply($composer_config_to_add, $packages_to_add, $expected_results_without_composer_plugin_violations); + } + + public function providerSimpleValidCases(): \Generator { + yield 'no composer plugins' => [ + [], + [ + [ + 'name' => "drupal/semver_test", + 'version' => '8.1.0', + 'type' => 'drupal-module', + 'install_path' => '../../modules/semver_test', + ], + ], + [], + ]; + + // @todo Uncomment this in https://www.drupal.org/project/automatic_updates/issues/3252299 + // phpcs:disable + /* + yield 'one supported composer plugin' => [ + [ + [ + 'name' => 'cweagans/composer-patches', + 'version' => '1.0.0', + 'type' => 'composer-plugin', + ], + ], + [ + // Note: this is not a complaint about using cweagans/composer-patches + // but a complaint about *how* it is used. + // @see \Drupal\package_manager\Validator\ComposerPatchesValidator + ValidationResult::createError([ + new TranslatableMarkup('The <code>cweagans/composer-patches</code> plugin is installed, but the <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of composer.json.'), + ]), + ], + ]; + */ + // phpcs:enable + + yield 'another supported composer plugin' => [ + [ + 'allow-plugins' => [ + 'drupal/core-vendor-hardening' => TRUE, + ], + ], + [ + [ + 'name' => 'drupal/core-vendor-hardening', + 'version' => '9.8.0', + 'type' => 'composer-plugin', + ], + ], + [], + ]; + + yield 'one UNsupported but disallowed plugin — pretty package name' => [ + [ + 'allow-plugins' => [ + 'composer/plugin-A' => FALSE, + ], + ], + [ + [ + 'name' => 'composer/plugin-A', + 'version' => '6.1', + 'type' => 'composer-plugin', + ], + ], + [], + ]; + + yield 'one UNsupported but disallowed plugin — normalized package name' => [ + [ + 'allow-plugins' => [ + 'composer/plugin-b' => FALSE, + ], + ], + [ + [ + 'name' => 'composer/plugin-b', + 'version' => '20.1', + 'type' => 'composer-plugin', + ], + ], + [], + ]; + + yield 'one UNsupported but disallowed plugin' => [ + [ + 'allow-plugins' => [ + // Definitely NOT `composer/plugin-c`! + 'drupal/core-project-message' => TRUE, + ], + ], + [ + [ + 'name' => 'composer/plugin-c', + 'version' => '16.4', + 'type' => 'composer-plugin', + ], + ], + [], + ]; + } + + public function providerSimpleInvalidCases(): \Generator { + yield 'one UNsupported composer plugin — pretty package name' => [ + [ + 'allow-plugins' => [ + 'NOT-cweagans/NOT-composer-patches' => TRUE, + ], + ], + [ + [ + 'name' => 'NOT-cweagans/NOT-composer-patches', + 'version' => '6.1', + 'type' => 'composer-plugin', + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>NOT-cweagans/NOT-composer-patches</code>'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + + yield 'one UNsupported composer plugin — normalized package name' => [ + [ + 'allow-plugins' => [ + 'also-not-cweagans/also-not-composer-patches' => TRUE, + ], + ], + [ + [ + 'name' => 'also-not-cweagans/also-not-composer-patches', + 'version' => '20.1', + 'type' => 'composer-plugin', + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>also-not-cweagans/also-not-composer-patches</code>'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + } + + /** + * Generates complex invalid test cases based on the simple test cases. + * + * @return \Generator + */ + public function providerComplexInvalidCases(): \Generator { + $valid_cases = iterator_to_array($this->providerSimpleValidCases()); + $invalid_cases = iterator_to_array($this->providerSimpleInvalidCases()); + $all_config = NestedArray::mergeDeepArray( + // First key-value pair for each simple test case: the packages it adds. + array_map(fn (array $c) => $c[0], $valid_cases + $invalid_cases) + ); + $all_packages = NestedArray::mergeDeepArray( + // Second key-value pair for each simple test case: the packages it adds. + array_map(fn (array $c) => $c[1], $valid_cases + $invalid_cases) + ); + + yield 'complex combination' => [ + $all_config, + $all_packages, + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>NOT-cweagans/NOT-composer-patches</code>'), + new TranslatableMarkup('<code>also-not-cweagans/also-not-composer-patches</code>'), + ], + new TranslatableMarkup('Unsupported Composer plugins were detected.'), + ), + ], + ]; + } + +} diff --git a/tests/src/Functional/UpdatePathTest.php b/tests/src/Functional/UpdatePathTest.php index 329e34fb1e..3d57d1cb1e 100644 --- a/tests/src/Functional/UpdatePathTest.php +++ b/tests/src/Functional/UpdatePathTest.php @@ -61,6 +61,8 @@ class UpdatePathTest extends UpdatePathTestBase { $this->assertSame(CronUpdater::SECURITY, $this->config('automatic_updates.settings')->get('cron')); + $this->assertSame(NULL, $this->config('package_manager.settings')->get('additional_trusted_composer_plugins')); + $this->runUpdates(); $this->assertSame(CronUpdater::DISABLED, $this->config('automatic_updates.settings')->get('cron')); @@ -75,6 +77,8 @@ class UpdatePathTest extends UpdatePathTestBase { $this->assertSame($expected_values, $key_value->getMultiple(array_values($map))); $this->assertSame(StatusCheckMailer::ERRORS_ONLY, $this->config('automatic_updates.settings')->get('status_check_mail')); + $this->assertSame([], $this->config('package_manager.settings')->get('additional_trusted_composer_plugins')); + // Ensure that the router was rebuilt and routes have the expected changes. $routes = $this->container->get('router')->getRouteCollection(); $routes = array_map([$routes, 'get'], [ -- GitLab