From 66cbcf2f2f6c6b0d08f822df3950f29c8a3b1f6c Mon Sep 17 00:00:00 2001
From: phenaproxima <phenaproxima@205645.no-reply.drupal.org>
Date: Thu, 9 Jun 2022 17:20:07 +0000
Subject: [PATCH] Issue #3274892 by phenaproxima, tedbow: Before applying the
 update, check that any needed changes to the site directory are possible

---
 automatic_updates.services.yml                |   6 +
 .../fake-site/vendor/composer/installed.json  |   7 +-
 .../tests/src/Functional/UpdaterFormTest.php  |   5 +
 .../sites/default/default.services.yml        |   2 +
 .../sites/default/default.settings.php        |   6 +
 .../fake_site/vendor/composer/installed.json  |  10 +-
 .../ScaffoldFilePermissionsValidator.php      | 127 +++++++
 .../fake-site/vendor/composer/installed.json  |   7 +-
 .../new_project_added/active.installed.json   |   7 +-
 .../new_project_added/staged.installed.json   |   7 +-
 .../no_errors/active.installed.json           |   7 +-
 .../no_errors/staged.installed.json           |   7 +-
 .../project_removed/active.installed.json     |   7 +-
 .../project_removed/staged.installed.json     |   7 +-
 .../version_changed/active.installed.json     |   7 +-
 .../version_changed/staged.installed.json     |   7 +-
 .../Functional/ReadinessValidationTest.php    |  11 +-
 tests/src/Functional/UpdaterFormTest.php      |   5 +-
 tests/src/Kernel/CronUpdaterTest.php          |   1 +
 .../ReadinessValidationManagerTest.php        |  15 +-
 .../ScaffoldFilePermissionsValidatorTest.php  | 346 ++++++++++++++++++
 .../StagedProjectsValidatorTest.php           |  12 +-
 22 files changed, 592 insertions(+), 24 deletions(-)
 create mode 100644 package_manager/tests/fixtures/fake_site/sites/default/default.services.yml
 create mode 100644 package_manager/tests/fixtures/fake_site/sites/default/default.settings.php
 create mode 100644 src/Validator/ScaffoldFilePermissionsValidator.php
 create mode 100644 tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php

diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index a5e5545c3a..8ef9946555 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -125,3 +125,9 @@ services:
     class: Drupal\automatic_updates\EventSubscriber\ConfigSubscriber
     tags:
       - { name: event_subscriber }
+  automatic_updates.validator.scaffold_file_permissions:
+    class: Drupal\automatic_updates\Validator\ScaffoldFilePermissionsValidator
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
diff --git a/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json b/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json
index dffe8ff002..630abfad5d 100644
--- a/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json
+++ b/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json
@@ -9,7 +9,12 @@
         },
         {
             "name": "drupal/core",
-            "version": "9.8.0"
+            "version": "9.8.0",
+            "extra": {
+                "drupal-scaffold": {
+                    "file-mapping": {}
+                }
+            }
         },
         {
             "name": "drupal/my_module",
diff --git a/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php b/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
index b4212ed7cc..8fd85a0a45 100644
--- a/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
+++ b/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
@@ -135,6 +135,11 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
    * @dataProvider providerSuccessfulUpdate
    */
   public function testSuccessfulUpdate(bool $maintenance_mode_on, string $project_name, string $installed_version, string $target_version): void {
+    // Disable the scaffold file permissions validator because it will try to
+    // read composer.json from the staging area, which won't exist because
+    // Package Manager is bypassed.
+    $this->disableValidators(['automatic_updates.validator.scaffold_file_permissions']);
+
     $this->updateProject = $project_name;
     $this->setReleaseMetadata(__DIR__ . '/../../../../tests/fixtures/release-history/drupal.9.8.2.xml');
     $this->setReleaseMetadata(__DIR__ . "/../../fixtures/release-history/$project_name.1.1.xml");
diff --git a/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml b/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml
new file mode 100644
index 0000000000..95dde1725d
--- /dev/null
+++ b/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml
@@ -0,0 +1,2 @@
+# This file should be staged because it's scaffolded into place by Drupal core.
+services: {}
diff --git a/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php b/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php
new file mode 100644
index 0000000000..0d23e84006
--- /dev/null
+++ b/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should be staged because it's scaffolded into place by Drupal core.
+ */
diff --git a/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json b/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
index ad9a32852d..bc3f936e44 100644
--- a/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
+++ b/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
@@ -2,7 +2,15 @@
   "packages": [
     {
       "name": "drupal/core",
-      "version": "9.8.0"
+      "version": "9.8.0",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {
+            "[web-root]/sites/default/default.settings.php": "",
+            "[web-root]/sites/default/default.services.yml": ""
+          }
+        }
+      }
     }
   ]
 }
