From 3d1bd21880e52ec8a2e79bccaf1b2e58b27839f8 Mon Sep 17 00:00:00 2001 From: Adam G-H <32250-phenaproxima@users.noreply.drupalcode.org> Date: Fri, 15 Dec 2023 03:03:51 +0000 Subject: [PATCH] Issue #3408835: SqliteDatabaseExcluder accidentally includes the database in stage operations if it is located inside the web root --- .../src/Event/CollectPathsToExcludeEvent.php | 3 + .../PathExcluder/SqliteDatabaseExcluder.php | 42 ++-- .../SqliteDatabaseExcluderTest.php | 224 ++++++++---------- 3 files changed, 124 insertions(+), 145 deletions(-) diff --git a/package_manager/src/Event/CollectPathsToExcludeEvent.php b/package_manager/src/Event/CollectPathsToExcludeEvent.php index f6e6c65dab..59c9c8f438 100644 --- a/package_manager/src/Event/CollectPathsToExcludeEvent.php +++ b/package_manager/src/Event/CollectPathsToExcludeEvent.php @@ -85,6 +85,9 @@ final class CollectPathsToExcludeEvent extends StageEvent implements PathListInt * The paths to ignore. Absolute paths will be made relative to the project * root; relative paths will be assumed to already be relative to the * project root, and ignored as given. + * + * @throws \LogicException + * If any of the given paths are absolute, but not inside the project root. */ public function addPathsRelativeToProjectRoot(array $paths): void { $project_root = $this->pathLocator->getProjectRoot(); diff --git a/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php b/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php index cfcf1fc95b..f21f74fde8 100644 --- a/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php +++ b/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php @@ -6,7 +6,7 @@ namespace Drupal\package_manager\PathExcluder; use Drupal\Core\Database\Connection; use Drupal\package_manager\Event\CollectPathsToExcludeEvent; -use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** @@ -22,16 +22,14 @@ class SqliteDatabaseExcluder implements EventSubscriberInterface { /** * Constructs a SqliteDatabaseExcluder object. * - * @param \Drupal\package_manager\PathLocator $pathLocator - * The path locator service. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory + * The path factory service. * @param \Drupal\Core\Database\Connection $database * The database connection. */ public function __construct( - private readonly PathLocator $pathLocator, - // TRICKY: this cannot be private nor readonly for testing purposes. - // @see \Drupal\Tests\package_manager\Kernel\PathExcluder\SqliteDatabaseExcluderTest::mockDatabase() - protected Connection $database + private readonly PathFactoryInterface $pathFactory, + private readonly Connection $database, ) {} /** @@ -50,19 +48,27 @@ class SqliteDatabaseExcluder implements EventSubscriberInterface { * The event object. */ public function excludeDatabaseFiles(CollectPathsToExcludeEvent $event): void { - // If the database is SQLite, it might be located in the active directory - // and we should exclude it. Always treat it as relative to the project root. + // If the database is SQLite, it might be located in the project directory + // and we should exclude it. if ($this->database->driver() === 'sqlite') { - $options = $this->database->getConnectionOptions(); - // Nothing to exclude if the database lives outside the project root. - if (str_starts_with($options['database'], '/') && !str_starts_with($options['database'], $this->pathLocator->getProjectRoot())) { - return; + $db_path = $this->database->getConnectionOptions()['database']; + // Exclude the database file and auxiliary files created by SQLite. + $paths = [$db_path, "$db_path-shm", "$db_path-wal"]; + + // If the database path is absolute, it might be outside the project root, + // in which case we don't need to do anything. + if ($this->pathFactory->create($db_path)->isAbsolute()) { + try { + $event->addPathsRelativeToProjectRoot($paths); + } + catch (\LogicException) { + // The database is outside of the project root, so we're done. + } + } + else { + // The database is in the web root, and must be excluded relative to it. + $event->addPathsRelativeToWebRoot($paths); } - $event->addPathsRelativeToProjectRoot([ - $options['database'], - $options['database'] . '-shm', - $options['database'] . '-wal', - ]); } } diff --git a/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php b/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php index 37d3fb1223..7ef8a0b615 100644 --- a/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php +++ b/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php @@ -11,6 +11,7 @@ use Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder; use Drupal\package_manager\PathLocator; use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Prophecy\Prophecy\ObjectProphecy; /** * @covers \Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder @@ -20,161 +21,130 @@ use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; class SqliteDatabaseExcluderTest extends PackageManagerKernelTestBase { /** - * {@inheritdoc} - */ - public function register(ContainerBuilder $container) { - parent::register($container); - - $container->getDefinition(SqliteDatabaseExcluder::class) - ->setClass(TestSqliteDatabaseExcluder::class); - } - - /** - * Mocks a SQLite database connection for the event subscriber. + * The mocked database connection. * - * @param array $connection_options - * The connection options for the mocked database connection. + * @var \Drupal\Core\Database\Connection|\Prophecy\Prophecy\ObjectProphecy */ - private function mockDatabase(array $connection_options): void { - $database = $this->prophesize(Connection::class); - $database->driver()->willReturn('sqlite'); - $database->getConnectionOptions()->willReturn($connection_options); - - /** @var \Drupal\Tests\package_manager\Kernel\PathExcluder\TestSiteConfigurationExcluder $sqlite_excluder */ - $sqlite_excluder = $this->container->get(SqliteDatabaseExcluder::class); - $sqlite_excluder->database = $database->reveal(); - } + private Connection|ObjectProphecy $mockDatabase; /** - * Tests that SQLite database files are excluded from stage operations. + * {@inheritdoc} */ - public function testSqliteDatabaseFilesExcluded(): void { - // In this test, we want to perform the actual stage operations so that we - // can be sure that files are staged as expected. - $this->setSetting('package_manager_bypass_composer_stager', FALSE); - // Ensure we have an up-to-date container. - $this->container = $this->container->get('kernel')->rebuildContainer(); - - $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); - - // Mock a SQLite database connection to a file in the active directory. The - // file should not be staged. - $this->mockDatabase([ - 'database' => 'sites/example.com/db.sqlite', - ]); - - $stage = $this->createStage(); - $stage->create(); - $stage->require(['ext-json:*']); - $stage_dir = $stage->getStageDirectory(); + public function register(ContainerBuilder $container) { + parent::register($container); - $excluded = [ - "sites/example.com/db.sqlite", - "sites/example.com/db.sqlite-shm", - "sites/example.com/db.sqlite-wal", - ]; - foreach ($excluded as $path) { - $this->assertFileExists("$active_dir/$path"); - $this->assertFileDoesNotExist("$stage_dir/$path"); - } + $this->mockDatabase = $this->prophesize(Connection::class); + $this->mockDatabase->driver() + ->willReturn('sqlite') + ->shouldBeCalled(); - $stage->apply(); - // The excluded files should still be in the active directory. - foreach ($excluded as $path) { - $this->assertFileExists("$active_dir/$path"); - } + $container->getDefinition(SqliteDatabaseExcluder::class) + ->setArgument('$database', $this->mockDatabase->reveal()); } /** - * Data provider for testPathProcessing(). + * Data provider for ::testSqliteDatabaseFilesExcluded(). * - * @return string[][] + * @return array[] * The test cases. */ - public function providerPathProcessing(): array { + public function providerSqliteDatabaseFilesExcluded(): array { return [ - 'relative path, in site directory' => [ - 'sites/example.com/db.sqlite', - [ - 'sites/example.com/db.sqlite', - 'sites/example.com/db.sqlite-shm', - 'sites/example.com/db.sqlite-wal', - ], + // If the database is at a relative path, it should be excluded relative + // to the web root. + 'relative path in relocated web root' => [ + 'www', + 'db.sqlite', + 'www/db.sqlite', + ], + 'relative path, web root is project root' => [ + '', + 'db.sqlite', + 'db.sqlite', + ], + // If the database is at an absolute path in the project root, it should + // be excluded relative to the project root. + 'absolute path in relocated web root' => [ + 'www', + '<PROJECT_ROOT>/www/db.sqlite', + 'www/db.sqlite', ], - 'relative path, at root' => [ + 'absolute path, web root is project root' => [ + '', + '<PROJECT_ROOT>/db.sqlite', 'db.sqlite', - [ - 'db.sqlite', - 'db.sqlite-shm', - 'db.sqlite-wal', - ], ], - 'absolute path, in site directory' => [ - '/sites/example.com/db.sqlite', - [ - 'sites/example.com/db.sqlite', - 'sites/example.com/db.sqlite-shm', - 'sites/example.com/db.sqlite-wal', - ], + // If the database is outside the project root, the excluder doesn't need + // to do anything. + 'absolute path outside of project, relocated web root' => [ + 'www', + '/path/to/database.sqlite', + FALSE, ], - 'absolute path, at root' => [ - '/db.sqlite', - [ - 'db.sqlite', - 'db.sqlite-shm', - 'db.sqlite-wal', - ], + 'absolute path outside of project, web root is project root' => [ + '', + '/path/to/database.sqlite', + FALSE, ], ]; } /** - * Tests SQLite database path processing. - * - * This test ensures that SQLite database paths are processed properly (e.g., - * converting an absolute path to a relative path) before being flagged for - * exclusion. + * Tests that SQLite database files are excluded from stage operations. * - * @param string $database_path - * The path of the SQLite database, as set in the database connection - * options. If it begins with a slash, it will be prefixed with the path of - * the active directory. - * @param string[] $expected_exclusions - * The database paths which should be flagged for exclusion. + * @param string $web_root + * The web root that should be returned by the path locator. See + * \Drupal\package_manager\PathLocator::getWebRoot(). + * @param string $db_path + * The path of the SQLite database, as it should be reported by the database + * connection. This can be a relative or absolute path; it does not need to + * actually exist. + * @param string|false $expected_excluded_path + * The path to the database, as it should be given to + * CollectPathsToExcludeEvent. If FALSE, the database is located outside the + * project and therefore is not excluded. * - * @dataProvider providerPathProcessing + * @dataProvider providerSqliteDatabaseFilesExcluded */ - public function testPathProcessing(string $database_path, array $expected_exclusions): void { + public function testSqliteDatabaseFilesExcluded(string $web_root, string $db_path, string|false $expected_excluded_path): void { + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ $path_locator = $this->container->get(PathLocator::class); - $path_factory = $this->container->get(PathFactoryInterface::class); - // If the database path should be treated as absolute, prefix it with the - // path of the active directory. - if (str_starts_with($database_path, '/')) { - $database_path = $path_locator->getProjectRoot() . $database_path; + $project_root = $path_locator->getProjectRoot(); + + // Set the mocked web root, keeping everything else as-is. + $path_locator->setPaths( + $project_root, + $path_locator->getVendorDirectory(), + $web_root, + $path_locator->getStagingRoot(), + ); + $db_path = str_replace('<PROJECT_ROOT>', $project_root, $db_path); + $this->mockDatabase->getConnectionOptions() + ->willReturn(['database' => $db_path]) + ->shouldBeCalled(); + + $event = new CollectPathsToExcludeEvent( + $this->createStage(), + $path_locator, + $this->container->get(PathFactoryInterface::class), + ); + $actual_excluded_paths = $this->container->get('event_dispatcher') + ->dispatch($event) + ->getAll(); + + if (is_string($expected_excluded_path)) { + $expected_exclusions = [ + $expected_excluded_path, + $expected_excluded_path . '-shm', + $expected_excluded_path . '-wal', + ]; + $this->assertEmpty(array_diff($expected_exclusions, $actual_excluded_paths)); + } + else { + // The path of the database should not appear anywhere in the list of + // excluded paths. + $this->assertStringNotContainsString($db_path, serialize($actual_excluded_paths)); } - $this->mockDatabase([ - 'database' => $database_path, - ]); - - $event = new CollectPathsToExcludeEvent($this->createStage(), $path_locator, $path_factory); - // Invoke the event subscriber directly, so we can check that the database - // was correctly excluded. - $this->container->get(SqliteDatabaseExcluder::class) - ->excludeDatabaseFiles($event); - // All of the expected exclusions should be flagged. - $this->assertEquals($expected_exclusions, $event->getAll()); } } - -/** - * A test-only version of the SQLite database excluder, to expose internals. - */ -class TestSqliteDatabaseExcluder extends SqliteDatabaseExcluder { - - /** - * {@inheritdoc} - */ - public Connection $database; - -} -- GitLab