From ca81aef41e53bd41ab0f68917c8f188b87935c82 Mon Sep 17 00:00:00 2001
From: phenaproxima <phenaproxima@205645.no-reply.drupal.org>
Date: Mon, 11 Apr 2022 17:56:25 +0000
Subject: [PATCH] Issue #3274323 by phenaproxima: Split ExcludedPathsSubscriber
 into multiple classes

---
 package_manager/package_manager.services.yml  |  38 ++-
 .../src/Event/ExcludedPathsTrait.php          |   2 +-
 .../ExcludedPathsSubscriber.php               | 250 ------------------
 .../src/PathExcluder/GitExcluder.php          |  64 +++++
 .../src/PathExcluder/PathExclusionsTrait.php  |  64 +++++
 .../SiteConfigurationExcluder.php             |  88 ++++++
 .../src/PathExcluder/SiteFilesExcluder.php    |  87 ++++++
 .../PathExcluder/SqliteDatabaseExcluder.php   |  68 +++++
 .../src/PathExcluder/TestSiteExcluder.php     |  50 ++++
 .../PathExcluder/VendorHardeningExcluder.php  |  55 ++++
 .../tests/src/Kernel/ExcludedPathsTest.php    | 243 -----------------
 .../Kernel/PathExcluder/GitExcluderTest.php   |  80 ++++++
 .../SiteConfigurationExcluderTest.php         | 104 ++++++++
 .../PathExcluder/SiteFilesExcluderTest.php    |  67 +++++
 .../SqliteDatabaseExcluderTest.php            | 181 +++++++++++++
 .../PathExcluder/TestSiteExcluderTest.php     |  58 ++++
 .../VendorHardeningExcluderTest.php           |  59 +++++
 .../StagedProjectsValidatorTest.php           |   4 +-
 18 files changed, 1062 insertions(+), 500 deletions(-)
 delete mode 100644 package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
 create mode 100644 package_manager/src/PathExcluder/GitExcluder.php
 create mode 100644 package_manager/src/PathExcluder/PathExclusionsTrait.php
 create mode 100644 package_manager/src/PathExcluder/SiteConfigurationExcluder.php
 create mode 100644 package_manager/src/PathExcluder/SiteFilesExcluder.php
 create mode 100644 package_manager/src/PathExcluder/SqliteDatabaseExcluder.php
 create mode 100644 package_manager/src/PathExcluder/TestSiteExcluder.php
 create mode 100644 package_manager/src/PathExcluder/VendorHardeningExcluder.php
 delete mode 100644 package_manager/tests/src/Kernel/ExcludedPathsTest.php
 create mode 100644 package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php
 create mode 100644 package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php
 create mode 100644 package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php
 create mode 100644 package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php
 create mode 100644 package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php
 create mode 100644 package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php

diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml
index 3fa0bfa707..3406f6b5d2 100644
--- a/package_manager/package_manager.services.yml
+++ b/package_manager/package_manager.services.yml
@@ -128,13 +128,43 @@ services:
       - '@string_translation'
     tags:
       - { name: event_subscriber }
-  package_manager.excluded_paths_subscriber:
-    class: Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
+  package_manager.test_site_excluder:
+    class: Drupal\package_manager\PathExcluder\TestSiteExcluder
     arguments:
-      - '%site.path%'
-      - '@package_manager.symfony_file_system'
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
+  package_manager.vendor_hardening_excluder:
+    class: Drupal\package_manager\PathExcluder\VendorHardeningExcluder
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
+  package_manager.site_files_excluder:
+    class: Drupal\package_manager\PathExcluder\SiteFilesExcluder
+    arguments:
+      - '@package_manager.path_locator'
       - '@stream_wrapper_manager'
+      - '@package_manager.symfony_file_system'
+    tags:
+      - { name: event_subscriber }
+  package_manager.sqlite_excluder:
+    class: Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder
+    arguments:
+      - '@package_manager.path_locator'
       - '@database'
+    tags:
+      - { name: event_subscriber }
+  package_manager.git_excluder:
+    class: Drupal\package_manager\PathExcluder\GitExcluder
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
+  package_manager.site_configuration_excluder:
+    class: Drupal\package_manager\PathExcluder\SiteConfigurationExcluder
+    arguments:
+      - '%site.path%'
       - '@package_manager.path_locator'
     tags:
       - { name: event_subscriber }