diff --git a/src/Validator/ScaffoldFilePermissionsValidator.php b/src/Validator/ScaffoldFilePermissionsValidator.php
new file mode 100644
index 0000000000..307ccb940c
--- /dev/null
+++ b/src/Validator/ScaffoldFilePermissionsValidator.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator;
+
+use Drupal\automatic_updates\Event\ReadinessCheckEvent;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\ComposerUtility;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\Validator\PreOperationStageValidatorInterface;
+
+/**
+ * Validates that scaffold files have appropriate permissions.
+ */
+class ScaffoldFilePermissionsValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Constructs a SiteDirectoryPermissionsValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    $paths = [];
+
+    // Figure out the absolute path of `sites/default`.
+    $site_dir = $this->pathLocator->getProjectRoot();
+    $web_root = $this->pathLocator->getWebRoot();
+    if ($web_root) {
+      $site_dir .= '/' . $web_root;
+    }
+    $site_dir .= '/sites/default';
+
+    $stage = $event->getStage();
+    $active_scaffold_files = $this->getDefaultSiteFilesFromScaffold($stage->getActiveComposer());
+
+    // If the active directory and staging area have different files scaffolded
+    // into `sites/default` (i.e., files were added, renamed, or deleted), the
+    // site directory itself must be writable for the changes to be applied.
+    if ($event instanceof PreApplyEvent) {
+      $staged_scaffold_files = $this->getDefaultSiteFilesFromScaffold($stage->getStageComposer());
+
+      if ($active_scaffold_files !== $staged_scaffold_files) {
+        $paths[] = $site_dir;
+      }
+    }
+    // The scaffolded files themselves must be writable, so that any changes to
+    // them in the staging area can be synced back to the active directory.
+    foreach ($active_scaffold_files as $scaffold_file) {
+      $paths[] = $site_dir . '/' . $scaffold_file;
+    }
+
+    // Flag messages about anything in $paths which exists, but isn't writable.
+    $non_writable_files = array_filter($paths, function (string $path): bool {
+      return file_exists($path) && !is_writable($path);
+    });
+    if ($non_writable_files) {
+      // Re-key the messages in order to prevent false negative comparisons in
+      // tests.
+      $non_writable_files = array_values($non_writable_files);
+      $event->addError($non_writable_files, $this->t('The following paths must be writable in order to update default site configuration files.'));
+    }
+  }
+
+  /**
+   * Returns the list of file names scaffolded into `sites/default`.
+   *
+   * @param \Drupal\package_manager\ComposerUtility $composer
+   *   A Composer utility helper for a directory.
+   *
+   * @return string[]
+   *   The names of files that are scaffolded into `sites/default`, stripped
+   *   of the preceding path. For example,
+   *   `[web-root]/sites/default/default.settings.php` will be
+   *   `default.settings.php`. Will be sorted alphabetically. If the target
+   *   directory doesn't have the `drupal/core` package installed, the returned
+   *   array will be empty.
+   */
+  protected function getDefaultSiteFilesFromScaffold(ComposerUtility $composer): array {
+    $installed = $composer->getInstalledPackages();
+
+    if (array_key_exists('drupal/core', $installed)) {
+      $extra = $installed['drupal/core']->getExtra();
+      // We expect Drupal core to provide a list of scaffold files.
+      $files = $extra['drupal-scaffold']['file-mapping'];
+    }
+    else {
+      $files = [];
+    }
+    $files = array_keys($files);
+    $files = preg_grep('/sites\/default\//', $files);
+    $files = array_map('basename', $files);
+    sort($files);
+
+    return $files;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ReadinessCheckEvent::class => 'validateStagePreOperation',
+      PreCreateEvent::class => 'validateStagePreOperation',
+      PreApplyEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/tests/fixtures/fake-site/vendor/composer/installed.json b/tests/fixtures/fake-site/vendor/composer/installed.json
index c8ad1ba32e..75bdd28cd5 100644
--- a/tests/fixtures/fake-site/vendor/composer/installed.json
+++ b/tests/fixtures/fake-site/vendor/composer/installed.json
@@ -16,7 +16,12 @@
         },
         {
             "name": "drupal/core",
-            "version": "9.8.0"
+            "version": "9.8.0",
+            "extra": {
+                "drupal-scaffold": {
+                    "file-mapping": {}
+                }
+            }
         },
         {
             "name": "drupal/core-dev",
diff --git a/tests/fixtures/project_staged_validation/new_project_added/active.installed.json b/tests/fixtures/project_staged_validation/new_project_added/active.installed.json
index 56d81ccad1..1f6de85d07 100644
--- a/tests/fixtures/project_staged_validation/new_project_added/active.installed.json
+++ b/tests/fixtures/project_staged_validation/new_project_added/active.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/new_project_added/staged.installed.json b/tests/fixtures/project_staged_validation/new_project_added/staged.installed.json
index b0c0c6d63e..b0bdf7c1a4 100644
--- a/tests/fixtures/project_staged_validation/new_project_added/staged.installed.json
+++ b/tests/fixtures/project_staged_validation/new_project_added/staged.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/no_errors/active.installed.json b/tests/fixtures/project_staged_validation/no_errors/active.installed.json
index 65a8de94c6..6b93669c33 100644
--- a/tests/fixtures/project_staged_validation/no_errors/active.installed.json
+++ b/tests/fixtures/project_staged_validation/no_errors/active.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/no_errors/staged.installed.json b/tests/fixtures/project_staged_validation/no_errors/staged.installed.json
index f395558489..ee87bd5e9e 100644
--- a/tests/fixtures/project_staged_validation/no_errors/staged.installed.json
+++ b/tests/fixtures/project_staged_validation/no_errors/staged.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/project_removed/active.installed.json b/tests/fixtures/project_staged_validation/project_removed/active.installed.json
index 4ec3e126c9..6e2ecb6b9f 100644
--- a/tests/fixtures/project_staged_validation/project_removed/active.installed.json
+++ b/tests/fixtures/project_staged_validation/project_removed/active.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_theme",
diff --git a/tests/fixtures/project_staged_validation/project_removed/staged.installed.json b/tests/fixtures/project_staged_validation/project_removed/staged.installed.json
index 100c033c34..c44eb0104d 100644
--- a/tests/fixtures/project_staged_validation/project_removed/staged.installed.json
+++ b/tests/fixtures/project_staged_validation/project_removed/staged.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module2",
diff --git a/tests/fixtures/project_staged_validation/version_changed/active.installed.json b/tests/fixtures/project_staged_validation/version_changed/active.installed.json
index e7bb050139..cb7c3ca5ef 100644
--- a/tests/fixtures/project_staged_validation/version_changed/active.installed.json
+++ b/tests/fixtures/project_staged_validation/version_changed/active.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/version_changed/staged.installed.json b/tests/fixtures/project_staged_validation/version_changed/staged.installed.json
index 62a4b1dc72..e457476ace 100644
--- a/tests/fixtures/project_staged_validation/version_changed/staged.installed.json
+++ b/tests/fixtures/project_staged_validation/version_changed/staged.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/src/Functional/ReadinessValidationTest.php b/tests/src/Functional/ReadinessValidationTest.php
index 96eab4ae97..942d28f22b 100644
--- a/tests/src/Functional/ReadinessValidationTest.php
+++ b/tests/src/Functional/ReadinessValidationTest.php
@@ -402,10 +402,13 @@ class ReadinessValidationTest extends AutomaticUpdatesFunctionalTestBase {
       'package_manager_test_fixture',
     ]);
     // Because all actual staging operations are bypassed by
