Skip to content
Snippets Groups Projects
Commit 3d1bd218 authored by Adam G-H's avatar Adam G-H Committed by Ted Bowman
Browse files

Issue #3408835: SqliteDatabaseExcluder accidentally includes the database in...

Issue #3408835: SqliteDatabaseExcluder accidentally includes the database in stage operations if it is located inside the web root
parent eed233ca
No related branches found
No related tags found
1 merge request!994Resolve #3408835 "Sqlitedatabaseexcluder accidentally includes"
...@@ -85,6 +85,9 @@ final class CollectPathsToExcludeEvent extends StageEvent implements PathListInt ...@@ -85,6 +85,9 @@ final class CollectPathsToExcludeEvent extends StageEvent implements PathListInt
* The paths to ignore. Absolute paths will be made relative to the project * 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 * root; relative paths will be assumed to already be relative to the
* project root, and ignored as given. * 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 { public function addPathsRelativeToProjectRoot(array $paths): void {
$project_root = $this->pathLocator->getProjectRoot(); $project_root = $this->pathLocator->getProjectRoot();
......
...@@ -6,7 +6,7 @@ namespace Drupal\package_manager\PathExcluder; ...@@ -6,7 +6,7 @@ namespace Drupal\package_manager\PathExcluder;
use Drupal\Core\Database\Connection; use Drupal\Core\Database\Connection;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent; use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\PathLocator; use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/** /**
...@@ -22,16 +22,14 @@ class SqliteDatabaseExcluder implements EventSubscriberInterface { ...@@ -22,16 +22,14 @@ class SqliteDatabaseExcluder implements EventSubscriberInterface {
/** /**
* Constructs a SqliteDatabaseExcluder object. * Constructs a SqliteDatabaseExcluder object.
* *
* @param \Drupal\package_manager\PathLocator $pathLocator * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory
* The path locator service. * The path factory service.
* @param \Drupal\Core\Database\Connection $database * @param \Drupal\Core\Database\Connection $database
* The database connection. * The database connection.
*/ */
public function __construct( public function __construct(
private readonly PathLocator $pathLocator, private readonly PathFactoryInterface $pathFactory,
// TRICKY: this cannot be private nor readonly for testing purposes. private readonly Connection $database,
// @see \Drupal\Tests\package_manager\Kernel\PathExcluder\SqliteDatabaseExcluderTest::mockDatabase()
protected Connection $database
) {} ) {}
/** /**
...@@ -50,19 +48,27 @@ class SqliteDatabaseExcluder implements EventSubscriberInterface { ...@@ -50,19 +48,27 @@ class SqliteDatabaseExcluder implements EventSubscriberInterface {
* The event object. * The event object.
*/ */
public function excludeDatabaseFiles(CollectPathsToExcludeEvent $event): void { public function excludeDatabaseFiles(CollectPathsToExcludeEvent $event): void {
// If the database is SQLite, it might be located in the active directory // If the database is SQLite, it might be located in the project directory
// and we should exclude it. Always treat it as relative to the project root. // and we should exclude it.
if ($this->database->driver() === 'sqlite') { if ($this->database->driver() === 'sqlite') {
$options = $this->database->getConnectionOptions(); $db_path = $this->database->getConnectionOptions()['database'];
// Nothing to exclude if the database lives outside the project root. // Exclude the database file and auxiliary files created by SQLite.
if (str_starts_with($options['database'], '/') && !str_starts_with($options['database'], $this->pathLocator->getProjectRoot())) { $paths = [$db_path, "$db_path-shm", "$db_path-wal"];
return;
// 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',
]);
} }
} }
......
...@@ -11,6 +11,7 @@ use Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder; ...@@ -11,6 +11,7 @@ use Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder;
use Drupal\package_manager\PathLocator; use Drupal\package_manager\PathLocator;
use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use Prophecy\Prophecy\ObjectProphecy;
/** /**
* @covers \Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder * @covers \Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder
...@@ -20,161 +21,130 @@ use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; ...@@ -20,161 +21,130 @@ use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
class SqliteDatabaseExcluderTest extends PackageManagerKernelTestBase { class SqliteDatabaseExcluderTest extends PackageManagerKernelTestBase {
/** /**
* {@inheritdoc} * The mocked database connection.
*/
public function register(ContainerBuilder $container) {
parent::register($container);
$container->getDefinition(SqliteDatabaseExcluder::class)
->setClass(TestSqliteDatabaseExcluder::class);
}
/**
* Mocks a SQLite database connection for the event subscriber.
* *
* @param array $connection_options * @var \Drupal\Core\Database\Connection|\Prophecy\Prophecy\ObjectProphecy
* The connection options for the mocked database connection.
*/ */
private function mockDatabase(array $connection_options): void { private Connection|ObjectProphecy $mockDatabase;
$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();
}
/** /**
* Tests that SQLite database files are excluded from stage operations. * {@inheritdoc}
*/ */
public function testSqliteDatabaseFilesExcluded(): void { public function register(ContainerBuilder $container) {
// In this test, we want to perform the actual stage operations so that we parent::register($container);
// 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();
$excluded = [ $this->mockDatabase = $this->prophesize(Connection::class);
"sites/example.com/db.sqlite", $this->mockDatabase->driver()
"sites/example.com/db.sqlite-shm", ->willReturn('sqlite')
"sites/example.com/db.sqlite-wal", ->shouldBeCalled();
];
foreach ($excluded as $path) {
$this->assertFileExists("$active_dir/$path");
$this->assertFileDoesNotExist("$stage_dir/$path");
}
$stage->apply(); $container->getDefinition(SqliteDatabaseExcluder::class)
// The excluded files should still be in the active directory. ->setArgument('$database', $this->mockDatabase->reveal());
foreach ($excluded as $path) {
$this->assertFileExists("$active_dir/$path");
}
} }
/** /**
* Data provider for testPathProcessing(). * Data provider for ::testSqliteDatabaseFilesExcluded().
* *
* @return string[][] * @return array[]
* The test cases. * The test cases.
*/ */
public function providerPathProcessing(): array { public function providerSqliteDatabaseFilesExcluded(): array {
return [ return [
'relative path, in site directory' => [ // If the database is at a relative path, it should be excluded relative
'sites/example.com/db.sqlite', // to the web root.
[ 'relative path in relocated web root' => [
'sites/example.com/db.sqlite', 'www',
'sites/example.com/db.sqlite-shm', 'db.sqlite',
'sites/example.com/db.sqlite-wal', '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',
'db.sqlite-shm',
'db.sqlite-wal',
],
], ],
'absolute path, in site directory' => [ // If the database is outside the project root, the excluder doesn't need
'/sites/example.com/db.sqlite', // to do anything.
[ 'absolute path outside of project, relocated web root' => [
'sites/example.com/db.sqlite', 'www',
'sites/example.com/db.sqlite-shm', '/path/to/database.sqlite',
'sites/example.com/db.sqlite-wal', FALSE,
],
], ],
'absolute path, at root' => [ 'absolute path outside of project, web root is project root' => [
'/db.sqlite', '',
[ '/path/to/database.sqlite',
'db.sqlite', FALSE,
'db.sqlite-shm',
'db.sqlite-wal',
],
], ],
]; ];
} }
/** /**
* Tests SQLite database path processing. * Tests that SQLite database files are excluded from stage operations.
*
* 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.
* *
* @param string $database_path * @param string $web_root
* The path of the SQLite database, as set in the database connection * The web root that should be returned by the path locator. See
* options. If it begins with a slash, it will be prefixed with the path of * \Drupal\package_manager\PathLocator::getWebRoot().
* the active directory. * @param string $db_path
* @param string[] $expected_exclusions * The path of the SQLite database, as it should be reported by the database
* The database paths which should be flagged for exclusion. * 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_locator = $this->container->get(PathLocator::class);
$path_factory = $this->container->get(PathFactoryInterface::class); $project_root = $path_locator->getProjectRoot();
// If the database path should be treated as absolute, prefix it with the
// path of the active directory. // Set the mocked web root, keeping everything else as-is.
if (str_starts_with($database_path, '/')) { $path_locator->setPaths(
$database_path = $path_locator->getProjectRoot() . $database_path; $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;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment