diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index c5487a34cb7624fd0a8dcc143eebf71856bf6dc1..7b703499e3246d8558d9a8db6523fca05669eb62 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -14,9 +14,11 @@ services: arguments: ['@automatic_updates.composer_runner', '@automatic_updates.file_system' ] automatic_updates.cleaner: - class: PhpTuf\ComposerStager\Domain\Cleaner + class: Drupal\automatic_updates\ComposerStager\Cleaner arguments: - ['@automatic_updates.file_system' ] + - '@automatic_updates.file_system' + - '%site.path%' + - '@automatic_updates.path_locator' automatic_updates.committer: class: PhpTuf\ComposerStager\Domain\Committer arguments: @@ -90,3 +92,4 @@ services: class: Drupal\automatic_updates\PathLocator arguments: - '@config.factory' + - '%app.root%' diff --git a/src/ComposerStager/Cleaner.php b/src/ComposerStager/Cleaner.php new file mode 100644 index 0000000000000000000000000000000000000000..718681fb70b9e298553308c80f8a5f75b96c507e --- /dev/null +++ b/src/ComposerStager/Cleaner.php @@ -0,0 +1,81 @@ +<?php + +namespace Drupal\automatic_updates\ComposerStager; + +use Drupal\automatic_updates\PathLocator; +use PhpTuf\ComposerStager\Domain\Cleaner as ComposerStagerCleaner; +use PhpTuf\ComposerStager\Domain\CleanerInterface; +use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; +use PhpTuf\ComposerStager\Infrastructure\Filesystem\FilesystemInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Defines a cleaner service that makes the staged site directory writable. + */ +class Cleaner implements CleanerInterface { + + /** + * The decorated cleaner service. + * + * @var \PhpTuf\ComposerStager\Domain\CleanerInterface + */ + protected $decorated; + + /** + * The current site path, without leading or trailing slashes. + * + * @var string + */ + protected $sitePath; + + /** + * The path locator service. + * + * @var \Drupal\automatic_updates\PathLocator + */ + protected $pathLocator; + + /** + * Constructs a Cleaner object. + * + * @param \PhpTuf\ComposerStager\Infrastructure\Filesystem\FilesystemInterface $file_system + * The Composer Stager file system service. + * @param string $site_path + * The current site path (e.g., 'sites/default'), without leading or + * trailing slashes. + * @param \Drupal\automatic_updates\PathLocator $locator + * The path locator service. + */ + public function __construct(FilesystemInterface $file_system, string $site_path, PathLocator $locator) { + // @todo Inject the decorated cleaner once the Composer Stager wiring has + // been moved into a separate module. + $this->decorated = new ComposerStagerCleaner($file_system); + $this->sitePath = $site_path; + $this->pathLocator = $locator; + } + + /** + * {@inheritdoc} + */ + public function clean(string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + // Ensure that the staged site directory is writable so we can delete it. + $site_dir = implode(DIRECTORY_SEPARATOR, [ + $stagingDir, + $this->pathLocator->getWebRoot() ?: '.', + $this->sitePath, + ]); + + if ($this->directoryExists($site_dir)) { + (new Filesystem())->chmod($site_dir, 0777); + } + $this->decorated->clean($stagingDir, $callback, $timeout); + } + + /** + * {@inheritdoc} + */ + public function directoryExists(string $stagingDir): bool { + return $this->decorated->directoryExists($stagingDir); + } + +} diff --git a/src/PathLocator.php b/src/PathLocator.php index ed1bdad7843ad7306b7ce658d0e0708889b439ac..dfc56c412ef86d4e9f475f3b24313da0f7a4a02b 100644 --- a/src/PathLocator.php +++ b/src/PathLocator.php @@ -18,14 +18,24 @@ class PathLocator { */ protected $configFactory; + /** + * The absolute path of the running Drupal code base. + * + * @var string + */ + protected $appRoot; + /** * Constructs a PathLocator object. * * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The config factory service. + * @param string $app_root + * The absolute path of the running Drupal code base. */ - public function __construct(ConfigFactoryInterface $config_factory) { + public function __construct(ConfigFactoryInterface $config_factory, string $app_root) { $this->configFactory = $config_factory; + $this->appRoot = $app_root; } /** @@ -41,8 +51,13 @@ class PathLocator { /** * Returns the path of the directory where updates should be staged. * + * This directory may be made world-writeable for clean-up, so it should be + * somewhere that doesn't put the Drupal installation at risk. + * * @return string * The absolute path of the directory where updates should be staged. + * + * @see \Drupal\automatic_updates\ComposerStager\Cleaner::clean() */ public function getStageDirectory(): string { // Append the site ID to the directory in order to support parallel test @@ -76,4 +91,17 @@ class PathLocator { return dirname($reflector->getFileName(), 2); } + /** + * Returns the path of the Drupal installation, relative to the project root. + * + * @return string + * The path of the Drupal installation, relative to the project root and + * without leading or trailing slashes. Will return an empty string if the + * project root and Drupal root are the same. + */ + public function getWebRoot(): string { + $web_root = str_replace($this->getProjectRoot(), NULL, $this->appRoot); + return trim($web_root, DIRECTORY_SEPARATOR); + } + } diff --git a/tests/src/Functional/ExclusionsTest.php b/tests/src/Functional/ExclusionsTest.php deleted file mode 100644 index d427dc69f24f0bc95112528b9293900a00c44cec..0000000000000000000000000000000000000000 --- a/tests/src/Functional/ExclusionsTest.php +++ /dev/null @@ -1,88 +0,0 @@ -<?php - -namespace Drupal\Tests\automatic_updates\Functional; - -use Drupal\automatic_updates\PathLocator; -use Drupal\automatic_updates\Updater; -use Drupal\Core\Site\Settings; -use Drupal\Tests\BrowserTestBase; - -/** - * Tests exclusion of certain files and directories from the staging area. - * - * @group automatic_updates - */ -class ExclusionsTest extends BrowserTestBase { - - /** - * {@inheritdoc} - */ - protected static $modules = ['automatic_updates_test', 'update_test']; - - /** - * {@inheritdoc} - */ - protected $defaultTheme = 'stark'; - - /** - * Tests that certain files and directories are not staged. - * - * @covers \Drupal\automatic_updates\Updater::getExclusions - */ - public function testExclusions(): void { - $stage_dir = "$this->siteDirectory/stage"; - - /** @var \Drupal\automatic_updates\PathLocator|\Prophecy\Prophecy\ObjectProphecy $locator */ - $locator = $this->prophesize(PathLocator::class); - $locator->getActiveDirectory()->willReturn(__DIR__ . '/../../fixtures/fake-site'); - $locator->getStageDirectory()->willReturn($stage_dir); - $locator->getProjectRoot()->willReturn($this->getDrupalRoot()); - - $updater = new Updater( - $this->container->get('state'), - $this->container->get('string_translation'), - $this->container->get('automatic_updates.beginner'), - $this->container->get('automatic_updates.stager'), - $this->container->get('automatic_updates.cleaner'), - $this->container->get('automatic_updates.committer'), - $this->container->get('event_dispatcher'), - $locator->reveal() - ); - - $settings = Settings::getAll(); - $settings['file_public_path'] = 'files/public'; - $settings['file_private_path'] = 'files/private'; - new Settings($settings); - - // Updater::begin() will trigger update validators, such as - // \Drupal\automatic_updates\Event\UpdateVersionSubscriber, that need to - // fetch release metadata. We need to ensure that those HTTP request(s) - // succeed, so set them up to point to our fake release metadata. - $this->config('update_test.settings') - ->set('xml_map', [ - 'drupal' => '0.0', - ]) - ->save(); - $this->config('update.settings') - ->set('fetch.url', $this->baseUrl . '/automatic-update-test') - ->save(); - $this->config('update_test.settings') - ->set('system_info.#all.version', '9.8.0') - ->save(); - - $updater->begin(['drupal' => '9.8.1']); - $this->assertFileDoesNotExist("$stage_dir/sites/default/settings.php"); - $this->assertFileDoesNotExist("$stage_dir/sites/default/settings.local.php"); - $this->assertFileDoesNotExist("$stage_dir/sites/default/services.yml"); - // A file in sites/default, that isn't one of the site-specific settings - // files, should be staged. - $this->assertFileExists("$stage_dir/sites/default/staged.txt"); - $this->assertDirectoryDoesNotExist("$stage_dir/sites/simpletest"); - $this->assertDirectoryDoesNotExist("$stage_dir/files/public"); - $this->assertDirectoryDoesNotExist("$stage_dir/files/private"); - // A file that's in the general files directory, but not in the public or - // private directories, should be staged. - $this->assertFileExists("$stage_dir/files/staged.txt"); - } - -} diff --git a/tests/src/Functional/FileSystemOperationsTest.php b/tests/src/Functional/FileSystemOperationsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..546d4d651e425e88bae8bcfe1fd025390514b27e --- /dev/null +++ b/tests/src/Functional/FileSystemOperationsTest.php @@ -0,0 +1,144 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Functional; + +use Drupal\automatic_updates\ComposerStager\Cleaner; +use Drupal\automatic_updates\PathLocator; +use Drupal\automatic_updates\Updater; +use Drupal\Core\Site\Settings; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests handling of files and directories during an update. + * + * @group automatic_updates + */ +class FileSystemOperationsTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['automatic_updates_test', 'update_test']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * The updater service under test. + * + * @var \Drupal\automatic_updates\Updater + */ + private $updater; + + /** + * The full path of the staging directory. + * + * @var string + */ + protected $stageDir; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Create a mocked path locator that uses the fake site fixture as its + // active directory, and has a staging area within the site directory for + // this test. + $drupal_root = $this->getDrupalRoot(); + /** @var \Drupal\automatic_updates\PathLocator|\Prophecy\Prophecy\ObjectProphecy $locator */ + $locator = $this->prophesize(PathLocator::class); + $locator->getActiveDirectory()->willReturn(__DIR__ . '/../../fixtures/fake-site'); + $this->stageDir = implode(DIRECTORY_SEPARATOR, [ + $drupal_root, + $this->siteDirectory, + 'stage', + ]); + $locator->getStageDirectory()->willReturn($this->stageDir); + $locator->getProjectRoot()->willReturn($drupal_root); + $locator->getWebRoot()->willReturn(''); + + // Create a cleaner that uses 'sites/default' as its site path, since it + // will otherwise default to the site path being used for the test site, + // which doesn't exist in the fake site fixture. + $cleaner = new Cleaner( + $this->container->get('automatic_updates.file_system'), + 'sites/default', + $locator->reveal() + ); + + $this->updater = new Updater( + $this->container->get('state'), + $this->container->get('string_translation'), + $this->container->get('automatic_updates.beginner'), + $this->container->get('automatic_updates.stager'), + $cleaner, + $this->container->get('automatic_updates.committer'), + $this->container->get('event_dispatcher'), + $locator->reveal() + ); + + // Use the public and private files directories in the fake site fixture. + $settings = Settings::getAll(); + $settings['file_public_path'] = 'files/public'; + $settings['file_private_path'] = 'files/private'; + new Settings($settings); + + // Updater::begin() will trigger update validators, such as + // \Drupal\automatic_updates\Validator\UpdateVersionValidator, that need to + // fetch release metadata. We need to ensure that those HTTP request(s) + // succeed, so set them up to point to our fake release metadata. + $this->config('update_test.settings') + ->set('xml_map', [ + 'drupal' => '0.0', + ]) + ->save(); + $this->config('update.settings') + ->set('fetch.url', $this->baseUrl . '/automatic-update-test') + ->save(); + $this->config('update_test.settings') + ->set('system_info.#all.version', '9.8.0') + ->save(); + } + + /** + * Tests that certain files and directories are not staged. + * + * @covers \Drupal\automatic_updates\Updater::getExclusions + */ + public function testExclusions(): void { + $this->updater->begin(['drupal' => '9.8.1']); + $this->assertFileDoesNotExist("$this->stageDir/sites/default/settings.php"); + $this->assertFileDoesNotExist("$this->stageDir/sites/default/settings.local.php"); + $this->assertFileDoesNotExist("$this->stageDir/sites/default/services.yml"); + // A file in sites/default, that isn't one of the site-specific settings + // files, should be staged. + $this->assertFileExists("$this->stageDir/sites/default/staged.txt"); + $this->assertDirectoryDoesNotExist("$this->stageDir/sites/simpletest"); + $this->assertDirectoryDoesNotExist("$this->stageDir/files/public"); + $this->assertDirectoryDoesNotExist("$this->stageDir/files/private"); + // A file that's in the general files directory, but not in the public or + // private directories, should be staged. + $this->assertFileExists("$this->stageDir/files/staged.txt"); + } + + /** + * Tests that the staging directory is properly cleaned up. + * + * @covers \Drupal\automatic_updates\Cleaner + */ + public function testClean(): void { + $this->updater->begin(['drupal' => '9.8.1']); + // Make the staged site directory read-only, so we can test that it will be + // made writable on clean-up. + $this->assertTrue(chmod("$this->stageDir/sites/default", 0400)); + $this->assertNotIsWritable("$this->stageDir/sites/default/staged.txt"); + // If the site directory is not writable, this will throw an exception. + $this->updater->clean(); + $this->assertDirectoryDoesNotExist($this->stageDir); + } + +}