-    // package_manager_bypass (enabled by the parent class), disable this
-    // validator because it will complain if there's no actual Composer data to
-    // inspect.
-    $this->disableValidators(['automatic_updates.staged_projects_validator']);
+    // package_manager_bypass (enabled by the parent class), disable these
+    // validators because they will complain if there's no actual Composer data
+    // to inspect.
+    $this->disableValidators([
+      'automatic_updates.staged_projects_validator',
+      'automatic_updates.validator.scaffold_file_permissions',
+    ]);
 
     // The error should be persistently visible, even after the checker stops
     // flagging it.
diff --git a/tests/src/Functional/UpdaterFormTest.php b/tests/src/Functional/UpdaterFormTest.php
index f73f37443f..7bbb2254bc 100644
--- a/tests/src/Functional/UpdaterFormTest.php
+++ b/tests/src/Functional/UpdaterFormTest.php
@@ -43,9 +43,10 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
    */
   protected function setUp(): void {
     // In this test class, all actual staging operations are bypassed by
-    // package_manager_bypass, which means this validator will complain because
-    // there is no actual Composer data for it to inspect.
+    // package_manager_bypass, which means these validators will complain
+    // because there is no actual Composer data for them to inspect.
     $this->disableValidators[] = 'automatic_updates.staged_projects_validator';
+    $this->disableValidators[] = 'automatic_updates.validator.scaffold_file_permissions';
 
     parent::setUp();
 
diff --git a/tests/src/Kernel/CronUpdaterTest.php b/tests/src/Kernel/CronUpdaterTest.php
index 2e70847acd..93edcf19b7 100644
--- a/tests/src/Kernel/CronUpdaterTest.php
+++ b/tests/src/Kernel/CronUpdaterTest.php
@@ -57,6 +57,7 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase {
     // they attempt to compare the active and stage directories.
     $this->disableValidators[] = 'automatic_updates.validator.staged_database_updates';
     $this->disableValidators[] = 'automatic_updates.staged_projects_validator';
+    $this->disableValidators[] = 'automatic_updates.validator.scaffold_file_permissions';
     parent::setUp();
 
     $this->logger = new TestLogger();
diff --git a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
index 4094b1f2de..8976c67436 100644
--- a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
+++ b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
@@ -238,11 +238,16 @@ class ReadinessValidationManagerTest extends AutomaticUpdatesKernelTestBase {
     // results should be stored.
     $this->assertValidationResultsEqual($results, $manager->getResults());
 
-    // Don't validate staged projects because actual staging operations are
-    // bypassed by package_manager_bypass, which will make this validator
-    // complain that there is no actual Composer data for it to inspect.
-    $validator = $this->container->get('automatic_updates.staged_projects_validator');
-    $this->container->get('event_dispatcher')->removeSubscriber($validator);
+    // Don't validate staged projects or scaffold file permissions because
+    // actual staging operations are bypassed by package_manager_bypass, which
+    // will make these validators complain that there is no actual Composer data
+    // for them to inspect.
+    $validators = array_map([$this->container, 'get'], [
+      'automatic_updates.staged_projects_validator',
+      'automatic_updates.validator.scaffold_file_permissions',
+    ]);
+    $event_dispatcher = $this->container->get('event_dispatcher');
+    array_walk($validators, [$event_dispatcher, 'removeSubscriber']);
 
     /** @var \Drupal\automatic_updates\Updater $updater */
     $updater = $this->container->get('automatic_updates.updater');
diff --git a/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php b/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php
new file mode 100644
index 0000000000..d77ce05b3a
--- /dev/null
+++ b/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php
@@ -0,0 +1,346 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation;
+
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\ScaffoldFilePermissionsValidator
+ *
+ * @group automatic_updates
+ */
+class ScaffoldFilePermissionsValidatorTest extends AutomaticUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['automatic_updates'];
+
+  /**
+   * The active directory of the virtual project.
+   *
+   * @var string
+   */
+  private $activeDir;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->createTestProject();
+    $this->activeDir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertValidationResultsEqual(array $expected_results, array $actual_results): void {
+    $map = function (string $path): string {
+      return $this->activeDir . '/' . $path;
+    };
+    foreach ($expected_results as $i => $result) {
+      // Prepend the active directory to every path listed in the error result,
+      // and add the expected summary.
+      $messages = array_map($map, $result->getMessages());
+      $expected_results[$i] = ValidationResult::createError($messages, t('The following paths must be writable in order to update default site configuration files.'));
+    }
+    parent::assertValidationResultsEqual($expected_results, $actual_results);
+  }
+
+  /**
+   * Write-protects a set of paths in the active directory.
+   *
+   * @param string[] $paths
+   *   The paths to write-protect, relative to the active directory.
+   */
+  private function writeProtect(array $paths): void {
+    foreach ($paths as $path) {
+      $path = $this->activeDir . '/' . $path;
+      chmod($path, 0400);
+      $this->assertFileIsNotWritable($path, "Failed to write-protect $path.");
+    }
+  }
+
+  /**
+   * Data provider for ::testPermissionsBeforeStart().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerPermissionsBeforeStart(): array {
+    return [
+      'write-protected scaffold file, writable site directory' => [
+        ['sites/default/default.settings.php'],
+        [
+          ValidationResult::createError(['sites/default/default.settings.php']),
+        ],
+      ],
+      // Whether the site directory is write-protected only matters during
+      // pre-apply, because it only presents a problem if scaffold files have
+      // been added or removed in the staging area. Which is a condition we can
+      // only detect during pre-apply.
+      'write-protected scaffold file and site directory' => [
+        [
+          'sites/default/default.settings.php',
+          'sites/default',
+        ],
+        [
+          ValidationResult::createError(['sites/default/default.settings.php']),
+        ],
+      ],
+      'write-protected site directory' => [
+        ['sites/default'],
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that scaffold file permissions are checked before an update begins.
+   *
+   * @param string[] $write_protected_paths
+   *   A list of paths, relative to the project root, which should be write
+   *   protected before staged changes are applied.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results, if any.
+   *
+   * @dataProvider providerPermissionsBeforeStart
+   */
+  public function testPermissionsBeforeStart(array $write_protected_paths, array $expected_results): void {
+    $this->writeProtect($write_protected_paths);
+    $this->assertCheckerResultsFromManager($expected_results, TRUE);
+
+    try {
+      $this->container->get('automatic_updates.updater')
+        ->begin(['drupal' => '9.8.1']);
+
+      // If no exception was thrown, ensure that we weren't expecting an error.
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+    }
+  }
+
+  /**
+   * Data provider for ::testScaffoldFilesChanged().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerScaffoldFilesChanged(): array {
+    // The summary is always replaced by ::assertValidationResultsEqual(), so
+    // if there's more than one message in a result, just give it a mocked
+    // summary object to prevent an exception.
+    $summary = $this->prophesize('\Drupal\Core\StringTranslation\TranslatableMarkup')
+      ->reveal();
+
+    return [
+      // If no scaffold files are changed, it doesn't matter if the site
+      // directory is writable.
+      'no scaffold changes, site directory not writable' => [
+        ['sites/default'],
+        [],
+        [],
+        [],
+      ],
+      'no scaffold changes, site directory writable' => [
+        [],
+        [],
+        [],
+        [],
+      ],
+      // If scaffold files are added or deleted in the site directory, the site
+      // directory must be writable.
+      'new scaffold file added to non-writable site directory' => [
+        ['sites/default'],
+        [],
+        [
+          '[web-root]/sites/default/new.txt' => '',
+        ],
+        [
+          ValidationResult::createError(['sites/default']),
+        ],
+      ],
+      'new scaffold file added to writable site directory' => [
+        [],
+        [],
+        [
+          '[web-root]/sites/default/new.txt' => '',
+        ],
+        [],
+      ],
+      'writable scaffold file removed from non-writable site directory' => [
+        ['sites/default'],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [
+          ValidationResult::createError(['sites/default']),
+        ],
+      ],
+      'writable scaffold file removed from writable site directory' => [
+        [],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'non-writable scaffold file removed from non-writable site directory' => [
+        [
+          // The file must be made write-protected before the site directory is,
+          // or the permissions change will fail.
+          'sites/default/deleted.txt',
+          'sites/default',
+        ],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [
+          ValidationResult::createError(['sites/default', 'sites/default/deleted.txt'], $summary),
+        ],
+      ],
+      'non-writable scaffold file removed from writable site directory' => [
+        ['sites/default/deleted.txt'],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [
+          ValidationResult::createError(['sites/default/deleted.txt']),
+        ],
+      ],
+      // If only scaffold files outside the site directory changed, the
+      // validator doesn't care if the site directory is writable.
+      'new scaffold file added outside non-writable site directory' => [
+        ['sites/default'],
+        [],
+        [
+          '[web-root]/foo.html' => '',
+        ],
+        [],
+      ],
+      'new scaffold file added outside writable site directory' => [
+        [],
+        [],
+        [
+          '[web-root]/foo.html' => '',
+        ],
+        [],
+      ],
+      'writable scaffold file removed outside non-writable site directory' => [
+        ['sites/default'],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'writable scaffold file removed outside writable site directory' => [
+        [],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'non-writable scaffold file removed outside non-writable site directory' => [
+        [
+          'sites/default',
+          'foo.txt',
+        ],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'non-writable scaffold file removed outside writable site directory' => [
+        ['foo.txt'],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests site directory permissions are checked before changes are applied.
+   *
+   * @param string[] $write_protected_paths
+   *   A list of paths, relative to the project root, which should be write
+   *   protected before staged changes are applied.
+   * @param string[] $active_scaffold_files
+   *   An array simulating the extra.drupal-scaffold.file-mapping section of the
+   *   active drupal/core package.
+   * @param string[] $staged_scaffold_files
+   *   An array simulating the extra.drupal-scaffold.file-mapping section of the
+   *   staged drupal/core package.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results, if any.
+   *
+   * @dataProvider providerScaffoldFilesChanged
+   */
+  public function testScaffoldFilesChanged(array $write_protected_paths, array $active_scaffold_files, array $staged_scaffold_files, array $expected_results): void {
+    // Create fake scaffold files so we can test scenarios in which a scaffold
+    // file that exists in the active directory is deleted in the staging area.
+    touch($this->activeDir . '/sites/default/deleted.txt');
+    touch($this->activeDir . '/foo.txt');
+
+    // Simulate updating Drupal core. This will copy the active directory into
+    // the (virtual) staging area.
+    // @see ::createTestProject()
+    // @see \Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager::copyFilesFromFixture()
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
+
+    // Rewrite the active and staged installed.json files, inserting the given
+    // lists of scaffold files.
+    $installed = [
+      'packages' => [
+        [
+          'name' => 'drupal/core',
+          'version' => \Drupal::VERSION,
+          'extra' => [
+            'drupal-scaffold' => [
+              'file_mapping' => [],
+            ],
+          ],
+        ],
+      ],
+    ];
+    // Since the list of scaffold files is in a deeply nested array, reference
+    // it for readability.
+    $scaffold_files = &$installed['packages'][0]['extra']['drupal-scaffold']['file-mapping'];
+
+    // Change the list of scaffold files in the active and stage directories.
+    $scaffold_files = $active_scaffold_files;
+    file_put_contents($this->activeDir . '/vendor/composer/installed.json', json_encode($installed));
+    $scaffold_files = $staged_scaffold_files;
+    file_put_contents($updater->getStageDirectory() . '/vendor/composer/installed.json', json_encode($installed));
+
+    $this->writeProtect($write_protected_paths);
+
+    try {
+      $updater->apply();
+
+      // If no exception was thrown, ensure that we weren't expecting an error.
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
index f393369997..7268428389 100644
--- a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
+++ b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
@@ -62,14 +62,22 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
    * Tests that exceptions are turned into validation errors.
    */
   public function testEventConsumesExceptionResults(): void {
+    /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher */
+    $event_dispatcher = $this->container->get('event_dispatcher');
+
     // Just before the staged changes are applied, delete the composer.json file
     // to trigger an error. This uses the highest possible priority to guarantee
     // it runs before any other subscribers.
     $listener = function (): void {
       unlink("$this->activeDir/composer.json");
     };
-    $this->container->get('event_dispatcher')
-      ->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
+    $event_dispatcher->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
+
+    // Disable the scaffold file permissions validator because it will try to
+    // read composer.json from the active directory, which won't exist thanks to
+    // the event listener we just added.
+    $validator = $this->container->get('automatic_updates.validator.scaffold_file_permissions');
+    $event_dispatcher->removeSubscriber($validator);
 
     $result = ValidationResult::createError([
       "Composer could not find the config file: $this->activeDir/composer.json\n",
-- 
GitLab