diff --git a/package_manager/src/Event/ExcludedPathsTrait.php b/package_manager/src/Event/ExcludedPathsTrait.php
index fc7e690440..6e2dfddc31 100644
--- a/package_manager/src/Event/ExcludedPathsTrait.php
+++ b/package_manager/src/Event/ExcludedPathsTrait.php
@@ -31,7 +31,7 @@ trait ExcludedPathsTrait {
    * @param string $path
    *   The path to exclude, relative to the project root.
    *
-   * @see \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
+   * @see \Drupal\package_manager\PathExcluder\SiteConfigurationExcluder
    */
   public function excludePath(string $path): void {
     $this->excludedPaths[] = $path;
diff --git a/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php b/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
deleted file mode 100644
index 5eb7e08811..0000000000
--- a/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
+++ /dev/null
@@ -1,250 +0,0 @@
-<?php
-
-namespace Drupal\package_manager\EventSubscriber;
-
-use Drupal\Core\Database\Connection;
-use Drupal\Core\StreamWrapper\LocalStream;
-use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
-use Drupal\package_manager\Event\PreApplyEvent;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Event\StageEvent;
-use Drupal\package_manager\PathLocator;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\Filesystem\Filesystem;
-use Symfony\Component\Finder\Finder;
-
-/**
- * Defines an event subscriber to exclude certain paths from staging areas.
- */
-class ExcludedPathsSubscriber implements EventSubscriberInterface {
-
-  /**
-   * The current site path, relative to the Drupal root.
-   *
-   * @var string
-   */
-  protected $sitePath;
-
-  /**
-   * The Symfony file system service.
-   *
-   * @var \Symfony\Component\Filesystem\Filesystem
-   */
-  protected $fileSystem;
-
-  /**
-   * The stream wrapper manager service.
-   *
-   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
-   */
-  protected $streamWrapperManager;
-
-  /**
-   * The database connection.
-   *
-   * @var \Drupal\Core\Database\Connection
-   */
-  protected $database;
-
-  /**
-   * The path locator service.
-   *
-   * @var \Drupal\package_manager\PathLocator
-   */
-  protected $pathLocator;
-
-  /**
-   * Constructs an ExcludedPathsSubscriber.
-   *
-   * @param string $site_path
-   *   The current site path, relative to the Drupal root.
-   * @param \Symfony\Component\Filesystem\Filesystem $file_system
-   *   The Symfony file system service.
-   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
-   *   The stream wrapper manager service.
-   * @param \Drupal\Core\Database\Connection $database
-   *   The database connection.
-   * @param \Drupal\package_manager\PathLocator $path_locator
-   *   The path locator service.
-   */
-  public function __construct(string $site_path, Filesystem $file_system, StreamWrapperManagerInterface $stream_wrapper_manager, Connection $database, PathLocator $path_locator) {
-    $this->sitePath = $site_path;
-    $this->fileSystem = $file_system;
-    $this->streamWrapperManager = $stream_wrapper_manager;
-    $this->database = $database;
-    $this->pathLocator = $path_locator;
-  }
-
-  /**
-   * Flags paths to be excluded, relative to the web root.
-   *
-   * This should only be used for paths that, if they exist at all, are
-   * *guaranteed* to exist within the web root.
-   *
-   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
-   *   The event object.
-   * @param string[] $paths
-   *   The paths to exclude. These should be relative to the web root, and will
-   *   be made relative to the project root.
-   */
-  protected function excludeInWebRoot(StageEvent $event, array $paths): void {
-    $web_root = $this->pathLocator->getWebRoot();
-    if ($web_root) {
-      $web_root .= '/';
-    }
-
-    foreach ($paths as $path) {
-      // Make the path relative to the project root by prefixing the web root.
-      $event->excludePath($web_root . $path);
-    }
-  }
-
-  /**
-   * Flags paths to be excluded, relative to the project root.
-   *
-   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
-   *   The event object.
-   * @param string[] $paths
-   *   The paths to exclude. Absolute paths will be made relative to the project
-   *   root; relative paths will be assumed to already be relative to the
-   *   project root, and excluded as given.
-   */
-  protected function excludeInProjectRoot(StageEvent $event, array $paths): void {
-    $project_root = $this->pathLocator->getProjectRoot();
-
-    foreach ($paths as $path) {
-      // Make absolute paths relative to the project root.
-      $path = str_replace($project_root, '', $path);
-      $path = ltrim($path, '/');
-      $event->excludePath($path);
-    }
-  }
-
-  /**
-   * Excludes common paths from staging operations.
-   *
-   * @param \Drupal\package_manager\Event\PreApplyEvent|\Drupal\package_manager\Event\PreCreateEvent $event
-   *   The event object.
-   *
-   * @see \Drupal\package_manager\Event\ExcludedPathsTrait::excludePath()
-   */
-  public function ignoreCommonPaths(StageEvent $event): void {
-    // Compile two lists of paths to exclude: paths that are relative to the
-    // project root, and paths that are relative to the web root.
-    $web = $project = [];
-
-    // Always ignore automated test directories. If they exist, they will be in
-    // the web root.
-    $web[] = 'sites/simpletest';
-
-    // If the core-vendor-hardening plugin (used in the legacy-project template)
-    // is present, it may have written security hardening files in the vendor
-    // directory. They should always be ignored.
-    $vendor_dir = $this->pathLocator->getVendorDirectory();
-    $project[] = $vendor_dir . '/web.config';
-    $project[] = $vendor_dir . '/.htaccess';
-
-    // Ignore public and private files. These paths could be either absolute or
-    // relative, depending on site settings. If they are absolute, treat them
-    // as relative to the project root. Otherwise, treat them as relative to
-    // the web root.
-    $files = array_filter([
-      $this->getFilesPath('public'),
-      $this->getFilesPath('private'),
-    ]);
-    foreach ($files as $path) {
-      if ($this->fileSystem->isAbsolutePath($path)) {
-        $project[] = $path;
-      }
-      else {
-        $web[] = $path;
-      }
-    }
-
-    // Ignore site-specific settings files, which are always in the web root.
-    $settings_files = [
-      'settings.php',
-      'settings.local.php',
-      'services.yml',
-    ];
-    foreach ($settings_files as $settings_file) {
-      $web[] = $this->sitePath . '/' . $settings_file;
-      $web[] = 'sites/default/' . $settings_file;
-    }
-
-    // If the database is SQLite, it might be located in the active directory
-    // and we should ignore it. Always treat it as relative to the project root.
-    if ($this->database->driver() === 'sqlite') {
-      $options = $this->database->getConnectionOptions();
-      $project[] = $options['database'];
-      $project[] = $options['database'] . '-shm';
-      $project[] = $options['database'] . '-wal';
-    }
-
-    // Find all .git directories in the project and exclude them. We cannot do
-    // this with FileSystemInterface::scanDirectory() because it unconditionally
-    // excludes anything starting with a dot.
-    $finder = Finder::create()
-      ->in($this->pathLocator->getProjectRoot())
-      ->directories()
-      ->name('.git')
-      ->ignoreVCS(FALSE)
-      ->ignoreDotFiles(FALSE)
-      ->ignoreUnreadableDirs();
-
-    foreach ($finder as $git_directory) {
-      $project[] = $git_directory->getPathname();
-    }
-
-    $this->excludeInWebRoot($event, $web);
-    $this->excludeInProjectRoot($event, $project);
-  }
-
-  /**
-   * Reacts before staged changes are committed the active directory.
-   *
-   * @param \Drupal\package_manager\Event\PreApplyEvent $event
-   *   The event object.
-   */
-  public function preApply(PreApplyEvent $event): void {
-    // Don't copy anything from the staging area's sites/default.
-    // @todo Make this a lot smarter in https://www.drupal.org/i/3228955.
-    $this->excludeInWebRoot($event, ['sites/default']);
-
-    $this->ignoreCommonPaths($event);
-  }
-
-  /**
-   * Returns the storage path for a stream wrapper.
-   *
-   * This will only work for stream wrappers that extend
-   * \Drupal\Core\StreamWrapper\LocalStream, which includes the stream wrappers
-   * for public and private files.
-   *
-   * @param string $scheme
-   *   The stream wrapper scheme.
-   *
-   * @return string|null
-   *   The storage path for files using the given scheme, relative to the Drupal
-   *   root, or NULL if the stream wrapper does not extend
-   *   \Drupal\Core\StreamWrapper\LocalStream.
-   */
-  private function getFilesPath(string $scheme): ?string {
-    $wrapper = $this->streamWrapperManager->getViaScheme($scheme);
-    if ($wrapper instanceof LocalStream) {
-      return $wrapper->getDirectoryPath();
-    }
-    return NULL;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      PreCreateEvent::class => 'ignoreCommonPaths',
-      PreApplyEvent::class => 'preApply',
-    ];
-  }
-
-}
diff --git a/package_manager/src/PathExcluder/GitExcluder.php b/package_manager/src/PathExcluder/GitExcluder.php
new file mode 100644
index 0000000000..b684a3af56
--- /dev/null
+++ b/package_manager/src/PathExcluder/GitExcluder.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Finder\Finder;
+
+/**
+ * Excludes .git directories from staging operations.
+ */
+class GitExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * Constructs a GitExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeGitDirectories',
+      PreApplyEvent::class => 'excludeGitDirectories',
+    ];
+  }
+
+  /**
+   * Excludes .git directories from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeGitDirectories(StageEvent $event): void {
+    // Find all .git directories in the project and exclude them. We cannot do
+    // this with FileSystemInterface::scanDirectory() because it unconditionally
+    // excludes anything starting with a dot.
+    $finder = Finder::create()
+      ->in($this->pathLocator->getProjectRoot())
+      ->directories()
+      ->name('.git')
+      ->ignoreVCS(FALSE)
+      ->ignoreDotFiles(FALSE)
+      ->ignoreUnreadableDirs();
+
+    $paths = [];
+    foreach ($finder as $git_directory) {
+      $paths[] = $git_directory->getPathname();
+    }
+    $this->excludeInProjectRoot($event, $paths);
+  }
+
+}
diff --git a/package_manager/src/PathExcluder/PathExclusionsTrait.php b/package_manager/src/PathExcluder/PathExclusionsTrait.php
new file mode 100644
index 0000000000..5c841417f6
--- /dev/null
+++ b/package_manager/src/PathExcluder/PathExclusionsTrait.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\StageEvent;
+
+/**
+ * Contains methods for excluding paths from staging operations.
+ */
+trait PathExclusionsTrait {
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Flags paths to be excluded, relative to the web root.
+   *
+   * This should only be used for paths that, if they exist at all, are
+   * *guaranteed* to exist within the web root.
+   *
+   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   * @param string[] $paths
+   *   The paths to exclude. These should be relative to the web root, and will
+   *   be made relative to the project root.
+   */
+  protected function excludeInWebRoot(StageEvent $event, array $paths): void {
+    $web_root = $this->pathLocator->getWebRoot();
+    if ($web_root) {
+      $web_root .= '/';
+    }
+
+    foreach ($paths as $path) {
+      // Make the path relative to the project root by prefixing the web root.
+      $event->excludePath($web_root . $path);
+    }
+  }
+
+  /**
+   * Flags paths to be excluded, relative to the project root.
+   *
+   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   * @param string[] $paths
+   *   The paths to exclude. Absolute paths will be made relative to the project
+   *   root; relative paths will be assumed to already be relative to the
+   *   project root, and excluded as given.
+   */
+  protected function excludeInProjectRoot(StageEvent $event, array $paths): void {
+    $project_root = $this->pathLocator->getProjectRoot();
+
+    foreach ($paths as $path) {
+      // Make absolute paths relative to the project root.
+      $path = str_replace($project_root, '', $path);
+      $path = ltrim($path, '/');
+      $event->excludePath($path);
+    }
+  }
+
+}
diff --git a/package_manager/src/PathExcluder/SiteConfigurationExcluder.php b/package_manager/src/PathExcluder/SiteConfigurationExcluder.php
new file mode 100644
index 0000000000..4ed7a8729b
--- /dev/null
+++ b/package_manager/src/PathExcluder/SiteConfigurationExcluder.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Excludes site configuration files from staging areas.
+ */
+class SiteConfigurationExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * The current site path, relative to the Drupal root.
+   *
+   * @var string
+   */
+  protected $sitePath;
+
+  /**
+   * Constructs an ExcludedPathsSubscriber.
+   *
+   * @param string $site_path
+   *   The current site path, relative to the Drupal root.
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(string $site_path, PathLocator $path_locator) {
+    $this->sitePath = $site_path;
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * Excludes common paths from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\PreApplyEvent|\Drupal\package_manager\Event\PreCreateEvent $event
+   *   The event object.
+   *
+   * @see \Drupal\package_manager\Event\ExcludedPathsTrait::excludePath()
+   */
+  public function ignoreCommonPaths(StageEvent $event): void {
+    // Site configuration files are always excluded relative to the web root.
+    $web = [];
+
+    // Ignore site-specific settings files, which are always in the web root.
+    $settings_files = [
+      'settings.php',
+      'settings.local.php',
+      'services.yml',
+    ];
+    foreach ($settings_files as $settings_file) {
+      $web[] = $this->sitePath . '/' . $settings_file;
+      $web[] = 'sites/default/' . $settings_file;
+    }
+
+    $this->excludeInWebRoot($event, $web);
+  }
+
+  /**
+   * Reacts before staged changes are committed the active directory.
+   *
+   * @param \Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   */
+  public function preApply(PreApplyEvent $event): void {
+    // Don't copy anything from the staging area's sites/default.
+    // @todo Make this a lot smarter in https://www.drupal.org/i/3228955.
+    $this->excludeInWebRoot($event, ['sites/default']);
+
+    $this->ignoreCommonPaths($event);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'ignoreCommonPaths',
+      PreApplyEvent::class => 'preApply',
+    ];
+  }
+
+}
diff --git a/package_manager/src/PathExcluder/SiteFilesExcluder.php b/package_manager/src/PathExcluder/SiteFilesExcluder.php
new file mode 100644
index 0000000000..b15870195c
--- /dev/null
+++ b/package_manager/src/PathExcluder/SiteFilesExcluder.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\Core\StreamWrapper\LocalStream;
+use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * Excludes public and private files from staging operations.
+ */
+class SiteFilesExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * The stream wrapper manager service.
+   *
+   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
+   */
+  protected $streamWrapperManager;
+
+  /**
+   * The Symfony file system service.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * Constructs a SiteFilesExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
+   *   The stream wrapper manager service.
+   * @param \Symfony\Component\Filesystem\Filesystem $file_system
+   *   The Symfony file system service.
+   */
+  public function __construct(PathLocator $path_locator, StreamWrapperManagerInterface $stream_wrapper_manager, Filesystem $file_system) {
+    $this->pathLocator = $path_locator;
+    $this->streamWrapperManager = $stream_wrapper_manager;
+    $this->fileSystem = $file_system;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeSiteFiles',
+      PreApplyEvent::class => 'excludeSiteFiles',
+    ];
+  }
+
+  /**
+   * Excludes public and private files from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeSiteFiles(StageEvent $event): void {
+    // Ignore public and private files. These paths could be either absolute or
+    // relative, depending on site settings. If they are absolute, treat them
+    // as relative to the project root. Otherwise, treat them as relative to
+    // the web root.
+    foreach (['public', 'private'] as $scheme) {
+      $wrapper = $this->streamWrapperManager->getViaScheme($scheme);
+      if ($wrapper instanceof LocalStream) {
+        $path = $wrapper->getDirectoryPath();
+
+        if ($this->fileSystem->isAbsolutePath($path)) {
+          $this->excludeInProjectRoot($event, [$path]);
+        }
+        else {
+          $this->excludeInWebRoot($event, [$path]);
+        }
+      }
+    }
+  }
+
+}
diff --git a/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php b/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php
new file mode 100644
index 0000000000..e182383c51
--- /dev/null
+++ b/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\Core\Database\Connection;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Excludes SQLite database files from staging operations.
+ */
+class SqliteDatabaseExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * Constructs a SqliteDatabaseExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Drupal\Core\Database\Connection $database
+   *   The database connection.
+   */
+  public function __construct(PathLocator $path_locator, Connection $database) {
+    $this->pathLocator = $path_locator;
+    $this->database = $database;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeDatabaseFiles',
+      PreApplyEvent::class => 'excludeDatabaseFiles',
+    ];
+  }
+
+  /**
+   * Excludes SQLite database files from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeDatabaseFiles(StageEvent $event): void {
+    // If the database is SQLite, it might be located in the active directory
+    // and we should ignore it. Always treat it as relative to the project root.
+    if ($this->database->driver() === 'sqlite') {
+      $options = $this->database->getConnectionOptions();
+      $this->excludeInProjectRoot($event, [
+        $options['database'],
+        $options['database'] . '-shm',
+        $options['database'] . '-wal',
+      ]);
+    }
+  }
+
+}
diff --git a/package_manager/src/PathExcluder/TestSiteExcluder.php b/package_manager/src/PathExcluder/TestSiteExcluder.php
new file mode 100644
index 0000000000..3fbae39016
--- /dev/null
+++ b/package_manager/src/PathExcluder/TestSiteExcluder.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Excludes 'sites/simpletest' from staging operations.
+ */
+class TestSiteExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * Constructs a TestSiteExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeTestSites',
+      PreApplyEvent::class => 'excludeTestSites',
+    ];
+  }
+
+  /**
+   * Excludes sites/simpletest from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeTestSites(StageEvent $event): void {
+    // Always ignore automated test directories. If they exist, they will be in
+    // the web root.
+    $this->excludeInWebRoot($event, ['sites/simpletest']);
+  }
+
+}
diff --git a/package_manager/src/PathExcluder/VendorHardeningExcluder.php b/package_manager/src/PathExcluder/VendorHardeningExcluder.php
new file mode 100644
index 0000000000..11248732bd
--- /dev/null
+++ b/package_manager/src/PathExcluder/VendorHardeningExcluder.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Excludes vendor hardening files from staging operations.
+ */
+class VendorHardeningExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * Constructs a VendorHardeningExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeVendorHardeningFiles',
+      PreApplyEvent::class => 'excludeVendorHardeningFiles',
+    ];
+  }
+
+  /**
+   * Excludes vendor hardening files from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeVendorHardeningFiles(StageEvent $event): void {
+    // If the core-vendor-hardening plugin (used in the legacy-project template)
+    // is present, it may have written security hardening files in the vendor
+    // directory. They should always be ignored.
+    $vendor_dir = $this->pathLocator->getVendorDirectory();
+    $this->excludeInProjectRoot($event, [
+      $vendor_dir . '/web.config',
+      $vendor_dir . '/.htaccess',
+    ]);
+  }
+
+}
diff --git a/package_manager/tests/src/Kernel/ExcludedPathsTest.php b/package_manager/tests/src/Kernel/ExcludedPathsTest.php
deleted file mode 100644
index eecde71a0a..0000000000
--- a/package_manager/tests/src/Kernel/ExcludedPathsTest.php
+++ /dev/null
@@ -1,243 +0,0 @@
-<?php
-
-namespace Drupal\Tests\package_manager\Kernel;
-
-use Drupal\Core\Database\Connection;
-use Drupal\Core\DependencyInjection\ContainerBuilder;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber;
-
-/**
- * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
- *
- * @group package_manager
- */
-class ExcludedPathsTest extends PackageManagerKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp(): void {
-    // In this test, we want to disable the lock file validator because, even
-    // though both the active and stage directories will have a valid lock file,
-    // this validator will complain because they don't differ at all.
-    $this->disableValidators[] = 'package_manager.validator.lock_file';
-    parent::setUp();
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function register(ContainerBuilder $container) {
-    parent::register($container);
-
-    $container->getDefinition('package_manager.excluded_paths_subscriber')
-      ->setClass(TestExcludedPathsSubscriber::class);
-  }
-
-  /**
-   * Tests that certain paths are excluded from staging operations.
-   */
-  public function testExcludedPaths(): void {
-    // The private stream wrapper is only registered if this setting is set.
-    // @see \Drupal\Core\CoreServiceProvider::register()
-    $this->setSetting('file_private_path', 'private');
-    // In this test, we want to perform the actual staging operations so that we
-    // can be sure that files are staged as expected. This will also rebuild
-    // the container, enabling the private stream wrapper.
-    $this->container->get('module_installer')->uninstall([
-      'package_manager_bypass',
-    ]);
-    // Ensure we have an up-to-date container.
-    $this->container = $this->container->get('kernel')->getContainer();
-
-    $this->createTestProject();
-    $active_dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-
-    $site_path = 'sites/example.com';
-    // Ensure that we are using directories within the fake site fixture for
-    // public and private files.
-    $this->setSetting('file_public_path', "$site_path/files");
-
-    // Mock a SQLite database connection to a file in the active directory. The
-    // file should not be staged.
-    $database = $this->prophesize(Connection::class);
-    $database->driver()->willReturn('sqlite');
-    $database->getConnectionOptions()->willReturn([
-      'database' => $site_path . '/db.sqlite',
-    ]);
-
-    // Update the event subscriber's dependencies.
-    /** @var \Drupal\Tests\package_manager\Kernel\TestExcludedPathsSubscriber $subscriber */
-    $subscriber = $this->container->get('package_manager.excluded_paths_subscriber');
-    $subscriber->sitePath = $site_path;
-    $subscriber->database = $database->reveal();
-
-    $stage = $this->createStage();
-    $stage->create();
-    $stage_dir = $stage->getStageDirectory();
-
-    $ignore = [
-      'sites/simpletest',
-      'vendor/.htaccess',
-      'vendor/web.config',
-      "$site_path/files/ignore.txt",
-      'private/ignore.txt',
-      "$site_path/settings.php",
-      "$site_path/settings.local.php",
-      "$site_path/services.yml",
-      // SQLite databases and their support files should always be ignored.
-      "$site_path/db.sqlite",
-      "$site_path/db.sqlite-shm",
-      "$site_path/db.sqlite-wal",
-      // Default site-specific settings files should be ignored.
-      'sites/default/settings.php',
-      'sites/default/settings.local.php',
-      'sites/default/services.yml',
-      // No git directories should be staged.
-      '.git/ignore.txt',
-      'modules/example/.git/ignore.txt',
-    ];
-    foreach ($ignore as $path) {
-      $this->assertFileExists("$active_dir/$path");
-      $this->assertFileDoesNotExist("$stage_dir/$path");
-    }
-    // A non-excluded file in the default site directory should be staged.
-    $this->assertFileExists("$stage_dir/sites/default/stage.txt");
-    // Regular module files should be staged.
-    $this->assertFileExists("$stage_dir/modules/example/example.info.yml");
-    // Files that start with .git, but aren't actually .git, should be staged.
-    $this->assertFileExists("$stage_dir/.gitignore");
-
-    // A new file added to the staging area in an excluded directory, should not
-    // be copied to the active directory.
-    $file = "$stage_dir/sites/default/no-copy.txt";
-    touch($file);
-    $this->assertFileExists($file);
-    $stage->apply();
-    $this->assertFileDoesNotExist("$active_dir/sites/default/no-copy.txt");
-
-    // The ignored files should still be in the active directory.
-    foreach ($ignore as $path) {
-      $this->assertFileExists("$active_dir/$path");
-    }
-  }
-
-  /**
-   * Data provider for ::testSqliteDatabaseExcluded().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerSqliteDatabaseExcluded(): array {
-    $drupal_root = $this->getDrupalRoot();
-
-    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',
-        ],
-      ],
-      'relative path, at root' => [
-        'db.sqlite',
-        [
-          'db.sqlite',
-          'db.sqlite-shm',
-          'db.sqlite-wal',
-        ],
-      ],
-      'absolute path, in site directory' => [
-        $drupal_root . '/sites/example.com/db.sqlite',
-        [
-          'sites/example.com/db.sqlite',
-          'sites/example.com/db.sqlite-shm',
-          'sites/example.com/db.sqlite-wal',
-        ],
-      ],
-      'absolute path, at root' => [
-        $drupal_root . '/db.sqlite',
-        [
-          'db.sqlite',
-          'db.sqlite-shm',
-          'db.sqlite-wal',
-        ],
-      ],
-    ];
-  }
-
-  /**
-   * Tests that SQLite database paths are excluded from the staging area.
-   *
-   * 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
-   *   The path of the SQLite database, as set in the database connection
-   *   options.
-   * @param string[] $expected_exclusions
-   *   The database paths which should be flagged for exclusion.
-   *
-   * @dataProvider providerSqliteDatabaseExcluded
-   */
-  public function testSqliteDatabaseExcluded(string $database_path, array $expected_exclusions): void {
-    $database = $this->prophesize(Connection::class);
-    $database->driver()->willReturn('sqlite');
-    $database->getConnectionOptions()->willReturn([
-      'database' => $database_path,
-    ]);
-
-    // Update the event subscriber to use the mocked database.
-    /** @var \Drupal\Tests\package_manager\Kernel\TestExcludedPathsSubscriber $subscriber */
-    $subscriber = $this->container->get('package_manager.excluded_paths_subscriber');
-    $subscriber->database = $database->reveal();
-
-    $event = new PreCreateEvent($this->createStage());
-    // Invoke the event subscriber directly, so we can check that the database
-    // was correctly excluded.
-    $subscriber->ignoreCommonPaths($event);
-    // All of the expected exclusions should be flagged.
-    $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths()));
-  }
-
-  /**
-   * Tests that unreadable directories are ignored by the event subscriber.
-   */
-  public function testUnreadableDirectoriesAreIgnored(): void {
-    $this->createTestProject();
-    $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);
-
-    $this->createStage()->create();
-  }
-
-}
-
-/**
- * A test-only version of the excluded paths event subscriber.
- */
-class TestExcludedPathsSubscriber extends ExcludedPathsSubscriber {
-
-  /**
-   * {@inheritdoc}
-   */
-  public $sitePath;
-
-  /**
-   * {@inheritdoc}
-   */
-  public $database;
-
-}
diff --git a/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php b/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php
new file mode 100644
index 0000000000..a1817cc0d0
--- /dev/null
+++ b/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\GitExcluder
+ *
+ * @group package_manager
+ */
+class GitExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * Tests that unreadable directories are ignored by the event subscriber.
+   */
+  public function testUnreadableDirectoriesAreIgnored(): void {
+    $this->createTestProject();
+    $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);
+
+    $this->createStage()->create();
+  }
+
+  /**
+   * Tests that Git directories are excluded from staging operations.
+   */
+  public function testGitDirectoriesExcluded(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->disableModules(['package_manager_bypass']);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->getContainer();
+
+    $this->createTestProject();
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      '.git/ignore.txt',
+      'modules/example/.git/ignore.txt',
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+    // Files that start with .git, but aren't actually .git, should be staged.
+    $this->assertFileExists("$stage_dir/.gitignore");
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
diff --git a/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php b/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php
new file mode 100644
index 0000000000..095461dcd1
--- /dev/null
+++ b/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\PathExcluder\SiteConfigurationExcluder;
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\SiteConfigurationExcluder
+ *
+ * @group package_manager
+ */
+class SiteConfigurationExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->getDefinition('package_manager.site_configuration_excluder')
+      ->setClass(TestSiteConfigurationExcluder::class);
+  }
+
+  /**
+   * Tests that certain paths are excluded from staging operations.
+   */
+  public function testExcludedPaths(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->disableModules(['package_manager_bypass']);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->getContainer();
+
+    $this->createTestProject();
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $site_path = 'sites/example.com';
+
+    // Update the event subscribers' dependencies.
+    $this->container->get('package_manager.site_configuration_excluder')->sitePath = $site_path;
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignore = [
+      "$site_path/settings.php",
+      "$site_path/settings.local.php",
+      "$site_path/services.yml",
+      // Default site-specific settings files should be ignored.
+      'sites/default/settings.php',
+      'sites/default/settings.local.php',
+      'sites/default/services.yml',
+    ];
+    foreach ($ignore as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+    // A non-excluded file in the default site directory should be staged.
+    $this->assertFileExists("$stage_dir/sites/default/stage.txt");
+    // Regular module files should be staged.
+    $this->assertFileExists("$stage_dir/modules/example/example.info.yml");
+
+    // A new file added to the staging area in an excluded directory, should not
+    // be copied to the active directory.
+    $file = "$stage_dir/sites/default/no-copy.txt";
+    touch($file);
+    $this->assertFileExists($file);
+    $stage->apply();
+    $this->assertFileDoesNotExist("$active_dir/sites/default/no-copy.txt");
+
+    // The ignored files should still be in the active directory.
+    foreach ($ignore as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
+
+/**
+ * A test version of the site configuration excluder, to expose internals.
+ */
+class TestSiteConfigurationExcluder extends SiteConfigurationExcluder {
+
+  /**
+   * {@inheritdoc}
+   */
+  public $sitePath;
+
+}
diff --git a/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php b/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php
new file mode 100644
index 0000000000..5241397642
--- /dev/null
+++ b/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\SiteFilesExcluder
+ *
+ * @group package_manager
+ */
+class SiteFilesExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * Tests that public and private files are excluded from staging operations.
+   */
+  public function testSiteFilesExcluded(): void {
+    // The private stream wrapper is only registered if this setting is set.
+    // @see \Drupal\Core\CoreServiceProvider::register()
+    $this->setSetting('file_private_path', 'private');
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected. This will also rebuild
+    // the container, enabling the private stream wrapper.
+    $this->disableModules(['package_manager_bypass']);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->getContainer();
+
+    $this->createTestProject();
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    // Ensure that we are using directories within the fake site fixture for
+    // public and private files.
+    $this->setSetting('file_public_path', "sites/example.com/files");
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      "sites/example.com/files/ignore.txt",
+      'private/ignore.txt',
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
diff --git a/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php b/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php
new file mode 100644
index 0000000000..9671134607
--- /dev/null
+++ b/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder;
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder
+ *
+ * @group package_manager
+ */
+class SqliteDatabaseExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->getDefinition('package_manager.sqlite_excluder')
+      ->setClass(TestSqliteDatabaseExcluder::class);
+  }
+
+  /**
+   * Mocks a SQLite database connection for the event subscriber.
+   *
+   * @param array $connection_options
+   *   The connection options for the mocked database connection.
+   */
+  private function mockDatabase(array $connection_options): void {
+    $database = $this->prophesize(Connection::class);
+    $database->driver()->willReturn('sqlite');
+    $database->getConnectionOptions()->willReturn($connection_options);
+
+    $this->container->get('package_manager.sqlite_excluder')
+      ->database = $database->reveal();
+  }
+
+  /**
+   * Tests that SQLite database files are excluded from staging operations.
+   */
+  public function testSqliteDatabaseFilesExcluded(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->disableModules(['package_manager_bypass']);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->getContainer();
+
+    $this->createTestProject();
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->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_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      "sites/example.com/db.sqlite",
+      "sites/example.com/db.sqlite-shm",
+      "sites/example.com/db.sqlite-wal",
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+  /**
+   * Data provider for ::testPathProcessing().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerPathProcessing(): array {
+    $drupal_root = $this->getDrupalRoot();
+
+    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',
+        ],
+      ],
+      'relative path, at root' => [
+        'db.sqlite',
+        [
+          'db.sqlite',
+          'db.sqlite-shm',
+          'db.sqlite-wal',
+        ],
+      ],
+      'absolute path, in site directory' => [
+        $drupal_root . '/sites/example.com/db.sqlite',
+        [
+          'sites/example.com/db.sqlite',
+          'sites/example.com/db.sqlite-shm',
+          'sites/example.com/db.sqlite-wal',
+        ],
+      ],
+      'absolute path, at root' => [
+        $drupal_root . '/db.sqlite',
+        [
+          'db.sqlite',
+          'db.sqlite-shm',
+          'db.sqlite-wal',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * 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.
+   *
+   * @param string $database_path
+   *   The path of the SQLite database, as set in the database connection
+   *   options.
+   * @param string[] $expected_exclusions
+   *   The database paths which should be flagged for exclusion.
+   *
+   * @dataProvider providerPathProcessing
+   */
+  public function testPathProcessing(string $database_path, array $expected_exclusions): void {
+    $this->mockDatabase([
+      'database' => $database_path,
+    ]);
+
+    $event = new PreCreateEvent($this->createStage());
+    // Invoke the event subscriber directly, so we can check that the database
+    // was correctly excluded.
+    $this->container->get('package_manager.sqlite_excluder')
+      ->excludeDatabaseFiles($event);
+    // All of the expected exclusions should be flagged.
+    $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths()));
+  }
+
+}
+
+/**
+ * A test-only version of the SQLite database excluder, to expose internals.
+ */
+class TestSqliteDatabaseExcluder extends SqliteDatabaseExcluder {
+
+  /**
+   * {@inheritdoc}
+   */
+  public $database;
+
+}
diff --git a/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php b/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php
new file mode 100644
index 0000000000..7b19c31732
--- /dev/null
+++ b/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\TestSiteExcluder
+ *
+ * @group package_manager
+ */
+class TestSiteExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * Tests that test site directories are excluded from staging operations.
+   */
+  public function testTestSitesExcluded(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->disableModules(['package_manager_bypass']);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->getContainer();
+
+    $this->createTestProject();
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      'sites/simpletest',
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
diff --git a/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php b/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php
new file mode 100644
index 0000000000..8d74f74daa
--- /dev/null
+++ b/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\VendorHardeningExcluder
+ *
+ * @group package_manager
+ */
+class VendorHardeningExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * Tests that vendor hardening files are excluded from staging operations.
+   */
+  public function testVendorHardeningFilesExcluded(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->disableModules(['package_manager_bypass']);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->getContainer();
+
+    $this->createTestProject();
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      'vendor/.htaccess',
+      'vendor/web.config',
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
index eef8b02e2e..171490c189 100644
--- a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
+++ b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
@@ -96,11 +96,11 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
 
     $event_dispatcher = $this->container->get('event_dispatcher');
     // Disable the disk space validator, since it doesn't work with vfsStream,
-    // and the excluded paths subscriber, since it won't deal with this tiny
+    // and the Git directory excluder, since it won't deal with this tiny
     // virtual file system correctly.
     $disable_subscribers = array_map([$this->container, 'get'], [
       'package_manager.validator.disk_space',
-      'package_manager.excluded_paths_subscriber',
+      'package_manager.git_excluder',
     ]);
     array_walk($disable_subscribers, [$event_dispatcher, 'removeSubscriber']);
 
-- 
GitLab