Commit 7f45a8ab authored by Adam G-H's avatar Adam G-H
Browse files

Issue #3298444 by phenaproxima, omkar.podey: Symlink validator should delegate...

Issue #3298444 by phenaproxima, omkar.podey: Symlink validator should delegate to Composer Stager's precondition
parent 612c1947
Loading
Loading
Loading
Loading
+3 −1
Original line number Diff line number Diff line
@@ -105,8 +105,10 @@ services:
  package_manager.validator.symlink:
    class: Drupal\package_manager\Validator\SymlinkValidator
    arguments:
      - '@module_handler'
      - '@package_manager.path_locator'
      - '@PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface'
      - '@PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface'
      - '@module_handler'
    tags:
      - { name: event_subscriber }
  package_manager.validator.duplicate_info_file:
+1 −2
Original line number Diff line number Diff line
@@ -56,8 +56,7 @@ final class GitExcluder implements EventSubscriberInterface {
      ->directories()
      ->name('.git')
      ->ignoreVCS(FALSE)
      ->ignoreDotFiles(FALSE)
      ->ignoreUnreadableDirs();
      ->ignoreDotFiles(FALSE);

    $paths = [];
    foreach ($finder as $git_directory) {
+66 −92
Original line number Diff line number Diff line
@@ -10,7 +10,9 @@ use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\Finder\Finder;
use PhpTuf\ComposerStager\Domain\Exception\PreconditionException;
use PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface;
use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface;

/**
 * Flags errors if the project root or staging area contain symbolic links.
@@ -27,13 +29,6 @@ class SymlinkValidator implements PreOperationStageValidatorInterface {

  use StringTranslationTrait;

  /**
   * The module handler service.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * The path locator service.
   *
@@ -42,105 +37,73 @@ class SymlinkValidator implements PreOperationStageValidatorInterface {
  protected $pathLocator;

  /**
   * Constructs a SymlinkValidator object.
   * The Composer Stager precondition that this validator wraps.
   *
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler service.
   * @param \Drupal\package_manager\PathLocator $path_locator
   *   The path locator service.
   */
  public function __construct(ModuleHandlerInterface $module_handler, PathLocator $path_locator) {
    $this->moduleHandler = $module_handler;
    $this->pathLocator = $path_locator;
  }

  /**
   * {@inheritdoc}
   * @var \PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface
   */
  public function validateStagePreOperation(PreOperationStageEvent $event): void {
    $dir = $this->pathLocator->getProjectRoot();

    if ($this->hasLinks($dir)) {
      $this->addError('Symbolic links were found in the active directory, which are not supported at this time.', $event);
    }
  }
  protected $precondition;

  /**
   * Checks if the staging area has any symbolic links.
   * The path factory service.
   *
   * @param \Drupal\package_manager\Event\PreApplyEvent $event
   *   The event object.
   * @var \PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface
   */
  public function preApply(PreApplyEvent $event): void {
    $dir = $event->getStage()->getStageDirectory();

    if ($this->hasLinks($dir)) {
      $this->addError('Symbolic links were found in the staging area, which are not supported at this time.', $event);
    }
  }
  protected $pathFactory;

  /**
   * Recursively checks if a directory has any symbolic links.
   *
   * @param string $dir
   *   The path of the directory to check.
   * The module handler service.
   *
   * @return bool
   *   TRUE if the directory contains any symbolic links, FALSE otherwise.
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected function hasLinks(string $dir): bool {
    // Finder::filter() explicitly requires a closure, so create one from
    // ::isLink() so that we can still override it for testing purposes.
    $is_link = \Closure::fromCallable([$this, 'isLink']);

    // Finder::hasResults() is more efficient than count() because it will
    // return early if there is a match.
    return Finder::create()
      ->in($dir)
      ->filter($is_link)
      ->ignoreUnreadableDirs()
      ->hasResults();
  }
  protected $moduleHandler;

  /**
   * Checks if a file or directory is a symbolic link.
   *
   * @param \SplFileInfo $file
   *   A value object for the file or directory.
   * Constructs a SymlinkValidator object.
   *
   * @return bool
   *   TRUE if the file or directory is a symbolic link, FALSE otherwise.
   * @param \Drupal\package_manager\PathLocator $path_locator
   *   The path locator service.
   * @param \PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface $precondition
   *   The Composer Stager precondition that this validator wraps.
   * @param \PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface $path_factory
   *   The path factory service.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler service.
   */
  protected function isLink(\SplFileInfo $file): bool {
    return $file->isLink();
  public function __construct(PathLocator $path_locator, CodebaseContainsNoSymlinksInterface $precondition, PathFactoryInterface $path_factory, ModuleHandlerInterface $module_handler) {
    $this->pathLocator = $path_locator;
    $this->precondition = $precondition;
    $this->pathFactory = $path_factory;
    $this->moduleHandler = $module_handler;
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    return [
      PreCreateEvent::class => 'validateStagePreOperation',
      StatusCheckEvent::class => 'validateStagePreOperation',
      PreApplyEvent::class => [
        ['validateStagePreOperation'],
        ['preApply'],
      ],
    ];
  public function validateStagePreOperation(PreOperationStageEvent $event): void {
    $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());

    // The precondition requires us to pass both an active and stage directory,
    // so if the stage hasn't been created or claimed yet, use the directory
    // that contains this file, which contains only a few files and no symlinks,
    // as the stage directory. The precondition itself doesn't care if the
    // directory actually exists or not.
    try {
      $stage_dir = $event->getStage()->getStageDirectory();
    }
    catch (\LogicException $e) {
      $stage_dir = __DIR__;
    }
    $stage_dir = $this->pathFactory->create($stage_dir);

  /**
   * Adds a validation error to a given event.
   *
   * @param string $message
   *   The error message. If the Help module is enabled, a link to Package
   *   Manager's help page will be appended.
   * @param \Drupal\package_manager\Event\PreApplyEvent|\Drupal\package_manager\Event\PreOperationStageEvent $event
   *   The event to add the error to.
   *
   * @see package_manager_help()
   */
  protected function addError(string $message, $event): void {
    try {
      $this->precondition->assertIsFulfilled($active_dir, $stage_dir);
    }
    catch (PreconditionException $e) {
      $message = $e->getMessage();

      // If the Help module is enabled, append a link to Package Manager's help
      // page.
      // @see package_manager_help()
      if ($this->moduleHandler->moduleExists('help')) {
        $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
          ->setOption('fragment', 'package-manager-faq-symlinks-found')
@@ -151,8 +114,19 @@ class SymlinkValidator implements PreOperationStageValidatorInterface {
          ':package-manager-help' => $url,
        ]);
      }

      $event->addError([$message]);
    }
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    return [
      PreCreateEvent::class => 'validateStagePreOperation',
      PreApplyEvent::class => 'validateStagePreOperation',
      StatusCheckEvent::class => 'validateStagePreOperation',
    ];
  }

}
+0 −24
Original line number Diff line number Diff line
@@ -2,7 +2,6 @@

namespace Drupal\Tests\package_manager\Kernel\PathExcluder;

use Drupal\package_manager_bypass\Beginner;
use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;

/**
@@ -23,29 +22,6 @@ class GitExcluderTest extends PackageManagerKernelTestBase {
    parent::setUp();
  }

  /**
   * Tests that unreadable directories are ignored by the event subscriber.
   */
  public function testUnreadableDirectoriesAreIgnored(): void {
    $active_dir = $this->container->get('package_manager.path_locator')
      ->getProjectRoot();

    // Create an unreadable directory within the active directory, which will
    // raise an exception as the event subscriber tries to scan for .git
    // directories...unless unreadable directories are being ignored, as they
    // should be.
    $unreadable_dir = $active_dir . '/unreadable';
    mkdir($unreadable_dir, 0000);
    $this->assertDirectoryIsNotReadable($unreadable_dir);

    // Don't mirror the active directory into the virtual staging area, since
    // the active directory contains an unreadable directory which will cause
    // an exception.
    Beginner::setFixturePath(NULL);

    $this->createStage()->create();
  }

  /**
   * Tests that Git directories are excluded from staging operations.
   */
+51 −132
Original line number Diff line number Diff line
@@ -5,9 +5,10 @@ namespace Drupal\Tests\package_manager\Kernel;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Url;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Exception\StageValidationException;
use Drupal\package_manager\ValidationResult;
use Drupal\package_manager\Validator\SymlinkValidator;
use PhpTuf\ComposerStager\Domain\Exception\PreconditionException;
use PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface;
use Prophecy\Argument;

/**
 * @covers \Drupal\package_manager\Validator\SymlinkValidator
@@ -17,144 +18,75 @@ use Drupal\package_manager\Validator\SymlinkValidator;
class SymlinkValidatorTest extends PackageManagerKernelTestBase {

  /**
   * {@inheritdoc}
   * The mocked precondition that checks for symlinks.
   *
   * @var \PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface|\Prophecy\Prophecy\ObjectProphecy
   */
  public function register(ContainerBuilder $container) {
    parent::register($container);
  private $precondition;

    $container->getDefinition('package_manager.validator.symlink')
      ->setClass(TestSymlinkValidator::class);
  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    $this->precondition = $this->prophesize(CodebaseContainsNoSymlinksInterface::class);
    parent::setUp();
  }

  /**
   * Tests that a symlink in the project root raises an error.
   * {@inheritdoc}
   */
  public function testSymlinkInProjectRoot(): void {
    $result = ValidationResult::createError([
      'Symbolic links were found in the active directory, which are not supported at this time.',
    ]);

    $active_dir = $this->container->get('package_manager.path_locator')
      ->getProjectRoot();
    // @see \Drupal\Tests\package_manager\Kernel\TestSymlinkValidator::isLink()
    touch($active_dir . '/modules/a_link');
    $this->assertStatusCheckResults([$result]);
    $this->assertResults([$result], PreCreateEvent::class);
  public function register(ContainerBuilder $container) {
    parent::register($container);

    $this->enableModules(['help']);
    $this->assertStatusCheckResults($this->addHelpTextToResults([$result]));
    $this->assertResultsWithHelp([$result], PreCreateEvent::class);
    $container->getDefinition('package_manager.validator.symlink')
      ->setArgument('$precondition', $this->precondition->reveal());
  }

  /**
   * Tests that a symlink in the staging area raises an error.
   * Data provider for ::testSymlink().
   *
   * @dataProvider providerHelpEnabledOrNot
   * @return array[]
   *   The test cases.
   */
  public function testSymlinkInStagingArea(bool $enable_help): void {
    $expected_results = [ValidationResult::createError([
        'Symbolic links were found in the staging area, which are not supported at this time.',
      ]),
  public function providerSymlink(): array {
    return [
      'no symlinks' => [FALSE],
      'symlinks' => [TRUE],
    ];

    if ($enable_help) {
      $this->enableModules(['help']);
      $expected_results = $this->addHelpTextToResults($expected_results);
    }

    $stage = $this->createStage();
    $stage->create();
    $stage->require(['composer/semver:^3']);

    // @see \Drupal\Tests\package_manager\Kernel\TestSymlinkValidator::isLink()
    touch($stage->getStageDirectory() . '/modules/a_link');

    try {
      $stage->apply();
      $this->fail('Expected a validation error.');
    }
    catch (StageValidationException $e) {
      $this->assertValidationResultsEqual($expected_results, $e->getResults());
    }
  }

  /**
   * Tests that symlinks in the project root and staging area raise an error.
   * Tests that the validator invokes Composer Stager's symlink precondition.
   *
   * @param bool $symlinks_exist
   *   Whether or not the precondition will detect symlinks.
   *
   * @dataProvider providerHelpEnabledOrNot
   * @dataProvider providerSymlink
   */
  public function testSymlinkInProjectRootAndStagingArea(bool $enable_help): void {
  public function testSymlink(bool $symlinks_exist): void {
    $arguments = Argument::cetera();
    // The precondition should always be invoked.
    $this->precondition->assertIsFulfilled($arguments)->shouldBeCalled();

    if ($symlinks_exist) {
      $exception = new PreconditionException($this->precondition->reveal(), 'Symlinks were found.');
      $this->precondition->assertIsFulfilled($arguments)->willThrow($exception);

      $expected_results = [
        ValidationResult::createError([
        'Symbolic links were found in the active directory, which are not supported at this time.',
      ]),
      ValidationResult::createError([
        'Symbolic links were found in the staging area, which are not supported at this time.',
          $exception->getMessage(),
        ]),
      ];

    if ($enable_help) {
      $this->enableModules(['help']);
      $expected_results = $this->addHelpTextToResults($expected_results);
    }

    $stage = $this->createStage();
    $stage->create();
    $stage->require(['composer/semver:^3']);

    $active_dir = $this->container->get('package_manager.path_locator')
      ->getProjectRoot();
    // @see \Drupal\Tests\package_manager\Kernel\TestSymlinkValidator::isLink()
    touch($active_dir . '/modules/a_link');
    touch($stage->getStageDirectory() . '/modules/a_link');

    try {
      $stage->apply();
      $this->fail('Expected a validation error.');
    }
    catch (StageValidationException $e) {
      $this->assertValidationResultsEqual($expected_results, $e->getResults());
    }
    else {
      $expected_results = [];
    }

  /**
   * Data provider for test methods that test with and without the Help module.
   *
   * @return array[]
   *   The test cases.
   */
  public function providerHelpEnabledOrNot() {
    return [
      'help_module_enabled' => [TRUE],
      'help_module_disabled' => [FALSE],
    ];
  }

  /**
   * Asserts that a set of validation results link to the Package Manager help.
   *
   * @param \Drupal\package_manager\ValidationResult[] $expected_results
   *   The expected validation 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.
   */
  private function assertResultsWithHelp(array $expected_results, string $event_class = NULL): void {
    $expected_results = $this->addHelpTextToResults($expected_results);
    $this->assertStatusCheckResults($expected_results);
    $this->assertResults($expected_results, $event_class);
  }
    $this->assertResults($expected_results, PreCreateEvent::class);

    $this->enableModules(['help']);

  /**
   * Adds help text to results messages.
   *
   * @param \Drupal\package_manager\ValidationResult[] $results
   *   The expected validation results.
   *
   * @return array
   *   The new results.
   */
  public function addHelpTextToResults(array $results): array {
    $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
      ->setOption('fragment', 'package-manager-faq-symlinks-found')
      ->toString();
@@ -164,25 +96,12 @@ class SymlinkValidatorTest extends PackageManagerKernelTestBase {
    $map = function (string $message) use ($url): string {
      return $message . ' See <a href="' . $url . '">the help page</a> for information on how to resolve the problem.';
    };
    foreach ($results as $index => $result) {
    foreach ($expected_results as $index => $result) {
      $messages = array_map($map, $result->getMessages());
      $results[$index] = ValidationResult::createError($messages);
    }
    return $results;
  }

      $expected_results[$index] = ValidationResult::createError($messages);
    }

/**
 * A test validator that considers anything named 'a_link' to be a symlink.
 */
class TestSymlinkValidator extends SymlinkValidator {

  /**
   * {@inheritdoc}
   */
  protected function isLink(\SplFileInfo $file): bool {
    return $file->getBasename() === 'a_link' || parent::isLink($file);
    $this->assertStatusCheckResults($expected_results);
    $this->assertResults($expected_results, PreCreateEvent::class);
  }

}