diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml index 5e21b987128d0a7ea4c1ddface0922709df33dfc..b6fa99e3ac5a5e8d5ff7deb0248060b7d67956dc 100644 --- a/package_manager/package_manager.services.yml +++ b/package_manager/package_manager.services.yml @@ -104,6 +104,12 @@ services: - '@package_manager.path_locator' tags: - { name: event_subscriber } + package_manager.validator.duplicate_info_file: + class: Drupal\package_manager\Validator\DuplicateInfoFileValidator + arguments: + - '@package_manager.path_locator' + tags: + - { name: event_subscriber } package_manager.test_site_excluder: class: Drupal\package_manager\PathExcluder\TestSiteExcluder arguments: diff --git a/package_manager/src/Validator/DuplicateInfoFileValidator.php b/package_manager/src/Validator/DuplicateInfoFileValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..56f9ec94781c121145bb9175a12ef0ba4434e3d3 --- /dev/null +++ b/package_manager/src/Validator/DuplicateInfoFileValidator.php @@ -0,0 +1,117 @@ +<?php + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Finder\Finder; + +/** + * Validates the stage does not have duplicate info.yml not present in active. + * + * @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. + */ +class DuplicateInfoFileValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + + /** + * Constructs a DuplicateInfoFileValidator object. + * + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + */ + public function __construct(PathLocator $path_locator) { + $this->pathLocator = $path_locator; + } + + /** + * Validates the stage does not have duplicate info.yml not present in active. + */ + public function validateDuplicateInfoFileInStage(PreApplyEvent $event): void { + $active_dir = $this->pathLocator->getProjectRoot(); + $stage_dir = $event->getStage()->getStageDirectory(); + $active_info_files = $this->findInfoFiles($active_dir); + $stage_info_files = $this->findInfoFiles($stage_dir); + + foreach ($stage_info_files as $stage_info_file => $stage_info_count) { + if (isset($active_info_files[$stage_info_file])) { + // Check if stage directory has more info.yml files matching + // $stage_info_file than in the active directory. + if ($stage_info_count > $active_info_files[$stage_info_file]) { + $event->addError([ + $this->t('The staging directory has @stage_count instances of @stage_info_file as compared to @active_count in the active directory. This likely indicates that a duplicate extension was installed.', [ + '@stage_info_file' => $stage_info_file, + '@stage_count' => $stage_info_count, + '@active_count' => $active_info_files[$stage_info_file], + ]), + ]); + } + } + // Check if stage directory has two or more info.yml files matching + // $stage_info_file which are not in active directory. + elseif ($stage_info_count > 1) { + $event->addError([ + $this->t('The staging directory has @stage_count instances of @stage_info_file. This likely indicates that a duplicate extension was installed.', [ + '@stage_info_file' => $stage_info_file, + '@stage_count' => $stage_info_count, + ]), + ]); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreApplyEvent::class => 'validateDuplicateInfoFileInStage', + ]; + } + + /** + * Recursively finds info.yml files in a directory. + * + * @param string $dir + * The path of the directory to check. + * + * @return int[] + * Array of count of info.yml files in the directory keyed by file name. + */ + protected function findInfoFiles(string $dir): array { + $info_files_finder = Finder::create() + ->in($dir) + ->ignoreUnreadableDirs() + ->name('*.info.yml'); + $info_files = []; + /** @var \Symfony\Component\Finder\SplFileInfo $info_file */ + foreach (iterator_to_array($info_files_finder) as $info_file) { + // Skipping info.yml files in tests/fixtures because Drupal will not scan + // these directories when doing extension discovery. + // + // @todo We should also skip info.yml files in tests/modules, + // tests/themes, and tests/profiles directories in + // https://www.drupal.org/i/3306163. + if (strpos($info_file->getPath(), DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'fixtures') !== FALSE) { + continue; + } + $file_name = $info_file->getFilename(); + $info_files[$file_name] = ($info_files[$file_name] ?? 0) + 1; + } + return $info_files; + } + +} diff --git a/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php b/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..da3e3407ff7011d307e0dcbfe4064ce8c846c006 --- /dev/null +++ b/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php @@ -0,0 +1,210 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Exception\StageValidationException; +use Drupal\package_manager\ValidationResult; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @covers \Drupal\package_manager\Validator\DuplicateInfoFileValidator + * + * @group package_manager + */ +class DuplicateInfoFileValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testDuplicateInfoFilesInStage. + * + * @return mixed[][] + * The test cases. + */ + public function providerDuplicateInfoFilesInStage(): array { + return [ + 'Duplicate info.yml files in stage' => [ + [ + '/module.info.yml', + ], + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + ValidationResult::createError([ + 'The staging directory has 2 instances of module.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.', + ]), + ], + ], + // Duplicate files in stage but having different extension which we don't + // care about. + 'Duplicate info files in stage' => [ + [ + '/my_file.info', + ], + [ + '/my_file.info', + '/modules/my_file.info', + ], + [], + ], + 'Duplicate info.yml files in stage with one file in tests folder' => [ + [ + '/tests/fixtures/module.info.yml', + ], + [ + '/tests/fixtures/module.info.yml', + '/modules/module.info.yml', + ], + [], + ], + 'Duplicate info.yml files in stage not present in active' => [ + [], + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + ValidationResult::createError([ + 'The staging directory has 2 instances of module.info.yml. This likely indicates that a duplicate extension was installed.', + ]), + ], + ], + 'Duplicate info.yml files in active' => [ + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + '/module.info.yml', + ], + [], + ], + 'Same number of info.yml files in active and stage' => [ + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [], + ], + 'Multiple duplicate info.yml files in stage' => [ + [ + '/module1.info.yml', + '/module2.info.yml', + ], + [ + '/module1.info.yml', + '/modules/module1.info.yml', + '/module2.info.yml', + '/modules/module2.info.yml', + '/module2/module2.info.yml', + ], + [ + ValidationResult::createError([ + 'The staging directory has 2 instances of module1.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.', + ]), + ValidationResult::createError([ + 'The staging directory has 3 instances of module2.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.', + ]), + ], + ], + 'Multiple duplicate info.yml files in stage not present in active' => [ + [], + [ + '/module1.info.yml', + '/modules/module1.info.yml', + '/module2.info.yml', + '/modules/module2.info.yml', + '/module2/module2.info.yml', + ], + [ + ValidationResult::createError([ + 'The staging directory has 2 instances of module1.info.yml. This likely indicates that a duplicate extension was installed.', + ]), + ValidationResult::createError([ + 'The staging directory has 3 instances of module2.info.yml. This likely indicates that a duplicate extension was installed.', + ]), + ], + ], + 'Multiple duplicate info.yml files in stage with one info.yml file not present in active' => [ + [ + '/module1.info.yml', + ], + [ + '/module1.info.yml', + '/modules/module1.info.yml', + '/module2.info.yml', + '/modules/module2.info.yml', + '/module2/module2.info.yml', + ], + [ + ValidationResult::createError([ + 'The staging directory has 2 instances of module1.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.', + ]), + ValidationResult::createError([ + 'The staging directory has 3 instances of module2.info.yml. This likely indicates that a duplicate extension was installed.', + ]), + ], + ], + ]; + } + + /** + * Tests that duplicate info.yml in stage raise an error. + * + * @param string[] $active_info_files + * An array of info.yml files in active directory. + * @param string[] $stage_info_files + * An array of info.yml files in stage directory. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * An array of expected results. + * + * @dataProvider providerDuplicateInfoFilesInStage + */ + public function testDuplicateInfoFilesInStage(array $active_info_files, array $stage_info_files, array $expected_results): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['composer/semver:^3']); + + $active_dir = $this->container->get('package_manager.path_locator') + ->getProjectRoot(); + $stage_dir = $stage->getStageDirectory(); + foreach ($active_info_files as $active_info_file) { + $this->createFileAtPath($active_dir, $active_info_file); + } + foreach ($stage_info_files as $stage_info_file) { + $this->createFileAtPath($stage_dir, $stage_info_file); + } + try { + $stage->apply(); + $this->assertEmpty($expected_results); + } + catch (StageValidationException $e) { + $this->assertNotEmpty($expected_results); + $this->assertValidationResultsEqual($expected_results, $e->getResults()); + } + } + + /** + * Creates the file at the root directory. + * + * @param string $root_directory + * The base directory in which the file will be created. + * @param string $file_path + * The path of the file to create. + */ + private function createFileAtPath(string $root_directory, string $file_path): void { + $parts = explode(DIRECTORY_SEPARATOR, $file_path); + $filename = array_pop($parts); + $file_dir = str_replace($filename, '', $file_path); + $fs = new Filesystem(); + if (!file_exists($file_dir)) { + $fs->mkdir($root_directory . $file_dir); + } + file_put_contents($root_directory . $file_path, ' '); + } + +}