Commit f0e313a4 authored by catch's avatar catch
Browse files

Issue #2313917 by tedbow, pwolanin, jhedstrom, Wim Leers, Mixologic, larowlan,...

Issue #2313917 by tedbow, pwolanin, jhedstrom, Wim Leers, Mixologic, larowlan, Mile23, Gábor Hojtsy, xjm, Berdir: Core version key in module's .info.yml doesn't respect core semantic versioning
parent 45ff41df
......@@ -58,8 +58,7 @@ function update_check_incompatibility($name, $type = 'module') {
$file = $themes[$name];
}
if (!isset($file)
|| !isset($file->info['core'])
|| $file->info['core'] != \Drupal::CORE_COMPATIBILITY
|| $file->info['core_incompatible']
|| version_compare(phpversion(), $file->info['php']) < 0) {
return TRUE;
}
......
......@@ -2,6 +2,7 @@
namespace Drupal\Core\Extension;
use Composer\Semver\Semver;
use Drupal\Component\Serialization\Exception\InvalidDataTypeException;
use Drupal\Core\Serialization\Yaml;
......@@ -10,6 +11,34 @@
*/
class InfoParserDynamic implements InfoParserInterface {
/**
* The earliest Drupal version that supports the 'core_version_requirement'.
*/
const FIRST_CORE_VERSION_REQUIREMENT_SUPPORTED_VERSION = '8.7.7';
/**
* Determines if a version satisfies the given constraints.
*
* This method uses \Composer\Semver\Semver::satisfies() but returns FALSE if
* the version or constraints are not valid instead of throwing an exception.
*
* @param string $version
* The version.
* @param string $constraints
* The constraints.
*
* @return bool
* TRUE if the version satisfies the constraints, otherwise FALSE.
*/
protected static function satisfies($version, $constraints) {
try {
return Semver::satisfies($version, $constraints);
}
catch (\UnexpectedValueException $exception) {
return FALSE;
}
}
/**
* {@inheritdoc}
*/
......@@ -28,6 +57,45 @@ public function parse($filename) {
if (!empty($missing_keys)) {
throw new InfoParserException('Missing required keys (' . implode(', ', $missing_keys) . ') in ' . $filename);
}
if ($parsed_info['type'] === 'profile' && isset($parsed_info['core_version_requirement'])) {
// @todo Support the 'core_version_requirement' key in profiles in
// https://www.drupal.org/node/3070401.
throw new InfoParserException("The 'core_version_requirement' key is not supported in profiles in $filename");
}
if (!isset($parsed_info['core']) && !isset($parsed_info['core_version_requirement'])) {
throw new InfoParserException("The 'core' or the 'core_version_requirement' key must be present in " . $filename);
}
if (isset($parsed_info['core']) && !preg_match("/^\d\.x$/", $parsed_info['core'])) {
throw new InfoParserException("Invalid 'core' value \"{$parsed_info['core']}\" in " . $filename);
}
if (isset($parsed_info['core_version_requirement'])) {
$supports_pre_core_version_requirement_version = static::isConstraintSatisfiedByPreviousVersion($parsed_info['core_version_requirement'], static::FIRST_CORE_VERSION_REQUIREMENT_SUPPORTED_VERSION);
// If the 'core_version_requirement' constraint does not satisfy any
// Drupal 8 versions before 8.7.7 then 'core' cannot be set or it will
// effectively support all versions of Drupal 8 because
// 'core_version_requirement' will be ignored in previous versions.
if (!$supports_pre_core_version_requirement_version && isset($parsed_info['core'])) {
throw new InfoParserException("The 'core_version_requirement' constraint ({$parsed_info['core_version_requirement']}) requires the 'core' key not be set in " . $filename);
}
// 'core_version_requirement' can not be used to specify Drupal 8
// versions before 8.7.7 because these versions do not use the
// 'core_version_requirement' key. Do not throw the exception if the
// constraint also is satisfied by 8.0.0-alpha1 to allow constraints
// such as '^8' or '^8 || ^9'.
if ($supports_pre_core_version_requirement_version && !static::satisfies('8.0.0-alpha1', $parsed_info['core_version_requirement'])) {
throw new InfoParserException("The 'core_version_requirement' can not be used to specify compatibility for a specific version before " . static::FIRST_CORE_VERSION_REQUIREMENT_SUPPORTED_VERSION . " in $filename");
}
}
// Determine if the extension is compatible with the current version of
// Drupal core.
try {
$core_version_constraint = isset($parsed_info['core_version_requirement']) ? $parsed_info['core_version_requirement'] : $parsed_info['core'];
$parsed_info['core_incompatible'] = !static::satisfies(\Drupal::VERSION, $core_version_constraint);
}
catch (\UnexpectedValueException $exception) {
$parsed_info['core_incompatible'] = TRUE;
}
if (isset($parsed_info['version']) && $parsed_info['version'] === 'VERSION') {
$parsed_info['version'] = \Drupal::VERSION;
}
......@@ -60,7 +128,80 @@ public function parse($filename) {
* An array of required keys.
*/
protected function getRequiredKeys() {
return ['type', 'core', 'name'];
return ['type', 'name'];
}
/**
* Determines if a constraint is satisfied by earlier versions of Drupal 8.
*
* @param string $constraint
* A core semantic version constraint.
* @param string $version
* A core version.
*
* @return bool
* TRUE if the constraint is satisfied by a core version prior to the
* provided version.
*/
protected static function isConstraintSatisfiedByPreviousVersion($constraint, $version) {
static $evaluated_constraints = [];
// Any particular constraint and version combination only needs to be
// evaluated once.
if (!isset($evaluated_constraints[$constraint][$version])) {
$evaluated_constraints[$constraint][$version] = FALSE;
foreach (static::getAllPreviousCoreVersions($version) as $previous_version) {
if (static::satisfies($previous_version, $constraint)) {
$evaluated_constraints[$constraint][$version] = TRUE;
// The constraint only has to satisfy one previous version so break
// when the first one is found.
break;
}
}
}
return $evaluated_constraints[$constraint][$version];
}
/**
* Gets all the versions of Drupal 8 before a specific version.
*
* @param string $version
* The version to get versions before.
*
* @return array
* All of the applicable Drupal 8 releases.
*/
protected static function getAllPreviousCoreVersions($version) {
static $versions_lists = [];
// Check if list of previous versions for the specified version has already
// been created.
if (empty($versions_lists[$version])) {
// Loop through all minor versions including 8.7.
foreach (range(0, 7) as $minor) {
// The largest patch number in a release was 17 in 8.6.17. Use 27 to
// leave room for future security releases.
foreach (range(0, 27) as $patch) {
$patch_version = "8.$minor.$patch";
if ($patch_version === $version) {
// Reverse the order of the versions so that they will be evaluated
// from the most recent versions first.
$versions_lists[$version] = array_reverse($versions_lists[$version]);
return $versions_lists[$version];
}
if ($patch === 0) {
// If this is a '0' patch release like '8.1.0' first create the
// pre-release versions such as '8.1.0-alpha1' and '8.1.0-rc1'.
foreach (['alpha', 'beta', 'rc'] as $prerelease) {
// The largest prerelease number was in 8.0.0-beta16.
foreach (range(0, 16) as $prerelease_number) {
$versions_lists[$version][] = "$patch_version-$prerelease$prerelease_number";
}
}
}
$versions_lists[$version][] = $patch_version;
}
}
}
return $versions_lists[$version];
}
}
......@@ -81,11 +81,17 @@ public function addUninstallValidator(ModuleUninstallValidatorInterface $uninsta
*/
public function install(array $module_list, $enable_dependencies = TRUE) {
$extension_config = \Drupal::configFactory()->getEditable('core.extension');
// Get all module data so we can find dependencies and sort and find the
// core requirements. The module list needs to be reset so that it can
// re-scan and include any new modules that may have been added directly
// into the filesystem.
$module_data = \Drupal::service('extension.list.module')->reset()->getList();
foreach ($module_list as $module) {
if (!empty($module_data[$module]->info['core_incompatible'])) {
throw new MissingDependencyException("Unable to install modules: module '$module' is incompatible with this version of Drupal core.");
}
}
if ($enable_dependencies) {
// Get all module data so we can find dependencies and sort.
// The module list needs to be reset so that it can re-scan and include
// any new modules that may have been added directly into the filesystem.
$module_data = \Drupal::service('extension.list.module')->reset()->getList();
$module_list = $module_list ? array_combine($module_list, $module_list) : [];
if ($missing_modules = array_diff_key($module_list, $module_data)) {
// One or more of the given modules doesn't exist.
......@@ -110,6 +116,9 @@ public function install(array $module_list, $enable_dependencies = TRUE) {
// Skip already installed modules.
if (!isset($module_list[$dependency]) && !isset($installed_modules[$dependency])) {
if ($module_data[$dependency]->info['core_incompatible']) {
throw new MissingDependencyException("Unable to install modules: module '$module'. Its dependency module '$dependency' is incompatible with this version of Drupal core.");
}
$module_list[$dependency] = $dependency;
}
}
......
......@@ -222,8 +222,6 @@ public function themesPage() {
}
if (empty($theme->status)) {
// Ensure this theme is compatible with this version of core.
$theme->incompatible_core = !isset($theme->info['core']) || ($theme->info['core'] != \DRUPAL::CORE_COMPATIBILITY);
// Require the 'content' region to make sure the main page
// content has a common place in all themes.
$theme->incompatible_region = !isset($theme->info['regions']['content']);
......@@ -234,7 +232,7 @@ public function themesPage() {
$theme->incompatible_engine = isset($theme->info['engine']) && !isset($theme->owner);
}
$theme->operations = [];
if (!empty($theme->status) || !$theme->incompatible_core && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine) {
if (!empty($theme->status) || !$theme->info['core_incompatible'] && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine) {
// Create the operations links.
$query['theme'] = $theme->getName();
if ($this->themeAccess->checkAccess($theme->getName())) {
......
......@@ -294,10 +294,14 @@ protected function buildRow(array $modules, Extension $module, $distribution) {
$reasons = [];
// Check the core compatibility.
if ($module->info['core'] != \Drupal::CORE_COMPATIBILITY) {
if ($module->info['core_incompatible']) {
$compatible = FALSE;
$reasons[] = $this->t('This version is not compatible with Drupal @core_version and should be replaced.', [
'@core_version' => \Drupal::CORE_COMPATIBILITY,
'@core_version' => \Drupal::VERSION,
]);
$row['#requires']['core'] = $this->t('Drupal Core (@core_requirement) (<span class="admin-missing">incompatible with</span> version @core_version)', [
'@core_requirement' => isset($module->info['core_version_requirement']) ? $module->info['core_version_requirement'] : $module->info['core'],
'@core_version' => \Drupal::VERSION,
]);
}
......@@ -341,7 +345,7 @@ protected function buildRow(array $modules, Extension $module, $distribution) {
}
// Disable the checkbox if the dependency is incompatible with this
// version of Drupal core.
elseif ($modules[$dependency]->info['core'] != \Drupal::CORE_COMPATIBILITY) {
elseif ($modules[$dependency]->info['core_incompatible']) {
$row['#requires'][$dependency] = $this->t('@module (<span class="admin-missing">incompatible with</span> this version of Drupal core)', [
'@module' => $name,
]);
......
......@@ -293,8 +293,8 @@ function template_preprocess_system_themes_page(&$variables) {
// Make sure to provide feedback on compatibility.
$current_theme['incompatible'] = '';
if (!empty($theme->incompatible_core)) {
$current_theme['incompatible'] = t("This theme is not compatible with Drupal @core_version. Check that the .info.yml file contains the correct 'core' value.", ['@core_version' => \Drupal::CORE_COMPATIBILITY]);
if (!empty($theme->info['core_incompatible'])) {
$current_theme['incompatible'] = t("This theme is not compatible with Drupal @core_version. Check that the .info.yml file contains a compatible 'core' or 'core_version_requirement' value.", ['@core_version' => \Drupal::VERSION]);
}
elseif (!empty($theme->incompatible_region)) {
$current_theme['incompatible'] = t("This theme is missing a 'content' region.");
......
<?php
/**
* @file
* Database to mimic the installation of the update_test_semver_update_n module.
*/
use Drupal\Core\Database\Database;
$connection = Database::getConnection();
// Set the schema version.
$connection->merge('key_value')
->condition('collection', 'system.schema')
->condition('name', 'update_test_semver_update_n')
->fields([
'collection' => 'system.schema',
'name' => 'update_test_semver_update_n',
'value' => 'i:8000;',
])
->execute();
// Update core.extension.
$extensions = $connection->select('config')
->fields('config', ['data'])
->condition('collection', '')
->condition('name', 'core.extension')
->execute()
->fetchField();
$extensions = unserialize($extensions);
$extensions['module']['update_test_semver_update_n'] = 8000;
$connection->update('config')
->fields([
'data' => serialize($extensions),
])
->condition('collection', '')
->condition('name', 'core.extension')
->execute();
name: 'System core incompatible semver test'
type: module
description: 'Support module for testing core incompatible semver.'
package: Testing
version: 1.0.0
core_version_requirement: ^7
name: 'System core ^8 version test'
type: module
description: 'Support module for testing core using semver.'
package: Testing
version: 1.0.0
core_version_requirement: ^8
name: 'System incompatible core 1.x version test'
type: module
description: 'Support module for testing system core incompatibility.'
package: Testing
version: 1.0.0
core: 1.x
......@@ -71,6 +71,7 @@ function system_test_system_info_alter(&$info, Extension $file, $type) {
'system_incompatible_core_version_dependencies_test',
'system_incompatible_module_version_test',
'system_incompatible_core_version_test',
'system_incompatible_core_version_test_1x',
])) {
$info['hidden'] = FALSE;
}
......
name: 'Update test hook_update_n semver'
type: module
description: 'Support module for update testing with core semver value.'
package: Testing
version: VERSION
core_version_requirement: ^8
<?php
/**
* @file
* Update hooks for the update_test_semver_update_n module.
*/
/**
* Update 8001.
*/
function update_test_semver_update_n_update_8001() {
\Drupal::state()->set('update_test_semver_update_n_update_8001', 'Yes, I was run. Thanks for testing!');
}
......@@ -70,7 +70,7 @@ public function testModulesListFormWithInvalidInfoFile() {
// Confirm that the error message is shown.
$this->assertSession()
->pageTextContains('Modules could not be listed due to an error: Missing required keys (core) in ' . $path . '/broken.info.yml');
->pageTextContains("The 'core' or the 'core_version_requirement' key must be present in " . $path . '/broken.info.yml');
// Check that the module filter text box is available.
$this->assertTrue($this->xpath('//input[@name="text"]'));
......
......@@ -102,6 +102,29 @@ public function testIncompatiblePhpVersionDependency() {
$this->assert(count($checkbox) == 1, 'Checkbox for the module is disabled.');
}
/**
* Tests enabling modules with different core version specifications.
*/
public function testCoreCompatibility() {
$assert_session = $this->assertSession();
// Test incompatible 'core_version_requirement'.
$this->drupalGet('admin/modules');
$assert_session->fieldDisabled('modules[system_incompatible_core_version_test_1x][enable]');
$assert_session->fieldDisabled('modules[system_core_incompatible_semver_test][enable]');
// Test compatible 'core_version_requirement' and compatible 'core'.
$this->drupalGet('admin/modules');
$assert_session->fieldEnabled('modules[common_test][enable]');
$assert_session->fieldEnabled('modules[system_core_semver_test][enable]');
// Ensure the modules can actually be installed.
$edit['modules[common_test][enable]'] = 'common_test';
$edit['modules[system_core_semver_test][enable]'] = 'system_core_semver_test';
$this->drupalPostForm('admin/modules', $edit, t('Install'));
$this->assertModules(['common_test', 'system_core_semver_test'], TRUE);
}
/**
* Tests enabling a module that depends on a module which fails hook_requirements().
*/
......
......@@ -367,8 +367,11 @@ public function testInvalidTheme() {
$this->assertText(t('This theme requires the base theme @base_theme to operate correctly.', ['@base_theme' => 'not_real_test_basetheme']));
$this->assertText(t('This theme requires the base theme @base_theme to operate correctly.', ['@base_theme' => 'test_invalid_basetheme']));
$this->assertText(t('This theme requires the theme engine @theme_engine to operate correctly.', ['@theme_engine' => 'not_real_engine']));
// Check for the error text of a theme with the wrong core version.
$this->assertText("This theme is not compatible with Drupal 8.x. Check that the .info.yml file contains the correct 'core' value.");
// Check for the error text of a theme with the wrong core version
// using 7.x and ^7.
$incompatible_core_message = 'This theme is not compatible with Drupal ' . \Drupal::VERSION . ". Check that the .info.yml file contains a compatible 'core' or 'core_version_requirement' value.";
$this->assertThemeIncompatibleText('Theme test with invalid core version', $incompatible_core_message);
$this->assertThemeIncompatibleText('Theme test with invalid semver core version', $incompatible_core_message);
// Check for the error text of a theme without a content region.
$this->assertText("This theme is missing a 'content' region.");
}
......@@ -437,24 +440,28 @@ public function testUninstallingThemes() {
* Tests installing a theme and setting it as default.
*/
public function testInstallAndSetAsDefault() {
$this->drupalGet('admin/appearance');
// Bartik is uninstalled in the test profile and has the third "Install and
// set as default" link.
$this->clickLink(t('Install and set as default'), 2);
// Test the confirmation message.
$this->assertText('Bartik is now the default theme.');
// Make sure Bartik is now set as the default theme in config.
$this->assertEqual($this->config('system.theme')->get('default'), 'bartik');
// This checks for a regression. See https://www.drupal.org/node/2498691.
$this->assertNoText('The bartik theme was not found.');
$themes = \Drupal::service('theme_handler')->rebuildThemeData();
$version = $themes['bartik']->info['version'];
// Confirm Bartik is indicated as the default theme.
$out = $this->getSession()->getPage()->getContent();
$this->assertTrue((bool) preg_match('/Bartik ' . preg_quote($version) . '\s{2,}\(default theme\)/', $out));
$themes = [
'bartik' => 'Bartik',
'test_core_semver' => 'Theme test with semver core version',
];
foreach ($themes as $theme_machine_name => $theme_name) {
$this->drupalGet('admin/appearance');
$this->getSession()->getPage()->findLink("Install $theme_name as default theme")->click();
// Test the confirmation message.
$this->assertText("$theme_name is now the default theme.");
// Make sure the theme is now set as the default theme in config.
$this->assertEqual($this->config('system.theme')->get('default'), $theme_machine_name);
// This checks for a regression. See https://www.drupal.org/node/2498691.
$this->assertNoText("The $theme_machine_name theme was not found.");
$themes = \Drupal::service('theme_handler')->rebuildThemeData();
$version = $themes[$theme_machine_name]->info['version'];
// Confirm the theme is indicated as the default theme.
$out = $this->getSession()->getPage()->getContent();
$this->assertTrue((bool) preg_match("/$theme_name " . preg_quote($version) . '\s{2,}\(default theme\)/', $out));
}
}
/**
......@@ -470,4 +477,16 @@ public function testThemeSettingsNoLogoNoFavicon() {
$this->assertText('The configuration options have been saved.');
}
/**
* Asserts that expected incompatibility text is displayed for a theme.
*
* @param string $theme_name
* Theme name to select element on page. This can be a partial name.
* @param string $expected_text
* The expected incompatibility text.
*/
private function assertThemeIncompatibleText($theme_name, $expected_text) {
$this->assertSession()->elementExists('css', ".theme-info:contains(\"$theme_name\") .incompatible:contains(\"$expected_text\")");
}
}
name: 'Theme test with semver core version'
type: theme
description: 'Test theme which has semver core version.'
version: VERSION
core_version_requirement: ^8 || ^9
name: 'Theme test with invalid semver core version'
type: theme
description: 'Test theme which has an invalid semver core version.'
version: VERSION
core_version_requirement: ^7
......@@ -699,7 +699,7 @@ function update_verify_update_archive($project, $archive_file, $directory) {
$info = \Drupal::service('info_parser')->parse($file->uri);
// If the module or theme is incompatible with Drupal core, set an error.
if (empty($info['core']) || $info['core'] != \Drupal::CORE_COMPATIBILITY) {
if ($info['core_incompatible']) {
$incompatible[] = !empty($info['name']) ? $info['name'] : t('Unknown');
}
else {
......@@ -717,7 +717,7 @@ function update_verify_update_archive($project, $archive_file, $directory) {
'%archive_file contains a version of %names that is not compatible with Drupal @version.',
'%archive_file contains versions of modules or themes that are not compatible with Drupal @version: %names',
[
'@version' => \Drupal::CORE_COMPATIBILITY,
'@version' => \Drupal::VERSION,
'%archive_file' => $file_system->basename($archive_file),
'%names' => implode(', ', $incompatible),
]
......
......@@ -26,6 +26,7 @@ protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../modules/system/tests/fixtures/update/drupal-8.bare.standard.php.gz',
__DIR__ . '/../../../../modules/system/tests/fixtures/update/drupal-8.update-test-schema-enabled.php',
__DIR__ . '/../../../../modules/system/tests/fixtures/update/drupal-8.update-test-semver-update-n-enabled.php',
];
}
......@@ -99,8 +100,11 @@ public function testUpdateHookN() {
// Ensure schema has changed.
$this->assertEqual(drupal_get_installed_schema_version('update_test_schema', TRUE), 8001);
$this->assertEqual(drupal_get_installed_schema_version('update_test_semver_update_n', TRUE), 8001);
// Ensure the index was added for column a.
$this->assertTrue($connection->schema()->indexExists('update_test_schema_table', 'test'), 'Version 8001 of the update_test_schema module is installed.');
// Ensure update_test_semver_update_n_update_8001 was run.
$this->assertEquals(\Drupal::state()->get('update_test_semver_update_n_update_8001'), 'Yes, I was run. Thanks for testing!');
}
/**
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment