Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Attribute;
/**
* Identifies sandbox managers which can operate on the running code base.
*
* Package Manager normally creates and operates on a fully separate, sandboxed
* copy of the site. This is pretty safe, but not always necessary for certain
* kinds of operations (e.g., adding a new module to the site).
* SandboxManagerBase subclasses with this attribute are allowed to skip the
* sandboxing and operate directly on the live site, but ONLY if the
* `package_manager_allow_direct_write` setting is set to TRUE.
*
* @see \Drupal\package_manager\SandboxManagerBase::isDirectWrite()
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class AllowDirectWrite {
}

View File

@ -0,0 +1,481 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Composer\Semver\Semver;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Exception\ComposerNotReadyException;
use PhpTuf\ComposerStager\API\Exception\PreconditionException;
use PhpTuf\ComposerStager\API\Exception\RuntimeException;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use PhpTuf\ComposerStager\API\Precondition\Service\ComposerIsAvailableInterface;
use PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Defines a class to get information from Composer.
*
* This is a PHP wrapper to facilitate interacting with composer and:
* - list installed packages: getInstalledPackagesList() (`composer show`)
* - validate composer state & project: validate() (`composer validate`)
* - read project & package configuration: getConfig() (`composer config`)
* - read root package info: getRootPackageInfo() (`composer show --self`)
*/
class ComposerInspector implements LoggerAwareInterface {
use LoggerAwareTrait {
setLogger as traitSetLogger;
}
use StringTranslationTrait;
/**
* The process output callback.
*
* @var \Drupal\package_manager\ProcessOutputCallback
*/
private ProcessOutputCallback $processCallback;
/**
* Statically cached installed package lists, keyed by directory.
*
* @var \Drupal\package_manager\InstalledPackagesList[]
*/
private array $packageLists = [];
/**
* A semantic version constraint for the supported version(s) of Composer.
*
* @see https://endoflife.date/composer
*
* @var string
*/
final public const SUPPORTED_VERSION = '^2.7';
public function __construct(
private readonly ComposerProcessRunnerInterface $runner,
private readonly ComposerIsAvailableInterface $composerIsAvailable,
private readonly PathFactoryInterface $pathFactory,
) {
$this->processCallback = new ProcessOutputCallback();
$this->setLogger(new NullLogger());
}
/**
* {@inheritdoc}
*/
public function setLogger(LoggerInterface $logger): void {
$this->traitSetLogger($logger);
$this->processCallback->setLogger($logger);
}
/**
* Checks that Composer commands can be run.
*
* @param string $working_dir
* The directory in which Composer will be run.
*
* @see ::validateExecutable()
* @see ::validateProject()
*/
public function validate(string $working_dir): void {
$this->validateExecutable();
$this->validateProject($working_dir);
}
/**
* Checks that `composer.json` is valid and `composer.lock` exists.
*
* @param string $working_dir
* The directory to check.
*
* @throws \Drupal\package_manager\Exception\ComposerNotReadyException
* Thrown if:
* - `composer.json` doesn't exist in the given directory or is invalid
* according to `composer validate`.
* - `composer.lock` doesn't exist in the given directory.
*/
private function validateProject(string $working_dir): void {
$messages = [];
$previous_exception = NULL;
// If either composer.json or composer.lock have changed, ensure the
// directory is in a completely valid state, according to Composer.
if ($this->invalidateCacheIfNeeded($working_dir)) {
try {
$this->runner->run([
'validate',
'--check-lock',
'--no-check-publish',
'--with-dependencies',
'--no-ansi',
"--working-dir=$working_dir",
]);
}
catch (RuntimeException $e) {
$messages[] = $e->getMessage();
$previous_exception = $e;
}
}
// Check for the presence of composer.lock, because `composer validate`
// doesn't expect it to exist, but we do (see ::getInstalledPackagesList()).
if (!file_exists($working_dir . DIRECTORY_SEPARATOR . 'composer.lock')) {
$messages[] = $this->t('composer.lock not found in @dir.', [
'@dir' => $working_dir,
]);
}
if ($messages) {
throw new ComposerNotReadyException($working_dir, $messages, 0, $previous_exception);
}
}
/**
* Validates that the Composer executable exists in a supported version.
*
* @throws \Exception
* Thrown if the Composer executable is not available or the detected
* version of Composer is not supported.
*/
private function validateExecutable(): void {
$messages = [];
// Ensure the Composer executable is available. For performance reasons,
// statically cache the result, since it's unlikely to change during the
// current request. If $unavailable_message is NULL, it means we haven't
// done this check yet. If it's FALSE, it means we did the check and there
// were no errors; and, if it's a string, it's the error message we received
// the last time we did this check.
static $unavailable_message;
if ($unavailable_message === NULL) {
try {
// The "Composer is available" precondition requires active and stage
// directories, but they don't actually matter to it, nor do path
// exclusions, so dummies can be passed for simplicity.
$active_dir = $this->pathFactory->create(__DIR__);
$stage_dir = $active_dir;
$this->composerIsAvailable->assertIsFulfilled($active_dir, $stage_dir);
$unavailable_message = FALSE;
}
catch (PreconditionException $e) {
$unavailable_message = $e->getMessage();
}
}
if ($unavailable_message) {
$messages[] = $unavailable_message;
}
// The detected version of Composer is unlikely to change during the
// current request, so statically cache it. If $unsupported_message is NULL,
// it means we haven't done this check yet. If it's FALSE, it means we did
// the check and there were no errors; and, if it's a string, it's the error
// message we received the last time we did this check.
static $unsupported_message;
if ($unsupported_message === NULL) {
try {
$detected_version = $this->getVersion();
if (Semver::satisfies($detected_version, static::SUPPORTED_VERSION)) {
// We did the version check, and it did not produce an error message.
$unsupported_message = FALSE;
}
else {
$unsupported_message = $this->t('The detected Composer version, @version, does not satisfy <code>@constraint</code>.', [
'@version' => $detected_version,
'@constraint' => static::SUPPORTED_VERSION,
]);
}
}
catch (\UnexpectedValueException $e) {
$unsupported_message = $e->getMessage();
}
}
if ($unsupported_message) {
$messages[] = $unsupported_message;
}
if ($messages) {
throw new ComposerNotReadyException(NULL, $messages);
}
}
/**
* Returns a config value from Composer.
*
* @param string $key
* The config key to get.
* @param string $context
* The path of either the directory in which to run Composer, or a specific
* configuration file (such as a particular package's `composer.json`) from
* which to read specific values.
*
* @return string|null
* The output data. Note that the caller must know the shape of the
* requested key's value: if it's a string, no further processing is needed,
* but if it is a boolean, an array or a map, JSON decoding should be
* applied.
*
* @see ::getAllowPluginsConfig()
* @see \Composer\Command\ConfigCommand::execute()
*/
public function getConfig(string $key, string $context): ?string {
$this->validateExecutable();
$command = ['config', $key];
// If we're consulting a specific file for the config value, we don't need
// to validate the project as a whole.
if (is_file($context)) {
$command[] = "--file={$context}";
}
else {
$this->validateProject($context);
$command[] = "--working-dir={$context}";
}
try {
$this->runner->run($command, callback: $this->processCallback->reset());
}
catch (RuntimeException $e) {
// Assume any error from `composer config` is about an undefined key-value
// pair which may have a known default value.
return match ($key) {
'extra' => '{}',
default => throw $e,
};
}
$output = $this->processCallback->getOutput();
return $output ? trim(implode('', $output)) : NULL;
}
/**
* Returns the current Composer version.
*
* @return string
* The Composer version.
*
* @throws \UnexpectedValueException
* Thrown if the Composer version cannot be determined.
*/
public function getVersion(): string {
$this->runner->run(['--format=json'], callback: $this->processCallback->reset());
$data = $this->processCallback->parseJsonOutput();
if (isset($data['application']['name'])
&& isset($data['application']['version'])
&& $data['application']['name'] === 'Composer'
&& is_string($data['application']['version'])) {
return $data['application']['version'];
}
throw new \UnexpectedValueException('Unable to determine Composer version');
}
/**
* Returns the installed packages list.
*
* @param string $working_dir
* The working directory in which to run Composer. Should contain a
* `composer.lock` file.
*
* @return \Drupal\package_manager\InstalledPackagesList
* The installed packages list for the directory.
*
* @throws \UnexpectedValueException
* Thrown if a package reports that its install path is the same as the
* working directory, and it is not of the `metapackage` type.
*/
public function getInstalledPackagesList(string $working_dir): InstalledPackagesList {
$working_dir = realpath($working_dir);
$this->validate($working_dir);
if (array_key_exists($working_dir, $this->packageLists)) {
return $this->packageLists[$working_dir];
}
$packages_data = $this->show($working_dir);
$packages_data = $this->getPackageTypes($packages_data, $working_dir);
foreach ($packages_data as $name => $package) {
$path = $package['path'];
// For packages installed as dev snapshots from certain version control
// systems, `composer show` displays the version like `1.0.x-dev 0a1b2c`,
// which will cause an exception if we try to parse it as a legitimate
// semantic version. Since we don't need the abbreviated commit hash, just
// remove it.
if (str_contains($package['version'], '-dev ')) {
$packages_data[$name]['version'] = explode(' ', $package['version'], 2)[0];
}
// We expect Composer to report that metapackages' install paths are the
// same as the working directory, in which case InstalledPackage::$path
// should be NULL. For all other package types, we consider it invalid
// if the install path is the same as the working directory.
if (isset($package['type']) && $package['type'] === 'metapackage') {
if ($path !== NULL) {
throw new \UnexpectedValueException("Metapackage '$name' is installed at unexpected path: '$path', expected NULL");
}
$packages_data[$name]['path'] = $path;
}
elseif ($path === $working_dir) {
throw new \UnexpectedValueException("Package '$name' cannot be installed at path: '$path'");
}
else {
$packages_data[$name]['path'] = realpath($path);
}
}
$packages_data = array_map(InstalledPackage::createFromArray(...), $packages_data);
$list = new InstalledPackagesList($packages_data);
$this->packageLists[$working_dir] = $list;
return $list;
}
/**
* Loads package types from the lock file.
*
* The package type is not available using `composer show` for listing
* packages. To avoiding making many calls to `composer show package-name`,
* load the lock file data to get the `type` key.
*
* @param array $packages_data
* The packages data returned from ::show().
* @param string $working_dir
* The directory where Composer was run.
*
* @return array
* The packages data, with a `type` key added to each package.
*/
private function getPackageTypes(array $packages_data, string $working_dir): array {
$lock_content = file_get_contents($working_dir . DIRECTORY_SEPARATOR . 'composer.lock');
$lock_data = json_decode($lock_content, TRUE, flags: JSON_THROW_ON_ERROR);
$lock_packages = array_merge($lock_data['packages'] ?? [], $lock_data['packages-dev'] ?? []);
foreach ($lock_packages as $lock_package) {
$name = $lock_package['name'];
if (isset($packages_data[$name]) && isset($lock_package['type'])) {
$packages_data[$name]['type'] = $lock_package['type'];
}
}
return $packages_data;
}
/**
* Returns the output of `composer show --self` in a directory.
*
* @param string $working_dir
* The directory in which to run Composer.
*
* @return array
* The parsed output of `composer show --self`.
*/
public function getRootPackageInfo(string $working_dir): array {
$this->validate($working_dir);
$this->runner->run(['show', '--self', '--format=json', "--working-dir={$working_dir}"], callback: $this->processCallback->reset());
return $this->processCallback->parseJsonOutput();
}
/**
* Gets the installed packages data from running `composer show`.
*
* @param string $working_dir
* The directory in which to run `composer show`.
*
* @return array[]
* The installed packages data, keyed by package name.
*/
protected function show(string $working_dir): array {
$data = [];
$options = ['show', '--format=json', "--working-dir={$working_dir}"];
// We don't get package installation paths back from `composer show` unless
// we explicitly pass the --path option to it. However, for some
// inexplicable reason, that option hides *other* relevant information
// about the installed packages. So, to work around this maddening quirk, we
// call `composer show` once without the --path option, and once with it,
// then merge the results together. Composer, for its part, will not support
// returning the install path from `composer show`: see
// https://github.com/composer/composer/pull/11340.
$this->runner->run($options, callback: $this->processCallback->reset());
$output = $this->processCallback->parseJsonOutput();
// $output['installed'] will not be set if no packages are installed.
if (isset($output['installed'])) {
foreach ($output['installed'] as $installed_package) {
$data[$installed_package['name']] = $installed_package;
}
$options[] = '--path';
$this->runner->run($options, callback: $this->processCallback->reset());
$output = $this->processCallback->parseJsonOutput();
foreach ($output['installed'] as $installed_package) {
$data[$installed_package['name']]['path'] = $installed_package['path'];
}
}
return $data;
}
/**
* Invalidates cached data if composer.json or composer.lock have changed.
*
* The following cached data may be invalidated:
* - Installed package lists (see ::getInstalledPackageList()).
*
* @param string $working_dir
* A directory that contains a `composer.json` file, and optionally a
* `composer.lock`. If either file has changed since the last time this
* method was called, any cached data for the directory will be invalidated.
*
* @return bool
* TRUE if the cached data was invalidated, otherwise FALSE.
*/
private function invalidateCacheIfNeeded(string $working_dir): bool {
static $known_hashes = [];
$invalidate = FALSE;
foreach (['composer.json', 'composer.lock'] as $filename) {
$known_hash = $known_hashes[$working_dir][$filename] ?? '';
// If the file doesn't exist, hash_file() will return FALSE.
$current_hash = @hash_file('xxh64', $working_dir . DIRECTORY_SEPARATOR . $filename);
if ($known_hash && $current_hash && hash_equals($known_hash, $current_hash)) {
continue;
}
$known_hashes[$working_dir][$filename] = $current_hash;
$invalidate = TRUE;
}
if ($invalidate) {
unset($this->packageLists[$working_dir]);
}
return $invalidate;
}
/**
* Returns the value of `allow-plugins` config setting.
*
* @param string $dir
* The directory in which to run Composer.
*
* @return bool[]|bool
* An array of boolean flags to allow or disallow certain plugins, or TRUE
* if all plugins are allowed.
*
* @see https://getcomposer.org/doc/06-config.md#allow-plugins
*/
public function getAllowPluginsConfig(string $dir): array|bool {
$value = $this->getConfig('allow-plugins', $dir);
// Try to convert the value we got back to a boolean. If it's not a boolean,
// it should be an array of plugin-specific flags.
$value = json_decode($value, TRUE, flags: JSON_THROW_ON_ERROR);
// An empty array indicates that no plugins are allowed.
return $value ?: [];
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathInterface;
use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface;
use PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface;
use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface;
use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface;
use Symfony\Component\Process\PhpExecutableFinder;
// cspell:ignore BINDIR
/**
* Runs Composer through the current PHP interpreter.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ComposerRunner implements ComposerProcessRunnerInterface {
public function __construct(
private readonly ExecutableFinderInterface $executableFinder,
private readonly ProcessFactoryInterface $processFactory,
private readonly FileSystemInterface $fileSystem,
private readonly ConfigFactoryInterface $configFactory,
) {}
/**
* {@inheritdoc}
*/
public function run(array $command, ?PathInterface $cwd = NULL, array $env = [], ?OutputCallbackInterface $callback = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void {
// Run Composer through the PHP interpreter so we don't have to rely on
// PHP being in the PATH.
array_unshift($command, (new PhpExecutableFinder())->find(), $this->executableFinder->find('composer'));
$home = $this->fileSystem->getTempDirectory();
$home .= '/package_manager_composer_home-';
$home .= $this->configFactory->get('system.site')->get('uuid');
$this->fileSystem->prepareDirectory($home, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
$process = $this->processFactory->create($command, $cwd, $env + ['COMPOSER_HOME' => $home]);
$process->setTimeout($timeout);
$process->mustRun($callback);
}
}

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use PhpTuf\ComposerStager\API\Path\Value\PathInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
use PhpTuf\ComposerStager\API\Precondition\Service\ActiveAndStagingDirsAreDifferentInterface;
use PhpTuf\ComposerStager\API\Precondition\Service\RsyncIsAvailableInterface;
use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface;
use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface;
/**
* Allows certain Composer Stager preconditions to be bypassed.
*
* Only certain preconditions can be bypassed; this class implements all of
* those interfaces, and only accepts them in its constructor.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class DirectWritePreconditionBypass implements ActiveAndStagingDirsAreDifferentInterface, RsyncIsAvailableInterface {
use StringTranslationTrait;
/**
* Whether or not the decorated precondition is being bypassed.
*
* @var bool
*/
private static bool $isBypassed = FALSE;
public function __construct(
private readonly ActiveAndStagingDirsAreDifferentInterface|RsyncIsAvailableInterface $decorated,
) {}
/**
* Bypasses the decorated precondition.
*/
public static function activate(): void {
static::$isBypassed = TRUE;
}
/**
* {@inheritdoc}
*/
public function getName(): TranslatableInterface {
return $this->decorated->getName();
}
/**
* {@inheritdoc}
*/
public function getDescription(): TranslatableInterface {
return $this->decorated->getDescription();
}
/**
* {@inheritdoc}
*/
public function getStatusMessage(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): TranslatableInterface {
if (static::$isBypassed) {
return new TranslatableStringAdapter('This precondition has been skipped because it is not needed in direct-write mode.');
}
return $this->decorated->getStatusMessage($activeDir, $stagingDir, $exclusions, $timeout);
}
/**
* {@inheritdoc}
*/
public function isFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): bool {
if (static::$isBypassed) {
return TRUE;
}
return $this->decorated->isFulfilled($activeDir, $stagingDir, $exclusions, $timeout);
}
/**
* {@inheritdoc}
*/
public function assertIsFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void {
if (static::$isBypassed) {
return;
}
$this->decorated->assertIsFulfilled($activeDir, $stagingDir, $exclusions, $timeout);
}
/**
* {@inheritdoc}
*/
public function getLeaves(): array {
return [$this];
}
}

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
use Drupal\package_manager\SandboxManagerBase;
use Drupal\package_manager\PathLocator;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use PhpTuf\ComposerStager\API\Path\Factory\PathListFactoryInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
/**
* Defines an event that collects paths to exclude.
*
* These paths are excluded by Composer Stager and are never copied into the
* stage directory from the active directory, or vice versa.
*/
final class CollectPathsToExcludeEvent extends SandboxEvent implements PathListInterface {
/**
* Constructs a CollectPathsToExcludeEvent object.
*
* @param \Drupal\package_manager\SandboxManagerBase $sandboxManager
* The stage which fired this event.
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
* @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory
* The path factory service.
* @param \PhpTuf\ComposerStager\API\Path\Value\PathListInterface|null $pathList
* (optional) The list of paths to exclude.
*/
public function __construct(
SandboxManagerBase $sandboxManager,
private readonly PathLocator $pathLocator,
private readonly PathFactoryInterface $pathFactory,
private ?PathListInterface $pathList = NULL,
) {
parent::__construct($sandboxManager);
$this->pathList ??= \Drupal::service(PathListFactoryInterface::class)
->create();
}
/**
* {@inheritdoc}
*/
public function add(string ...$paths): void {
$this->pathList->add(...$paths);
}
/**
* {@inheritdoc}
*/
public function getAll(): array {
return array_unique($this->pathList->getAll());
}
/**
* Flags paths to be ignored, 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 string[] $paths
* The paths to ignore. These should be relative to the web root. They will
* be made relative to the project root.
*/
public function addPathsRelativeToWebRoot(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.
$this->add($web_root . $path);
}
}
/**
* Flags paths to be ignored, relative to the project root.
*
* @param string[] $paths
* The paths to ignore. Absolute paths will be made relative to the project
* root; relative paths are assumed to be relative to the project root.
*
* @throws \LogicException
* If any of the given paths are absolute, but not inside the project root.
*/
public function addPathsRelativeToProjectRoot(array $paths): void {
$project_root = $this->pathLocator->getProjectRoot();
foreach ($paths as $path) {
if ($this->pathFactory->create($path)->isAbsolute()) {
if (!str_starts_with($path, $project_root)) {
throw new \LogicException("$path is not inside the project root: $project_root.");
}
}
// Make absolute paths relative to the project root.
$path = str_replace($project_root, '', $path);
$path = ltrim($path, '/');
$this->add($path);
}
}
/**
* Finds all directories in the project root matching the given name.
*
* @param string $directory_name
* A directory name.
*
* @return string[]
* All discovered absolute paths matching the given directory name.
*/
public function scanForDirectoriesByName(string $directory_name): array {
$flags = \FilesystemIterator::UNIX_PATHS;
$flags |= \FilesystemIterator::CURRENT_AS_SELF;
$directories_tree = new \RecursiveDirectoryIterator($this->pathLocator->getProjectRoot(), $flags);
$filtered_directories = new \RecursiveIteratorIterator($directories_tree, \RecursiveIteratorIterator::SELF_FIRST);
$matched_directories = new \CallbackFilterIterator($filtered_directories,
fn (\RecursiveDirectoryIterator $current) => $current->isDir() && $current->getFilename() === $directory_name
);
return array_keys(iterator_to_array($matched_directories));
}
}

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
use Drupal\package_manager\SandboxManagerBase;
/**
* Common methods for pre- and post-require events.
*
* @internal
* This is an internal part of Automatic Updates and should only be used by
* \Drupal\package_manager\Event\PreRequireEvent and
* \Drupal\package_manager\Event\PostRequireEvent.
*/
trait EventWithPackageListTrait {
/**
* The runtime packages, in the form 'vendor/name:constraint'.
*
* @var string[]
*/
private $runtimePackages;
/**
* The dev packages to be required, in the form 'vendor/name:constraint'.
*
* @var string[]
*/
private $devPackages;
/**
* Constructs the object.
*
* @param \Drupal\package_manager\SandboxManagerBase $sandboxManager
* The stage.
* @param string[] $runtime_packages
* The runtime (i.e., non-dev) packages to be required, in the form
* 'vendor/name:constraint'.
* @param string[] $dev_packages
* The dev packages to be required, in the form 'vendor/name:constraint'.
*/
public function __construct(SandboxManagerBase $sandboxManager, array $runtime_packages, array $dev_packages = []) {
$this->runtimePackages = $runtime_packages;
$this->devPackages = $dev_packages;
parent::__construct($sandboxManager);
}
/**
* Gets the runtime (i.e., non-dev) packages.
*
* @return string[]
* An array of packages where the keys are package names in the form
* `vendor/name` and the values are version constraints. Packages without a
* version constraint will default to `*`.
*/
public function getRuntimePackages(): array {
return $this->getKeyedPackages($this->runtimePackages);
}
/**
* Gets the dev packages.
*
* @return string[]
* An array of packages where the values are version constraints and keys
* are package names in the form `vendor/name`. Packages without a version
* constraint will default to `*`.
*/
public function getDevPackages(): array {
return $this->getKeyedPackages($this->devPackages);
}
/**
* Gets packages as a keyed array.
*
* @param string[] $packages
* The packages, in the form 'vendor/name:version'.
*
* @return string[]
* An array of packages where the values are version constraints and keys
* are package names in the form `vendor/name`. Packages without a version
* constraint will default to `*`.
*/
private function getKeyedPackages(array $packages): array {
$keyed_packages = [];
foreach ($packages as $package) {
if (strpos($package, ':') > 0) {
[$name, $constraint] = explode(':', $package);
}
else {
[$name, $constraint] = [$package, '*'];
}
$keyed_packages[$name] = $constraint;
}
return $keyed_packages;
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
/**
* Event fired after staged changes are synced to the active directory.
*/
final class PostApplyEvent extends SandboxEvent {
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
/**
* Event fired after a stage directory has been created.
*/
final class PostCreateEvent extends SandboxEvent {
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
/**
* Event fired after packages are updated to the stage directory.
*/
final class PostRequireEvent extends SandboxEvent {
use EventWithPackageListTrait;
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
use Drupal\package_manager\ImmutablePathList;
use Drupal\package_manager\SandboxManagerBase;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
/**
* Event fired before staged changes are synced to the active directory.
*/
final class PreApplyEvent extends SandboxValidationEvent {
/**
* The list of paths to ignore in the active and stage directories.
*
* @var \Drupal\package_manager\ImmutablePathList
*/
public readonly ImmutablePathList $excludedPaths;
/**
* Constructs a PreApplyEvent object.
*
* @param \Drupal\package_manager\SandboxManagerBase $sandboxManager
* The stage which fired this event.
* @param \PhpTuf\ComposerStager\API\Path\Value\PathListInterface $excluded_paths
* The list of paths to exclude. These will not be copied from the stage
* directory to the active directory, nor be deleted from the active
* directory if they exist, when the stage directory is copied back into
* the active directory.
*/
public function __construct(SandboxManagerBase $sandboxManager, PathListInterface $excluded_paths) {
parent::__construct($sandboxManager);
$this->excludedPaths = new ImmutablePathList($excluded_paths);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
use Drupal\package_manager\ImmutablePathList;
use Drupal\package_manager\SandboxManagerBase;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
/**
* Event fired before a stage directory is created.
*/
final class PreCreateEvent extends SandboxValidationEvent {
/**
* The list of paths to exclude from the stage directory.
*
* @var \Drupal\package_manager\ImmutablePathList
*/
public readonly ImmutablePathList $excludedPaths;
/**
* Constructs a PreCreateEvent object.
*
* @param \Drupal\package_manager\SandboxManagerBase $sandboxManager
* The stage which fired this event.
* @param \PhpTuf\ComposerStager\API\Path\Value\PathListInterface $excluded_paths
* The list of paths to exclude. These will not be copied into the stage
* directory when it is created.
*/
public function __construct(SandboxManagerBase $sandboxManager, PathListInterface $excluded_paths) {
parent::__construct($sandboxManager);
$this->excludedPaths = new ImmutablePathList($excluded_paths);
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
/**
* Event fired before packages are updated to the stage directory.
*/
final class PreRequireEvent extends SandboxValidationEvent {
use EventWithPackageListTrait;
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
use Drupal\package_manager\SandboxManagerBase;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Base class for all events related to the life cycle of the stage.
*/
abstract class SandboxEvent extends Event {
/**
* Constructs a StageEvent object.
*
* @param \Drupal\package_manager\SandboxManagerBase $sandboxManager
* The stage which fired this event.
*/
public function __construct(public readonly SandboxManagerBase $sandboxManager) {
}
}

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\package_manager\ValidationResult;
/**
* Base class for events dispatched before a stage life cycle operation.
*/
abstract class SandboxValidationEvent extends SandboxEvent {
/**
* The validation results.
*
* @var \Drupal\package_manager\ValidationResult[]
*/
protected $results = [];
/**
* Gets the validation results.
*
* @param int|null $severity
* (optional) The severity for the results to return. Should be one of the
* SystemManager::REQUIREMENT_* constants.
*
* @return \Drupal\package_manager\ValidationResult[]
* The validation results.
*/
public function getResults(?int $severity = NULL): array {
if ($severity !== NULL) {
return array_filter($this->results, function ($result) use ($severity) {
return $result->severity === $severity;
});
}
return $this->results;
}
/**
* Convenience method to flag a validation error.
*
* @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
* The error messages.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
* The summary of error messages. Must be passed if there is more than one
* message.
*/
public function addError(array $messages, ?TranslatableMarkup $summary = NULL): void {
$this->addResult(ValidationResult::createError(array_values($messages), $summary));
}
/**
* Convenience method, adds an error validation result from a throwable.
*
* @param \Throwable $throwable
* The throwable.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
* (optional) The summary of error messages.
*/
public function addErrorFromThrowable(\Throwable $throwable, ?TranslatableMarkup $summary = NULL): void {
$this->addResult(ValidationResult::createErrorFromThrowable($throwable, $summary));
}
/**
* Adds a validation result to the event.
*
* @param \Drupal\package_manager\ValidationResult $result
* The validation result to add.
*
* @throws \InvalidArgumentException
* Thrown if the validation result is not an error.
*/
public function addResult(ValidationResult $result): void {
// Only errors are allowed for this event.
if ($result->severity !== RequirementSeverity::Error->value) {
throw new \InvalidArgumentException('Only errors are allowed.');
}
$this->results[] = $result;
}
/**
* {@inheritdoc}
*/
public function stopPropagation(): void {
if (empty($this->getResults(RequirementSeverity::Error->value))) {
$this->addErrorFromThrowable(new \LogicException('Event propagation stopped without any errors added to the event. This bypasses the package_manager validation system.'));
}
parent::stopPropagation();
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Event;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\package_manager\ImmutablePathList;
use Drupal\package_manager\SandboxManagerBase;
use Drupal\package_manager\ValidationResult;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
/**
* Event fired to check the status of the system to use Package Manager.
*/
final class StatusCheckEvent extends SandboxValidationEvent {
/**
* The paths to exclude, or NULL if there was an error collecting them.
*
* @var \Drupal\package_manager\ImmutablePathList|null
*
* @see ::__construct()
*/
public readonly ?ImmutablePathList $excludedPaths;
/**
* Constructs a StatusCheckEvent object.
*
* @param \Drupal\package_manager\SandboxManagerBase $sandboxManager
* The stage which fired this event.
* @param \PhpTuf\ComposerStager\API\Path\Value\PathListInterface|\Throwable $excluded_paths
* The list of paths to exclude or, if an error occurred while they were
* being collected, the throwable from that error. If this is a throwable,
* it will be converted to a validation error.
*/
public function __construct(SandboxManagerBase $sandboxManager, PathListInterface|\Throwable $excluded_paths) {
parent::__construct($sandboxManager);
// If there was an error collecting the excluded paths, convert it to a
// validation error so we can still run status checks that don't need to
// examine the list of excluded paths.
if ($excluded_paths instanceof \Throwable) {
$this->addErrorFromThrowable($excluded_paths);
$excluded_paths = NULL;
}
else {
$excluded_paths = new ImmutablePathList($excluded_paths);
}
$this->excludedPaths = $excluded_paths;
}
/**
* Adds warning information to the event.
*
* @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
* One or more warning messages.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
* A summary of warning messages. Must be passed if there is more than one
* message.
*/
public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL): void {
$this->addResult(ValidationResult::createWarning($messages, $summary));
}
/**
* {@inheritdoc}
*/
public function addResult(ValidationResult $result): void {
// Override the parent to also allow warnings.
$this->results[] = $result;
}
}

View File

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\EventSubscriber;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PostApplyEvent;
use Drupal\package_manager\Event\PostRequireEvent;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\PathLocator;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Event subscriber to log changes that happen during the stage life cycle.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ChangeLogger implements EventSubscriberInterface, LoggerAwareInterface {
use LoggerAwareTrait;
use StringTranslationTrait;
/**
* The key to store the list of packages installed when the stage is created.
*
* @var string
*
* @see ::recordInstalledPackages()
*/
private const INSTALLED_PACKAGES_KEY = 'package_manager_installed_packages';
/**
* The metadata key under which to store the requested package versions.
*
* @var string
*
* @see ::recordRequestedPackageVersions()
*/
private const REQUESTED_PACKAGES_KEY = 'package_manager_requested_packages';
public function __construct(
private readonly ComposerInspector $composerInspector,
private readonly PathLocator $pathLocator,
) {}
/**
* Records packages installed in the project root.
*
* We need to do this before the staging environment has been created, so that
* we have a complete picture of which requested packages are merely being
* updated, and which are being newly added. Once the staging environment has
* been created, the installed packages won't change -- if they do, a
* validation error will be raised.
*
* @param \Drupal\package_manager\Event\PreCreateEvent $event
* The event being handled.
*
* @see \Drupal\package_manager\Validator\LockFileValidator
*/
public function recordInstalledPackages(PreCreateEvent $event): void {
$packages = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot());
$event->sandboxManager->setMetadata(static::INSTALLED_PACKAGES_KEY, $packages);
}
/**
* Records requested packages.
*
* @param \Drupal\package_manager\Event\PostRequireEvent $event
* The event object.
*/
public function recordRequestedPackageVersions(PostRequireEvent $event): void {
// There could be multiple 'require' operations, so overlay the requested
// packages from the current operation onto the requested packages from any
// previous 'require' operation.
$requested_packages = array_merge(
$event->sandboxManager->getMetadata(static::REQUESTED_PACKAGES_KEY) ?? [],
$event->getRuntimePackages(),
$event->getDevPackages(),
);
$event->sandboxManager->setMetadata(static::REQUESTED_PACKAGES_KEY, $requested_packages);
// If we're in direct-write mode, the changes have already been made, so
// we should log them right away.
if ($event->sandboxManager->isDirectWrite()) {
$this->logChanges($event);
}
}
/**
* Logs changes made by Package Manager.
*
* @param \Drupal\package_manager\Event\PostApplyEvent|\Drupal\package_manager\Event\PostRequireEvent $event
* The event being handled.
*/
public function logChanges(PostApplyEvent|PostRequireEvent $event): void {
$installed_at_start = $event->sandboxManager->getMetadata(static::INSTALLED_PACKAGES_KEY);
$installed_post_apply = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot());
// Compare the packages which were installed when the stage was created
// against the package versions that were requested over all the stage's
// require operations, and create a log entry listing all of it.
$requested_log = [];
$requested_packages = $event->sandboxManager->getMetadata(static::REQUESTED_PACKAGES_KEY) ?? [];
// Sort the requested packages by name, to make it easier to review a large
// change list.
ksort($requested_packages, SORT_NATURAL);
foreach ($requested_packages as $name => $constraint) {
$installed_version = $installed_at_start[$name]?->version;
if ($installed_version === NULL) {
// For clarity, make the "any version" constraint human-readable.
if ($constraint === '*') {
$constraint = $this->t('* (any version)');
}
$requested_log[] = $this->t('- Install @name @constraint', [
'@name' => $name,
'@constraint' => $constraint,
]);
}
else {
$requested_log[] = $this->t('- Update @name from @installed_version to @constraint', [
'@name' => $name,
'@installed_version' => $installed_version,
'@constraint' => $constraint,
]);
}
}
// It's possible that $requested_log will be empty: for example, a custom
// stage that only does removals, or some other operation, and never
// dispatches PostRequireEvent.
if ($requested_log) {
$message = $this->t("Requested changes:\n@change_list", [
'@change_list' => implode("\n", array_map('strval', $requested_log)),
]);
$this->logger?->info($message);
}
// Create a separate log entry listing everything that actually changed.
$applied_log = [];
$updated_packages = $installed_post_apply->getPackagesWithDifferentVersionsIn($installed_at_start);
// Sort the packages by name to make it easier to review large change sets.
$updated_packages->ksort(SORT_NATURAL);
foreach ($updated_packages as $name => $package) {
$applied_log[] = $this->t('- Updated @name from @installed_version to @updated_version', [
'@name' => $name,
'@installed_version' => $installed_at_start[$name]->version,
'@updated_version' => $package->version,
]);
}
$added_packages = $installed_post_apply->getPackagesNotIn($installed_at_start);
$added_packages->ksort(SORT_NATURAL);
foreach ($added_packages as $name => $package) {
$applied_log[] = $this->t('- Installed @name @version', [
'@name' => $name,
'@version' => $package->version,
]);
}
$removed_packages = $installed_at_start->getPackagesNotIn($installed_post_apply);
$removed_packages->ksort(SORT_NATURAL);
foreach ($installed_at_start->getPackagesNotIn($installed_post_apply) as $name => $package) {
$applied_log[] = $this->t('- Uninstalled @name', ['@name' => $name]);
}
$message = $this->t("Applied changes:\n@change_list", [
'@change_list' => implode("\n", array_map('strval', $applied_log)),
]);
$this->logger?->info($message);
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => ['recordInstalledPackages'],
PostRequireEvent::class => ['recordRequestedPackageVersions'],
PostApplyEvent::class => ['logChanges'],
];
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\EventSubscriber;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PostRequireEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Handles sandbox events when direct-write is enabled.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class DirectWriteSubscriber implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* The state key which holds the original status of maintenance mode.
*
* @var string
*/
private const STATE_KEY = 'package_manager.maintenance_mode';
public function __construct(private readonly StateInterface $state) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
StatusCheckEvent::class => 'warnAboutDirectWrite',
// We want to go into maintenance mode after other subscribers, to give
// them a chance to flag errors.
PreRequireEvent::class => ['enterMaintenanceMode', -10000],
// We want to exit maintenance mode as early as possible.
PostRequireEvent::class => ['exitMaintenanceMode', 10000],
];
}
/**
* Logs a warning about direct-write mode, if it is in use.
*
* @param \Drupal\package_manager\Event\StatusCheckEvent $event
* The event being handled.
*/
public function warnAboutDirectWrite(StatusCheckEvent $event): void {
if ($event->sandboxManager->isDirectWrite()) {
$event->addWarning([
$this->t('Direct-write mode is enabled, which means that changes will be made without sandboxing them first. This can be risky and is not recommended for production environments. For safety, your site will be put into maintenance mode while dependencies are updated.'),
]);
}
}
/**
* Enters maintenance mode before a direct-mode require operation.
*
* @param \Drupal\package_manager\Event\PreRequireEvent $event
* The event being handled.
*/
public function enterMaintenanceMode(PreRequireEvent $event): void {
$errors = $event->getResults(RequirementSeverity::Error->value);
if (empty($errors) && $event->sandboxManager->isDirectWrite()) {
$this->state->set(static::STATE_KEY, (bool) $this->state->get('system.maintenance_mode'));
$this->state->set('system.maintenance_mode', TRUE);
}
}
/**
* Leaves maintenance mode after a direct-mode require operation.
*
* @param \Drupal\package_manager\Event\PreRequireEvent $event
* The event being handled.
*/
public function exitMaintenanceMode(PostRequireEvent $event): void {
if ($event->sandboxManager->isDirectWrite()) {
$this->state->set('system.maintenance_mode', $this->state->get(static::STATE_KEY));
$this->state->delete(static::STATE_KEY);
}
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\EventSubscriber;
use Drupal\package_manager\Event\PostApplyEvent;
use Drupal\update\UpdateManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Clears stale update data once staged changes have been applied.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class UpdateDataSubscriber implements EventSubscriberInterface {
public function __construct(private readonly UpdateManagerInterface $updateManager) {
}
/**
* Clears stale update data.
*
* This will always run after any stage directory changes are applied to the
* active directory, since it's likely that core and/or multiple extensions
* have been added, removed, or updated.
*/
public function clearData(): void {
$this->updateManager->refreshUpdateData();
update_storage_clear();
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PostApplyEvent::class => ['clearData', 1000],
];
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Exception;
/**
* Exception thrown if a stage encounters an error applying an update.
*
* If this exception is thrown it indicates that an update of the active
* codebase was attempted but failed. If this happens the site code is in an
* indeterminate state. Package Manager does not provide a method for recovering
* from this state. The site code should be restored from a backup.
*
* This exception is different from StageFailureMarkerException in that it is
* thrown if an error happens *during* the apply operation, rather than before
* or after it.
*
* Should not be thrown by external code.
*/
final class ApplyFailedException extends SandboxException {
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Exception;
/**
* Exception thrown if we cannot reliably use Composer.
*
* Should not be thrown by external code.
*
* @see \Drupal\package_manager\ComposerInspector::validate()
*/
final class ComposerNotReadyException extends \RuntimeException {
/**
* Constructs a ComposerNotReadyException object.
*
* @param string|null $workingDir
* The directory where Composer was run, or NULL if the errors are related
* to the Composer executable itself.
* @param array $messages
* An array of messages explaining why Composer cannot be run correctly.
* @param int $code
* (optional) The exception code. Defaults to 0.
* @param \Throwable|null $previous
* (optional) The previous exception, for exception chaining.
*/
public function __construct(public readonly ?string $workingDir, array $messages, int $code = 0, ?\Throwable $previous = NULL) {
parent::__construct(implode("\n", $messages), $code, $previous);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Exception;
/**
* Exception thrown if a stage can't be created due to an earlier failed commit.
*
* If this exception is thrown it indicates that an earlier commit operation had
* failed. If this happens the site code is in an indeterminate state. Package
* Manager does not provide a method for recovering from this state. The site
* code should be restored from a backup.
*
* We are extending RuntimeException rather than StageException which makes it
* clear that it's unrelated to the stage life cycle.
*
* This exception is different from ApplyFailedException as it focuses on
* the failure marker being detected outside the stage lifecycle.
*/
final class FailureMarkerExistsException extends \RuntimeException {
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Exception;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\SandboxEvent;
/**
* Exception thrown if an error related to an event occurs.
*
* This exception is thrown when an error strictly associated with an event
* occurs. This is also what makes it different from StageException.
*
* Should not be thrown by external code.
*/
class SandboxEventException extends SandboxException {
/**
* Constructs a StageEventException object.
*
* @param \Drupal\package_manager\Event\SandboxEvent $event
* The stage event during which this exception is thrown.
* @param string|null $message
* (optional) The exception message. Defaults to a plain text representation
* of the validation results.
* @param mixed ...$arguments
* Additional arguments to pass to the parent constructor.
*/
public function __construct(public readonly SandboxEvent $event, ?string $message = NULL, ...$arguments) {
parent::__construct($event->sandboxManager, $message ?: $this->getResultsAsText(), ...$arguments);
}
/**
* Formats the validation results, if any, as plain text.
*
* @return string
* The results, formatted as plain text.
*/
protected function getResultsAsText(): string {
$text = '';
if ($this->event instanceof SandboxValidationEvent) {
foreach ($this->event->getResults() as $result) {
$messages = $result->messages;
$summary = $result->summary;
if ($summary) {
array_unshift($messages, $summary);
}
$text .= implode("\n", $messages) . "\n";
}
}
return $text;
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Exception;
use Drupal\package_manager\SandboxManagerBase;
/**
* Base class for all exceptions related to stage operations.
*
* Should not be thrown by external code.
*/
class SandboxException extends \RuntimeException {
/**
* Constructs a StageException object.
*
* @param \Drupal\package_manager\SandboxManagerBase $sandboxManager
* The stage.
* @param mixed ...$arguments
* Additional arguments to pass to the parent constructor.
*/
public function __construct(public readonly SandboxManagerBase $sandboxManager, ...$arguments) {
parent::__construct(...$arguments);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Exception;
/**
* Exception thrown if a stage encounters an ownership or locking error.
*
* Should not be thrown by external code.
*/
final class SandboxOwnershipException extends SandboxException {
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Composer\InstalledVersions;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Site\Settings;
use PhpTuf\ComposerStager\API\Exception\LogicException;
use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
/**
* An executable finder which looks for executable paths in configuration.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ExecutableFinder implements ExecutableFinderInterface, LoggerAwareInterface {
use LoggerAwareTrait;
/**
* The path where Composer is installed in the project, or FALSE if it's not.
*/
private string|false|null $composerPackagePath = NULL;
/**
* The path of the Composer binary, or NULL if it can't be found.
*/
private ?string $composerBinaryPath = NULL;
public function __construct(
private readonly ExecutableFinderInterface $decorated,
private readonly FileSystemInterface $fileSystem,
private readonly ConfigFactoryInterface $configFactory,
) {
$this->composerPackagePath = InstalledVersions::isInstalled('composer/composer')
? InstalledVersions::getInstallPath('composer/composer')
: FALSE;
}
/**
* {@inheritdoc}
*/
public function find(string $name): string {
$legacy_executables = $this->configFactory->get('package_manager.settings')
->get('executables');
if ($name === 'rsync') {
try {
return Settings::get('package_manager_rsync_path', $this->decorated->find($name));
}
catch (LogicException $e) {
if (isset($legacy_executables[$name])) {
@trigger_error("Storing the path to rsync in configuration is deprecated in drupal:11.2.4 and not supported in drupal:12.0.0. Move it to the <code>package_manager_rsync_path</code> setting instead. See https://www.drupal.org/node/3540264", E_USER_DEPRECATED);
return $legacy_executables[$name];
}
throw $e;
}
}
// If we're looking for Composer, use the project's local copy if available.
elseif ($name === 'composer') {
$path = $this->getLocalComposerPath();
if ($path && file_exists($path)) {
return $path;
}
// If the regular executable finder can't find Composer, and it's not
// overridden by a setting, fall back to the configured path to Composer
// (if available), which is no longer supported.
try {
return Settings::get('package_manager_composer_path', $this->decorated->find($name));
}
catch (LogicException $e) {
if (isset($legacy_executables[$name])) {
@trigger_error("Storing the path to Composer in configuration is deprecated in drupal:11.2.4 and not supported in drupal:12.0.0. Add composer/composer directly to your project's dependencies instead. See https://www.drupal.org/node/3540264", E_USER_DEPRECATED);
return $legacy_executables[$name];
}
throw $e;
}
}
return $this->decorated->find($name);
}
/**
* Tries to find the Composer binary installed in the project.
*
* @return string|null
* The path of the `composer` binary installed in the project's vendor
* dependencies, or NULL if it is not installed or cannot be found.
*/
private function getLocalComposerPath(): ?string {
// Composer is not installed in the project, so there's nothing to do.
if ($this->composerPackagePath === FALSE) {
return NULL;
}
// This is a bit expensive to compute, so statically cache it.
if ($this->composerBinaryPath) {
return $this->composerBinaryPath;
}
$composer_json = file_get_contents($this->composerPackagePath . '/composer.json');
$composer_json = Json::decode($composer_json);
foreach ($composer_json['bin'] ?? [] as $bin) {
if (str_ends_with($bin, '/composer')) {
$bin = $this->composerPackagePath . '/' . $bin;
// For extra security, try to disable the binary's execute permission.
// If that fails, it's worth warning about but is not an actual problem.
if (is_executable($bin) && !$this->fileSystem->chmod($bin, 0644)) {
$this->logger?->warning('Composer was found at %path, but could not be made read-only.', [
'%path' => $bin,
]);
}
return $this->composerBinaryPath = $bin;
}
}
return NULL;
}
}

View File

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\package_manager\Exception\FailureMarkerExistsException;
/**
* Handles failure marker file operation.
*
* The failure marker is a file placed in the active directory while staged
* code is copied back into it, and then removed afterward. This allows us to
* know if a commit operation failed midway through, which could leave the site
* code base in an indeterminate state -- which, in the worst case scenario,
* might render Drupal being unable to boot.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class FailureMarker implements EventSubscriberInterface {
public function __construct(private readonly PathLocator $pathLocator) {
}
/**
* Gets the marker file path.
*
* @return string
* The absolute path of the marker file.
*/
public function getPath(): string {
return $this->pathLocator->getProjectRoot() . '/PACKAGE_MANAGER_FAILURE.yml';
}
/**
* Deletes the marker file.
*/
public function clear(): void {
unlink($this->getPath());
}
/**
* Writes data to marker file.
*
* @param \Drupal\package_manager\SandboxManagerBase $sandbox_manager
* The stage.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $message
* Failure message to be added.
* @param \Throwable|null $throwable
* (optional) The throwable that caused the failure.
*/
public function write(SandboxManagerBase $sandbox_manager, TranslatableMarkup $message, ?\Throwable $throwable = NULL): void {
$data = [
'stage_class' => get_class($sandbox_manager),
'stage_type' => $sandbox_manager->getType(),
'stage_file' => (new \ReflectionObject($sandbox_manager))->getFileName(),
'message' => (string) $message,
'throwable_class' => $throwable ? get_class($throwable) : FALSE,
'throwable_message' => $throwable?->getMessage() ?? 'Not available',
'throwable_backtrace' => $throwable?->getTraceAsString() ?? 'Not available.',
];
file_put_contents($this->getPath(), Yaml::dump($data));
}
/**
* Gets the data from the file if it exists.
*
* @return array|null
* The data from the file if it exists.
*
* @throws \Drupal\package_manager\Exception\FailureMarkerExistsException
* Thrown if failure marker exists but cannot be decoded.
*/
private function getData(): ?array {
$path = $this->getPath();
if (file_exists($path)) {
$data = file_get_contents($path);
try {
return Yaml::parse($data);
}
catch (ParseException $exception) {
throw new FailureMarkerExistsException('Failure marker file exists but cannot be decoded.', $exception->getCode(), $exception);
}
}
return NULL;
}
/**
* Gets the message from the file if it exists.
*
* @param bool $include_backtrace
* Whether to include the backtrace in the message. Defaults to TRUE. May be
* set to FALSE in a context where it does not make sense to include, such
* as emails.
*
* @return string|null
* The message from the file if it exists, otherwise NULL.
*
* @throws \Drupal\package_manager\Exception\FailureMarkerExistsException
* Thrown if failure marker exists but cannot be decoded.
*/
public function getMessage(bool $include_backtrace = TRUE): ?string {
$data = $this->getData();
if ($data === NULL) {
return NULL;
}
$message = $data['message'];
if ($data['throwable_class']) {
$message .= sprintf(
' Caused by %s, with this message: %s',
$data['throwable_class'],
$data['throwable_message'],
);
if ($include_backtrace) {
$message .= "\nBacktrace:\n" . $data['throwable_backtrace'];
}
}
return $message;
}
/**
* Asserts the failure file doesn't exist.
*
* @throws \Drupal\package_manager\Exception\FailureMarkerExistsException
* Thrown if the marker file exists.
*/
public function assertNotExists(): void {
if ($message = $this->getMessage()) {
throw new FailureMarkerExistsException($message);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
CollectPathsToExcludeEvent::class => 'excludeMarkerFile',
];
}
/**
* Excludes the failure marker file from stage operations.
*
* @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event
* The event being handled.
*/
public function excludeMarkerFile(CollectPathsToExcludeEvent $event): void {
$event->addPathsRelativeToProjectRoot([
$this->getPath(),
]);
}
}

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface;
use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum;
/**
* Logs process output to a file.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class FileProcessOutputCallback implements OutputCallbackInterface {
/**
* The file to write to.
*
* @var resource
*/
private readonly mixed $handle;
public function __construct(
string $path,
private readonly ?OutputCallbackInterface $decorated = NULL,
) {
$this->handle = fopen($path, 'a');
if (empty($this->handle)) {
throw new \RuntimeException("Could not open or create '$path' for writing.");
}
}
/**
* {@inheritdoc}
*/
public function clearErrorOutput(): void {
$this->decorated?->clearErrorOutput();
}
/**
* {@inheritdoc}
*/
public function clearOutput(): void {
$this->decorated?->clearOutput();
}
/**
* {@inheritdoc}
*/
public function getErrorOutput(): array {
return $this->decorated?->getErrorOutput() ?? [];
}
/**
* {@inheritdoc}
*/
public function getOutput(): array {
return $this->decorated?->getOutput() ?? [];
}
/**
* {@inheritdoc}
*/
public function __invoke(OutputTypeEnum $type, string $buffer): void {
fwrite($this->handle, $buffer);
if ($this->decorated) {
($this->decorated)($type, $buffer);
}
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace Drupal\package_manager\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for package_manager.
*/
class PackageManagerHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name) : ?string {
switch ($route_name) {
case 'help.page.package_manager':
$output = '<h3 id="package-manager-about">' . $this->t('About') . '</h3>';
$output .= '<p>' . $this->t('Package Manager is a framework for updating Drupal core and installing contributed modules and themes via Composer. It has no user interface, but it provides an API for creating a temporary copy of the current site, making changes to the copy, and then syncing those changes back into the live site.') . '</p>';
$output .= '<p>' . $this->t('Package Manager dispatches events before and after various operations, and external code can integrate with it by subscribing to those events. For more information, see <code>package_manager.api.php</code>.') . '</p>';
$output .= '<h3 id="package-manager-requirements">' . $this->t('Requirements') . '</h3>';
$output .= '<ul>';
$output .= ' <li>' . $this->t("The Drupal application's codebase must be writable in order to use Automatic Updates. This includes Drupal core, modules, themes and the Composer dependencies in the <code>vendor</code> directory. This makes Automatic Updates incompatible with some hosting platforms.") . '</li>';
$output .= ' <li>' . $this->t('Package Manager requires a Composer executable whose version satisfies <code>@version</code>, and PHP must have permission to run it.', ['@version' => ComposerInspector::SUPPORTED_VERSION]) . '</li>';
$output .= ' <li>' . $this->t("Your Drupal site's <code>composer.json</code> file must be valid according to <code>composer validate</code>. See <a href=\":url\">Composer's documentation</a> for more information.", [':url' => 'https://getcomposer.org/doc/03-cli.md#validate']) . '</li>';
$output .= ' <li>' . $this->t('Composer must be configured for secure downloads. This means that <a href=":disable-tls">the <code>disable-tls</code> option</a> must be <code>false</code>, and <a href=":secure-http">the <code>secure-http</code> option</a> must be <code>true</code> in the <code>config</code> section of your <code>composer.json</code> file. If these options are not set in your <code>composer.json</code>, Composer will behave securely by default. To set these values at the command line, run the following commands:', [
':disable-tls' => 'https://getcomposer.org/doc/06-config.md#disable-tls',
':secure-http' => 'https://getcomposer.org/doc/06-config.md#secure-http',
]);
$output .= '<pre><code>';
$output .= "composer config --unset disable-tls\n";
$output .= "composer config --unset secure-http\n";
$output .= '</code></pre></li></ul>';
$output .= '<h3 id="package-manager-limitations">' . $this->t('Limitations') . '</h3>';
$output .= '<p>' . $this->t("Because Package Manager modifies the current site's code base, it is intentionally limited in certain ways to prevent unexpected changes to the live site:") . '</p>';
$output .= '<ul>';
$output .= ' <li>' . $this->t('It does not support Drupal multi-site installations.') . '</li>';
$output .= ' <li>' . $this->t('It only allows supported Composer plugins. If you have any, see <a href="#package-manager-faq-unsupported-composer-plugin">What if it says I have unsupported Composer plugins in my codebase?</a>.') . '</li>';
$output .= ' <li>' . $this->t('It does not automatically perform version control operations, e.g., with Git. Site administrators are responsible for committing updates.') . '</li>';
$output .= ' <li>' . $this->t('It can only maintain one copy of the site at any given time. If a copy of the site already exists, another one cannot be created until the existing copy is destroyed.') . '</li>';
$output .= ' <li>' . $this->t('It associates the temporary copy of the site with the user or session that originally created it, and only that user or session can make changes to it.') . '</li>';
$output .= ' <li>' . $this->t('It does not allow modules to be uninstalled while syncing changes into live site.') . '</li>';
$output .= '</ul>';
$output .= '<p>' . $this->t('For more information, see the <a href=":url">online documentation for the Package Manager module</a>.', [':url' => 'https://www.drupal.org/docs/8/core/modules/package-manager']) . '</p>';
$output .= '<h3 id="package-manager-faq">' . $this->t('FAQ') . '</h3>';
$output .= '<h4 id="package-manager-composer-related-faq">' . $this->t('FAQs related to Composer') . '</h4>';
$output .= '<ul>';
$output .= ' <li>' . $this->t('What if it says the <code>proc_open()</code> function is disabled on your PHP installation?');
$output .= ' <p>' . $this->t('Ask your system administrator to remove <code>proc_open()</code> from the <a href=":url">disable_functions</a> setting in <code>php.ini</code>.', [':url' => 'https://www.php.net/manual/en/ini.core.php#ini.disable-functions']) . '</p>';
$output .= ' </li>';
$output .= ' <li>' . $this->t('What if it says the <code>composer</code> executable cannot be found?');
$output .= ' <p>' . $this->t("If the <code>composer</code> executable's path cannot be automatically determined, you will need to add Composer to your project by running the following command: <code>composer require \"composer/composer:@version\"</code>:", [
'@version' => ComposerInspector::SUPPORTED_VERSION,
]) . '</p>';
$output .= ' </li>';
$output .= ' <li>' . $this->t('What if it says the detected version of Composer is not supported?');
$output .= ' <p>' . $this->t('The version of the <code>composer</code> executable must satisfy <code>@version</code>. See the <a href=":url">the Composer documentation</a> for more information, or use this command to add Composer to your project: <code>composer require "composer/composer:@version"</code>', [
'@version' => ComposerInspector::SUPPORTED_VERSION,
':url' => 'https://getcomposer.org/doc/03-cli.md#self-update-selfupdate',
]) . '</p>';
$output .= ' </li>';
$output .= ' <li>' . $this->t('What if it says the <code>composer validate</code> command failed?');
$output .= ' <p>' . $this->t('Composer detected problems with your <code>composer.json</code> and/or <code>composer.lock</code> files, and the project is not in a completely valid state. See <a href=":url">the Composer documentation</a> for more information.', [':url' => 'https://getcomposer.org/doc/04-schema.md']) . '</p>';
$output .= ' </li>';
$output .= '</ul>';
$output .= '<h4 id="package-manager-faq-rsync">' . $this->t('Using rsync') . '</h4>';
$output .= '<p>' . $this->t('Package Manager must be able to run <code>rsync</code> to copy files between the live site and the stage directory. Package Manager will try to detect the path to <code>rsync</code>, but if it cannot be detected, you can set it explicitly by adding the following line to <code>settings.php</code>:') . '</p>';
$output .= "<pre><code>\$config['package_manager.settings']['executables']['rsync'] = '/full/path/to/rsync';</code></pre>";
$output .= '<h4 id="package-manager-tuf-info">' . $this->t('Enabling PHP-TUF protection') . '</h4>';
$output .= '<p>' . $this->t('Package Manager requires <a href=":php-tuf">PHP-TUF</a>, which implements <a href=":tuf">The Update Framework</a> as a way to help secure Composer package downloads via the <a href=":php-tuf-plugin">PHP-TUF Composer integration plugin</a>. This plugin must be installed and configured properly in order to use Package Manager.', [
':php-tuf' => 'https://github.com/php-tuf/php-tuf',
':tuf' => 'https://theupdateframework.io/',
':php-tuf-plugin' => 'https://github.com/php-tuf/composer-integration',
]) . '</p>';
$output .= '<p>' . $this->t('To install and configure the plugin as needed, you can run the following commands:') . '</p>';
$output .= '<pre><code>';
$output .= "composer config allow-plugins.php-tuf/composer-integration true\n";
$output .= "composer require php-tuf/composer-integration";
$output .= '</code></pre>';
$output .= '<p>' . $this->t('Package Manager currently requires the <code>https://packages.drupal.org/8</code> Composer repository to be protected by TUF. To set this up, run the following command:') . '</p>';
$output .= '<pre><code>';
$output .= "composer tuf:protect https://packages.drupal.org/8\n";
$output .= '</code></pre>';
$output .= '<h4 id="package-manager-faq-unsupported-composer-plugin">' . $this->t('What if it says I have unsupported Composer plugins in my codebase?') . '</h4>';
$output .= '<p>' . $this->t('A fresh Drupal installation only uses supported Composer plugins, but some modules or themes may depend on additional Composer plugins. <a href=":new-issue">Create a new issue</a> when you encounter this.', [':new-issue' => 'https://www.drupal.org/node/add/project-issue/auto_updates']) . '</p>';
$output .= '<p>' . $this->t('It is possible to <em>trust</em> additional Composer plugins, but this requires significant expertise: understanding the code of that Composer plugin, what the effects on the file system are and how it affects the Package Manager module. Some Composer plugins could result in a broken site!') . '</p>';
$output .= '<h4 id="package-manager-faq-composer-patches-installed-or-removed">' . $this->t('What if it says <code>cweagans/composer-patches</code> cannot be installed/removed?') . '</h4>';
$output .= '<p>' . $this->t('Installation or removal of <code>cweagans/composer-patches</code> via Package Manager is not supported. You can install or remove it manually by running Composer commands in your site root.') . '</p>';
$output .= '<p>' . $this->t('To install it:') . '</p>';
$output .= '<pre><code>composer require cweagans/composer-patches</code></pre>';
$output .= '<p>' . $this->t('To remove it:') . '</p>';
$output .= '<pre><code>composer remove cweagans/composer-patches</code></pre>';
$output .= '<h4 id="package-manager-faq-composer-patches-not-a-root-dependency">' . $this->t('What if it says <code>cweagans/composer-patches</code> must be a root dependency?') . '</h4>';
$output .= '<p>' . $this->t('If <code>cweagans/composer-patches</code> is installed, it must be defined as a dependency of the main project (i.e., it must be listed in the <code>require</code> or <code>require-dev</code> section of <code>composer.json</code>). You can run the following command in your site root to add it as a dependency of the main project:') . '</p>';
$output .= "<pre><code>composer require cweagans/composer-patches</code></pre>";
return $output;
}
return NULL;
}
}

View File

@ -0,0 +1,163 @@
<?php
namespace Drupal\package_manager\Hook;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Site\Settings;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Exception\FailureMarkerExistsException;
use Drupal\package_manager\FailureMarker;
use PhpTuf\ComposerStager\API\Exception\ExceptionInterface;
use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface;
/**
* Requirements checks for Package Manager.
*/
class PackageManagerRequirementsHooks {
use StringTranslationTrait;
public function __construct(
protected readonly ComposerInspector $composerInspector,
protected ExecutableFinderInterface $executableFinder,
private readonly ConfigFactoryInterface $configFactory,
) {}
/**
* Implements hook_runtime_requirements().
*/
#[Hook('runtime_requirements')]
public function runtime(): array {
$requirements = [];
$requirements = $this->checkSettings($requirements);
$requirements = $this->checkFailure($requirements);
// Report the Composer version in use, as well as its path.
$title = $this->t('Composer version');
try {
$requirements['package_manager_composer'] = [
'title' => $title,
'description' => $this->t('@version (<code>@path</code>)', [
'@version' => $this->composerInspector->getVersion(),
'@path' => $this->executableFinder->find('composer'),
]),
'severity' => RequirementSeverity::Info,
];
}
catch (\Throwable $e) {
// All Composer Stager exceptions are translatable.
$message = $e instanceof ExceptionInterface
? $e->getTranslatableMessage()
: $e->getMessage();
$requirements['package_manager_composer'] = [
'title' => $title,
'description' => $this->t('Composer was not found. The error message was: @message', [
'@message' => $message,
]),
'severity' => RequirementSeverity::Error,
];
}
$legacy_executables = $this->configFactory->get('package_manager.settings')
->get('executables');
if (isset($legacy_executables['composer'])) {
$requirements['package_manager_configured_executable_composer'] = [
'title' => $this->t('Path to Composer is configured'),
'description' => $this->t("The path to Composer is set in configuration, which is no longer supported. To fix this, add <code>composer/composer</code> to your project's dependencies by running <code>composer require 'composer/composer:@version'</code>, <em>or</em> add the following line to <code>settings.php</code>: <code>\$settings['package_manager_composer_path'] = '@composer_path';</code>. See <a href=\":url\">this change record</a> for more information.", [
'@composer_path' => $legacy_executables['composer'],
'@version' => ComposerInspector::SUPPORTED_VERSION,
':url' => 'https://www.drupal.org/node/3540264',
]),
'severity' => RequirementSeverity::Warning,
];
}
if (isset($legacy_executables['rsync'])) {
$requirements['package_manager_configured_executable_rsync'] = [
'title' => $this->t('Path to <code>rsync</code> is configured'),
'description' => $this->t("The path to <code>rsync</code> is set in configuration, which is no longer supported. To fix this, add the following line to <code>settings.php</code>: <code>\$settings['package_manager_rsync_path'] = '@rsync_path';</code>. See <a href=\":url\">this change record</a> for more information.", [
'@rsync_path' => $legacy_executables['rsync'],
':url' => 'https://www.drupal.org/node/3540264',
]),
'severity' => RequirementSeverity::Warning,
];
}
return $requirements;
}
/**
* Implements hook_update_requirements().
*/
#[Hook('update_requirements')]
public function update(): array {
$requirements = [];
$requirements = $this->checkSettings($requirements);
$requirements = $this->checkFailure($requirements);
return $requirements;
}
/**
* Check that package manager has an explicit setting to allow installation.
*
* @param array $requirements
* The requirements array that has been processed so far.
*
* @return array
* Requirements array.
*
* @see hook_runtime_requirements
* @see hook_update_requirements
*/
public function checkSettings($requirements): array {
if (Settings::get('testing_package_manager', FALSE) === FALSE) {
$requirements['testing_package_manager'] = [
'title' => 'Package Manager',
'description' => $this->t("Package Manager is available for early testing. To install the module set the value of 'testing_package_manager' to TRUE in your settings.php file."),
'severity' => RequirementSeverity::Error,
];
}
return $requirements;
}
/**
* Check for a failed update.
*
* This is run during requirements to allow restoring from backup.
*
* @param array $requirements
* The requirements array that has been processed so far.
*
* @return array
* Requirements array.
*
* @see hook_runtime_requirements
* @see hook_update_requirements
*/
public function checkFailure(array $requirements): array {
// If we're able to check for the presence of the failure marker at all, do
// it irrespective of the current run phase. If the failure marker is there,
// the site is in an indeterminate state and should be restored from backup
// ASAP.
$service_id = FailureMarker::class;
if (\Drupal::hasService($service_id)) {
try {
\Drupal::service($service_id)->assertNotExists(NULL);
}
catch (FailureMarkerExistsException $exception) {
$requirements['package_manager_failure_marker'] = [
'title' => $this->t('Failed Package Manager update detected'),
'description' => $exception->getMessage(),
'severity' => RequirementSeverity::Error,
];
}
}
return $requirements;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
/**
* Defines a path list that cannot be changed.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ImmutablePathList implements PathListInterface {
public function __construct(private readonly PathListInterface $decorated) {}
/**
* {@inheritdoc}
*/
public function add(string ...$paths): never {
throw new \LogicException('Immutable path lists cannot be changed.');
}
/**
* {@inheritdoc}
*/
public function getAll(): array {
return $this->decorated->getAll();
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Install\Requirements;
use Drupal\Core\Extension\InstallRequirementsInterface;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\Site\Settings;
use Drupal\package_manager\Exception\FailureMarkerExistsException;
use Drupal\package_manager\FailureMarker;
/**
* Install time requirements for the package_manager module.
*/
class PackageManagerRequirements implements InstallRequirementsInterface {
/**
* {@inheritdoc}
*/
public static function getRequirements(): array {
$requirements = [];
if (Settings::get('testing_package_manager', FALSE) === FALSE) {
$requirements['testing_package_manager'] = [
'title' => 'Package Manager',
'description' => t("Package Manager is available for early testing. To install the module set the value of 'testing_package_manager' to TRUE in your settings.php file."),
'severity' => RequirementSeverity::Error,
];
}
// If we're able to check for the presence of the failure marker at all, do
// it irrespective of the current run phase. If the failure marker is there,
// the site is in an indeterminate state and should be restored from backup
// ASAP.
$service_id = FailureMarker::class;
if (\Drupal::hasService($service_id)) {
try {
\Drupal::service($service_id)->assertNotExists(NULL);
}
catch (FailureMarkerExistsException $exception) {
$requirements['package_manager_failure_marker'] = [
'title' => t('Failed Package Manager update detected'),
'description' => $exception->getMessage(),
'severity' => RequirementSeverity::Error,
];
}
}
return $requirements;
}
}

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Component\Serialization\Yaml;
/**
* A value object that represents an installed Composer package.
*/
final class InstalledPackage {
/**
* Constructs an InstalledPackage object.
*
* @param string $name
* The package name.
* @param string $version
* The package version.
* @param string|null $path
* The package path, or NULL if the package type is `metapackage`.
* @param string $type
* The package type.
*/
private function __construct(
public readonly string $name,
public readonly string $version,
public readonly ?string $path,
public readonly string $type,
) {}
/**
* Create an installed package object from an array.
*
* @param array $data
* The package data.
*
* @return static
*/
public static function createFromArray(array $data): static {
$path = isset($data['path']) ? realpath($data['path']) : NULL;
// Fall back to `library`.
// @see https://getcomposer.org/doc/04-schema.md#type
$type = $data['type'] ?? 'library';
assert(($type === 'metapackage') === is_null($path), 'Metapackage install path must be NULL.');
return new static($data['name'], $data['version'], $path, $type);
}
/**
* Returns the Drupal project name for this package.
*
* This assumes that drupal.org adds a `project` key to every `.info.yml` file
* in the package, regardless of where they are in the package's directory
* structure. The package name is irrelevant except for checking that the
* vendor is `drupal`. For example, if the project key in the info file were
* `my_module`, and the package name were `drupal/whatever`, and this method
* would return `my_module`.
*
* @return string|null
* The name of the Drupal project installed by this package, or NULL if:
* - The package type is not one of `drupal-module`, `drupal-theme`, or
* `drupal-profile`.
* - The package's vendor is not `drupal`.
* - The project name could not otherwise be determined.
*
* @throws \UnexpectedValueException
* Thrown if the same project name exists in more than one package.
*/
public function getProjectName(): ?string {
// Only consider packages which are packaged by drupal.org and will be
// known to the core Update Status module.
$drupal_package_types = [
'drupal-module',
'drupal-theme',
'drupal-profile',
];
if ($this->path && str_starts_with($this->name, 'drupal/') && in_array($this->type, $drupal_package_types, TRUE)) {
return $this->scanForProjectName();
}
return NULL;
}
/**
* Scans a given path to determine the Drupal project name.
*
* The path will be scanned recursively for `.info.yml` files containing a
* `project` key.
*
* @return string|null
* The name of the project, as declared in the first found `.info.yml` which
* contains a `project` key, or NULL if none was found.
*/
private function scanForProjectName(): ?string {
$iterator = new \RecursiveDirectoryIterator($this->path);
$iterator = new \RecursiveIteratorIterator($iterator);
$iterator = new \RegexIterator($iterator, '/.+\.info\.yml$/', \RegexIterator::GET_MATCH);
foreach ($iterator as $match) {
$info = file_get_contents($match[0]);
$info = Yaml::decode($info);
if (!empty($info['project'])) {
return $info['project'];
}
}
return NULL;
}
}

View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Composer\Semver\Comparator;
/**
* Defines a class to list installed Composer packages.
*
* This only lists the packages that were installed at the time this object was
* instantiated. If packages are added or removed later on, a new package list
* must be created to reflect those changes.
*
* @see \Drupal\package_manager\ComposerInspector::getInstalledPackagesList()
*/
final class InstalledPackagesList extends \ArrayObject {
/**
* {@inheritdoc}
*/
public function append(mixed $value): never {
throw new \LogicException('Installed package lists cannot be modified.');
}
/**
* {@inheritdoc}
*/
public function offsetSet(mixed $key, mixed $value): never {
throw new \LogicException('Installed package lists cannot be modified.');
}
/**
* {@inheritdoc}
*/
public function offsetUnset(mixed $key): never {
throw new \LogicException('Installed package lists cannot be modified.');
}
/**
* {@inheritdoc}
*/
public function offsetGet(mixed $key): ?InstalledPackage {
// Overridden to provide a clearer return type hint and compatibility with
// the null-safe operator.
if ($this->offsetExists($key)) {
return parent::offsetGet($key);
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function exchangeArray(mixed $array): never {
throw new \LogicException('Installed package lists cannot be modified.');
}
/**
* Returns the packages that are in this list, but not in another.
*
* @param self $other
* Another list of installed packages.
*
* @return static
* A list of packages which are in this list but not the other one, keyed by
* name.
*/
public function getPackagesNotIn(self $other): static {
$packages = array_diff_key($this->getArrayCopy(), $other->getArrayCopy());
return new static($packages);
}
/**
* Returns the packages which have a different version in another list.
*
* This compares this list with another one, and returns a list of packages
* which are present in both, but in different versions.
*
* @param self $other
* Another list of installed packages.
*
* @return static
* A list of packages which are present in both this list and the other one,
* but in different versions, keyed by name.
*/
public function getPackagesWithDifferentVersionsIn(self $other): static {
// Only compare packages that are both here and there.
$packages = array_intersect_key($this->getArrayCopy(), $other->getArrayCopy());
$packages = array_filter($packages, fn (InstalledPackage $p) => Comparator::notEqualTo($p->version, $other[$p->name]->version));
return new static($packages);
}
/**
* Returns the package for a given Drupal project name, if it is installed.
*
* Although it is common for the package name to match the project name (for
* example, a project name of `token` is likely part of the `drupal/token`
* package), it's not guaranteed. Therefore, in order to avoid inadvertently
* reading information about the wrong package, use this method to properly
* determine which package installs a particular Drupal project.
*
* @param string $project_name
* The name of a Drupal project.
*
* @return \Drupal\package_manager\InstalledPackage|null
* The Composer package which installs the project, or NULL if it could not
* be determined.
*/
public function getPackageByDrupalProjectName(string $project_name): ?InstalledPackage {
$matching_package = NULL;
foreach ($this as $package) {
if ($package->getProjectName() === $project_name) {
if ($matching_package) {
throw new \UnexpectedValueException(sprintf("Project '%s' was found in packages '%s' and '%s'.", $project_name, $matching_package->name, $package->name));
}
$matching_package = $package;
}
}
return $matching_package;
}
/**
* Returns the canonical names of the supported core packages.
*
* @return string[]
* The canonical list of supported core package names.
*/
private static function getCorePackageList(): array {
// This method returns the installed packages that are considered part of
// Drupal core. There's no way to tell by package type alone, since these
// packages have varying types, but are all part of Drupal core's
// repository.
return [
'drupal/core',
'drupal/core-composer-scaffold',
'drupal/core-dev',
'drupal/core-dev-pinned',
'drupal/core-project-message',
'drupal/core-recommended',
'drupal/core-vendor-hardening',
];
}
/**
* Returns a list of installed core packages.
*
* Packages returned by ::getCorePackageList() are considered core packages.
*
* @param bool $include_dev
* (optional) Whether to include core packages intended for development.
* Defaults to TRUE.
*
* @return static
* A list of the installed core packages.
*/
public function getCorePackages(bool $include_dev = TRUE): static {
$core_packages = array_intersect_key(
$this->getArrayCopy(),
array_flip(static::getCorePackageList())
);
// If drupal/core-recommended is present, it supersedes drupal/core, since
// drupal/core will always be one of its direct dependencies.
if (array_key_exists('drupal/core-recommended', $core_packages)) {
unset($core_packages['drupal/core']);
}
if (!$include_dev) {
unset($core_packages['drupal/core-dev']);
unset($core_packages['drupal/core-dev-pinned']);
}
return new static($core_packages);
}
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Core\Extension\ExtensionVersion;
/**
* A utility class for dealing with legacy version numbers.
*
* @internal
* This is an internal utility class that could be changed or removed in any
* release and should not be used by external code.
*/
final class LegacyVersionUtility {
/**
* Converts a version number to a semantic version if needed.
*
* @param string $version
* The version number.
*
* @return string
* The version number, converted if needed.
*/
public static function convertToSemanticVersion(string $version): string {
if (self::isLegacyVersion($version)) {
$version = substr($version, 4);
$version_parts = explode('-', $version);
$version = $version_parts[0] . '.0';
if (count($version_parts) === 2) {
$version .= '-' . $version_parts[1];
}
}
return $version;
}
/**
* Converts a version number to a legacy version if needed and possible.
*
* @param string $version_string
* The version number.
*
* @return string|null
* The version number, converted if needed, or NULL if not possible. Only
* semantic version numbers that have patch level of 0 can be converted into
* legacy version numbers.
*/
public static function convertToLegacyVersion($version_string): ?string {
if (self::isLegacyVersion($version_string)) {
return $version_string;
}
$version = ExtensionVersion::createFromVersionString($version_string);
if ($extra = $version->getVersionExtra()) {
$version_string_without_extra = str_replace("-$extra", '', $version_string);
}
else {
$version_string_without_extra = $version_string;
}
[,, $patch] = explode('.', $version_string_without_extra);
// A semantic version can only be converted to legacy if it's patch level is
// '0'.
if ($patch !== '0') {
return NULL;
}
return '8.x-' . $version->getMajorVersion() . '.' . $version->getMinorVersion() . ($extra ? "-$extra" : '');
}
/**
* Determines if a version is legacy.
*
* @param string $version
* The version number.
*
* @return bool
* TRUE if the version is a legacy version number, otherwise FALSE.
*/
private static function isLegacyVersion(string $version): bool {
return stripos($version, '8.x-') === 0;
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use PhpTuf\ComposerStager\API\Core\BeginnerInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface;
use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface;
use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum;
/**
* Logs Composer Stager's Beginner process output to a file.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class LoggingBeginner implements BeginnerInterface {
public function __construct(
private readonly BeginnerInterface $decorated,
private readonly ConfigFactoryInterface $configFactory,
private readonly TimeInterface $time,
) {}
/**
* {@inheritdoc}
*/
public function begin(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void {
$path = $this->configFactory->get('package_manager.settings')->get('log');
if ($path) {
$callback = new FileProcessOutputCallback($path, $callback);
$callback(OutputTypeEnum::OUT, sprintf("### Beginning in %s\n", $stagingDir->absolute()));
}
$start_time = $this->time->getCurrentMicroTime();
$this->decorated->begin($activeDir, $stagingDir, $exclusions, $callback, $timeout);
$end_time = $this->time->getCurrentMicroTime();
if ($callback) {
$callback(OutputTypeEnum::OUT, sprintf("### Finished in %0.3f seconds\n", $end_time - $start_time));
}
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use PhpTuf\ComposerStager\API\Core\CommitterInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface;
use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface;
use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum;
/**
* Logs Composer Stager's Committer process output to a file.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class LoggingCommitter implements CommitterInterface {
public function __construct(
private readonly CommitterInterface $decorated,
private readonly ConfigFactoryInterface $configFactory,
private readonly TimeInterface $time,
) {}
/**
* {@inheritdoc}
*/
public function commit(PathInterface $stagingDir, PathInterface $activeDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void {
$path = $this->configFactory->get('package_manager.settings')->get('log');
if ($path) {
$callback = new FileProcessOutputCallback($path, $callback);
$callback(OutputTypeEnum::OUT, sprintf("### Committing changes from %s to %s\n", $stagingDir->absolute(), $activeDir->absolute()));
}
$start_time = $this->time->getCurrentMicroTime();
$this->decorated->commit($stagingDir, $activeDir, $exclusions, $callback, $timeout);
$end_time = $this->time->getCurrentMicroTime();
if ($callback) {
$callback(OutputTypeEnum::OUT, sprintf("### Finished in %0.3f seconds\n", $end_time - $start_time));
}
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Core\Config\ConfigFactoryInterface;
use PhpTuf\ComposerStager\API\Core\StagerInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathInterface;
use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface;
use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface;
use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum;
/**
* Logs Composer Stager's Stager process output to a file.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class LoggingStager implements StagerInterface {
public function __construct(
private readonly StagerInterface $decorated,
private readonly ConfigFactoryInterface $configFactory,
) {}
/**
* {@inheritdoc}
*/
public function stage(array $composerCommand, PathInterface $activeDir, PathInterface $stagingDir, ?OutputCallbackInterface $callback = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void {
$path = $this->configFactory->get('package_manager.settings')->get('log');
if ($path) {
$callback = new FileProcessOutputCallback($path, $callback);
$callback(OutputTypeEnum::OUT, sprintf("### Staging '%s' in %s\n", implode(' ', $composerCommand), $stagingDir->absolute()));
}
$this->decorated->stage($composerCommand, $activeDir, $stagingDir, $callback, $timeout);
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
use Drupal\Core\Queue\QueueFactory;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\TempStore\SharedTempStoreFactory;
use PhpTuf\ComposerStager\API\Core\BeginnerInterface;
use PhpTuf\ComposerStager\API\Core\CommitterInterface;
use PhpTuf\ComposerStager\API\Core\StagerInterface;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* Prevents any module from being uninstalled if update is in process.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class PackageManagerUninstallValidator implements ModuleUninstallValidatorInterface {
use StringTranslationTrait;
public function __construct(
private readonly PathLocator $pathLocator,
private readonly BeginnerInterface $beginner,
private readonly StagerInterface $stager,
private readonly CommitterInterface $committer,
private readonly QueueFactory $queueFactory,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly SharedTempStoreFactory $sharedTempStoreFactory,
private readonly TimeInterface $time,
private readonly PathFactoryInterface $pathFactory,
private readonly FailureMarker $failureMarker,
) {}
/**
* {@inheritdoc}
*/
public function validate($module) {
$sandbox_manager = new class(
$this->pathLocator,
$this->beginner,
$this->stager,
$this->committer,
$this->queueFactory,
$this->eventDispatcher,
$this->sharedTempStoreFactory,
$this->time,
$this->pathFactory,
$this->failureMarker) extends SandboxManagerBase {};
$reasons = [];
if (!$sandbox_manager->isAvailable() && $sandbox_manager->isApplying()) {
$reasons[] = $this->t('Modules cannot be uninstalled while Package Manager is applying staged changes to the active code base.');
}
return $reasons;
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\PrivateKey;
use Drupal\Core\Queue\QueueFactory;
use Drupal\Core\State\StateInterface;
use Drupal\update\UpdateFetcherInterface;
use Drupal\update\UpdateProcessor;
/**
* Extends the Update Status update processor allow fetching any project.
*
* The Update Status module's update processor service is intended to only fetch
* information for projects in the active codebase. Although it would be
* possible to use the Update Status module's update processor service to fetch
* information for projects not in the active code base this would add the
* project information to Update Status module's cache which would result in
* these projects being returned from the Update Status module's global
* functions such as update_get_available().
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class PackageManagerUpdateProcessor extends UpdateProcessor {
public function __construct(
ConfigFactoryInterface $config_factory,
QueueFactory $queue_factory,
UpdateFetcherInterface $update_fetcher,
StateInterface $state_store,
PrivateKey $private_key,
KeyValueFactoryInterface $key_value_factory,
KeyValueExpirableFactoryInterface $key_value_expirable_factory,
TimeInterface $time,
) {
parent::__construct(...func_get_args());
$this->fetchQueue = $queue_factory->get('package_manager.update_fetch_tasks');
$this->tempStore = $key_value_expirable_factory->get('package_manager.update');
$this->fetchTaskStore = $key_value_factory->get('package_manager.update_fetch_task');
$this->availableReleasesTempStore = $key_value_expirable_factory->get('package_manager.update_available_releases');
}
/**
* Gets the project data by name.
*
* @param string $name
* The project name.
*
* @return mixed[]
* The project data if any is available, otherwise NULL.
*/
public function getProjectData(string $name): ?array {
if ($this->availableReleasesTempStore->has($name)) {
return $this->availableReleasesTempStore->get($name);
}
$project_fetch_data = [
'name' => $name,
'project_type' => 'unknown',
'includes' => [],
];
$this->createFetchTask($project_fetch_data);
if ($this->processFetchTask($project_fetch_data)) {
// If the fetch task was successful return the project information.
return $this->availableReleasesTempStore->get($name);
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function processFetchTask($project) {
// The parent method will set 'update.last_check' which will be used to
// inform the user when the last time update information was checked. In
// order to leave this value unaffected we will reset this to its previous
// value.
$last_check = $this->stateStore->get('update.last_check');
$success = parent::processFetchTask($project);
$this->stateStore->set('update.last_check', $last_check);
return $success;
}
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\PathExcluder;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Excludes .git directories from stage operations.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class GitExcluder implements EventSubscriberInterface {
public function __construct(
private readonly PathLocator $pathLocator,
private readonly ComposerInspector $composerInspector,
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
CollectPathsToExcludeEvent::class => 'excludeGitDirectories',
];
}
/**
* Excludes .git directories from stage operations.
*
* Any .git directories that are a part of an installed package -- for
* example, a module that Composer installed from source -- are included.
*
* @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event
* The event object.
*
* @throws \Exception
* See \Drupal\package_manager\ComposerInspector::validate().
*/
public function excludeGitDirectories(CollectPathsToExcludeEvent $event): void {
$project_root = $this->pathLocator->getProjectRoot();
// To determine which .git directories to exclude, the installed packages
// must be known, and that requires Composer commands to be able to run.
// This intentionally does not catch exceptions: failed Composer validation
// in the project root implies that this excluder cannot function correctly.
// Note: the call to ComposerInspector::getInstalledPackagesList() would
// also have triggered this, but explicitness is preferred here.
// @see \Drupal\package_manager\StatusCheckTrait::runStatusCheck()
$this->composerInspector->validate($project_root);
$paths_to_exclude = [];
$installed_paths = [];
// Collect the paths of every installed package.
$installed_packages = $this->composerInspector->getInstalledPackagesList($project_root);
foreach ($installed_packages as $package) {
if (!empty($package->path)) {
$installed_paths[] = $package->path;
}
}
$paths = $event->scanForDirectoriesByName('.git');
foreach ($paths as $git_directory) {
// Don't exclude any `.git` directory that is directly under an installed
// package's path, since it means Composer probably installed that package
// from source and therefore needs the `.git` directory in order to update
// the package.
if (!in_array(dirname($git_directory), $installed_paths, TRUE)) {
$paths_to_exclude[] = $git_directory;
}
}
$event->addPathsRelativeToProjectRoot($paths_to_exclude);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\PathExcluder;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Excludes node_modules files from stage directories.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class NodeModulesExcluder implements EventSubscriberInterface {
/**
* Excludes node_modules directories from stage operations.
*
* @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event
* The event object.
*/
public function excludeNodeModulesFiles(CollectPathsToExcludeEvent $event): void {
$event->addPathsRelativeToProjectRoot($event->scanForDirectoriesByName('node_modules'));
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
CollectPathsToExcludeEvent::class => 'excludeNodeModulesFiles',
];
}
}

View File

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\PathExcluder;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\Event\PostCreateEvent;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Excludes site configuration files from stage directories.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class SiteConfigurationExcluder implements EventSubscriberInterface {
public function __construct(
protected string $sitePath,
private readonly PathLocator $pathLocator,
private readonly FileSystemInterface $fileSystem,
) {}
/**
* Excludes site configuration files from stage operations.
*
* @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event
* The event object.
*/
public function excludeSiteConfiguration(CollectPathsToExcludeEvent $event): void {
// These two files are never relevant to existing sites.
$paths = [
'sites/default/default.settings.php',
'sites/default/default.services.yml',
];
// Exclude site-specific settings files, which are always in the web root.
// By default, Drupal core will always try to write-protect these files.
// @see system_requirements()
$settings_files = [
'settings.php',
'settings.local.php',
'services.yml',
];
foreach ($settings_files as $settings_file) {
$paths[] = $this->sitePath . '/' . $settings_file;
$paths[] = 'sites/default/' . $settings_file;
}
// Site configuration files are always excluded relative to the web root.
$event->addPathsRelativeToWebRoot($paths);
}
/**
* Makes the staged `sites/default` directory owner-writable.
*
* This allows the core scaffold plugin to make changes in `sites/default`,
* if needed. Otherwise, it would break if `sites/default` is not writable.
* This can happen because rsync preserves directory permissions (and Drupal
* tries to write-protect the site directory).
*
* We specifically exclude `default.settings.php` and `default.services.yml`
* from Package Manager operations. This allows the scaffold plugin to change
* those files in the stage directory.
*
* @param \Drupal\package_manager\Event\PostCreateEvent $event
* The event being handled.
*
* @see ::excludeSiteConfiguration()
*/
public function makeDefaultSiteDirectoryWritable(PostCreateEvent $event): void {
$dir = $this->getDefaultSiteDirectoryPath($event->sandboxManager->getSandboxDirectory());
// If the directory doesn't even exist, there's nothing to do here.
if (!is_dir($dir)) {
return;
}
if (!$this->fileSystem->chmod($dir, 0700)) {
throw new FileException("Could not change permissions on '$dir'.");
}
}
/**
* Makes `sites/default` permissions the same in live and stage directories.
*
* @param \Drupal\package_manager\Event\PreApplyEvent $event
* The event being handled.
*
* @throws \Drupal\Core\File\Exception\FileException
* If the permissions of the live `sites/default` cannot be determined, or
* cannot be changed on the staged `sites/default`.
*/
public function syncDefaultSiteDirectoryPermissions(PreApplyEvent $event): void {
$staged_dir = $this->getDefaultSiteDirectoryPath($event->sandboxManager->getSandboxDirectory());
// If the directory doesn't even exist, there's nothing to do here.
if (!is_dir($staged_dir)) {
return;
}
$live_dir = $this->getDefaultSiteDirectoryPath($this->pathLocator->getProjectRoot());
$permissions = fileperms($live_dir);
if ($permissions === FALSE) {
throw new FileException("Could not determine permissions for '$live_dir'.");
}
if (!$this->fileSystem->chmod($staged_dir, $permissions)) {
throw new FileException("Could not change permissions on '$staged_dir'.");
}
}
/**
* Returns the full path to `sites/default`, relative to a root directory.
*
* @param string $root_dir
* The root directory.
*
* @return string
* The full path to `sites/default` within the given root directory.
*/
private function getDefaultSiteDirectoryPath(string $root_dir): string {
$dir = [$root_dir];
$web_root = $this->pathLocator->getWebRoot();
if ($web_root) {
$dir[] = $web_root;
}
return implode(DIRECTORY_SEPARATOR, [...$dir, 'sites', 'default']);
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
CollectPathsToExcludeEvent::class => 'excludeSiteConfiguration',
PostCreateEvent::class => 'makeDefaultSiteDirectoryWritable',
PreApplyEvent::class => 'syncDefaultSiteDirectoryPermissions',
];
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\PathExcluder;
use Drupal\Core\StreamWrapper\LocalStream;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Filesystem\Filesystem;
/**
* Excludes site files from stage operations.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class SiteFilesExcluder implements EventSubscriberInterface {
public function __construct(
private readonly StreamWrapperManagerInterface $streamWrapperManager,
private readonly Filesystem $fileSystem,
private readonly array $wrappers,
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
CollectPathsToExcludeEvent::class => 'excludeSiteFiles',
];
}
/**
* Excludes public and private files from stage operations.
*
* @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event
* The event object.
*/
public function excludeSiteFiles(CollectPathsToExcludeEvent $event): void {
// Exclude files handled by the stream wrappers listed in $this->wrappers.
// 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 ($this->wrappers as $scheme) {
$wrapper = $this->streamWrapperManager->getViaScheme($scheme);
if ($wrapper instanceof LocalStream) {
$path = $wrapper->getDirectoryPath();
if ($this->fileSystem->isAbsolutePath($path)) {
if ($path = realpath($path)) {
$event->addPathsRelativeToProjectRoot([$path]);
}
}
else {
$event->addPathsRelativeToWebRoot([$path]);
}
}
}
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\PathExcluder;
use Drupal\Core\Database\Connection;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Excludes SQLite database files from stage operations.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class SqliteDatabaseExcluder implements EventSubscriberInterface {
public function __construct(
private readonly PathFactoryInterface $pathFactory,
private readonly Connection $database,
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
CollectPathsToExcludeEvent::class => 'excludeDatabaseFiles',
];
}
/**
* Excludes SQLite database files from stage operations.
*
* @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event
* The event object.
*/
public function excludeDatabaseFiles(CollectPathsToExcludeEvent $event): void {
// If the database is SQLite, it might be located in the project directory,
// and should be excluded.
if ($this->database->driver() === 'sqlite') {
// @todo Support database connections other than the default in
// https://www.drupal.org/i/3441919.
$db_path = $this->database->getConnectionOptions()['database'];
// Exclude the database file and auxiliary files created by SQLite.
$paths = [$db_path, "$db_path-shm", "$db_path-wal"];
// If the database path is absolute, it might be outside the project root,
// in which case we don't need to do anything.
if ($this->pathFactory->create($db_path)->isAbsolute()) {
try {
$event->addPathsRelativeToProjectRoot($paths);
}
catch (\LogicException) {
// The database is outside the project root, so we're done.
}
}
else {
// The database is in the web root, and must be excluded relative to it.
$event->addPathsRelativeToWebRoot($paths);
}
}
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\PathExcluder;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Excludes 'sites/simpletest' from stage operations.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class TestSiteExcluder implements EventSubscriberInterface {
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
CollectPathsToExcludeEvent::class => 'excludeTestSites',
];
}
/**
* Excludes sites/simpletest from stage operations.
*
* @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event
* The event object.
*/
public function excludeTestSites(CollectPathsToExcludeEvent $event): void {
// Always exclude automated test directories. If they exist, they will be in
// the web root.
$event->addPathsRelativeToWebRoot(['sites/simpletest']);
}
}

View File

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\PathExcluder;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Filesystem\Exception\InvalidArgumentException;
use Symfony\Component\Filesystem\Path;
/**
* Excludes unknown paths from stage operations.
*
* Any path in the root directory of the project that is NOT one of the
* following are considered unknown paths:
* 1. The vendor directory
* 2. The web root
* 3. composer.json
* 4. composer.lock
* 5. Scaffold files as determined by the drupal/core-composer-scaffold plugin
*
* If the web root and the project root are the same, nothing is excluded.
*
* This excluder can be disabled by changing the config setting
* `package_manager.settings:include_unknown_files_in_project_root` to TRUE.
* This may be needed for sites that have files outside the web root (besides
* the vendor directory) which are nonetheless needed in order for Composer to
* assemble the code base correctly; a classic example would be a directory of
* patch files used by `cweagans/composer-patches`.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class UnknownPathExcluder implements EventSubscriberInterface, LoggerAwareInterface {
use LoggerAwareTrait;
use StringTranslationTrait;
public function __construct(
private readonly ComposerInspector $composerInspector,
private readonly PathLocator $pathLocator,
private readonly ConfigFactoryInterface $configFactory,
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
CollectPathsToExcludeEvent::class => 'excludeUnknownPaths',
StatusCheckEvent::class => 'logExcludedPaths',
];
}
/**
* Returns the paths to exclude from stage operations.
*
* @return string[]
* The paths that should be excluded from stage operations, relative to the
* project root.
*
* @throws \Exception
* See \Drupal\package_manager\ComposerInspector::validate().
*/
private function getExcludedPaths(): array {
// If this excluder is disabled, or the project root and web root are the
// same, we are not excluding any paths.
$is_disabled = $this->configFactory->get('package_manager.settings')
->get('include_unknown_files_in_project_root');
$web_root = $this->pathLocator->getWebRoot();
if ($is_disabled || empty($web_root)) {
return [];
}
// To determine the files to include, the installed packages must be known,
// and that requires Composer commands to be able to run. This intentionally
// does not catch exceptions: failed Composer validation in the project root
// implies that this excluder cannot function correctly. In such a case, the
// call to ComposerInspector::getConfig() would also have triggered an
// exception, but explicitness is preferred here.
// @see \Drupal\package_manager\StatusCheckTrait::runStatusCheck()
$project_root = $this->pathLocator->getProjectRoot();
$this->composerInspector->validate($project_root);
// The vendor directory and web root are always included in staging
// operations, along with `composer.json`, `composer.lock`, and any scaffold
// files provided by Drupal core.
$always_include = [
$this->composerInspector->getConfig('vendor-dir', $project_root),
$web_root,
'composer.json',
'composer.lock',
];
foreach ($this->getScaffoldFiles() as $scaffold_file_path) {
// The web root is always included in staging operations, so we don't need
// to do anything special for scaffold files that live in it.
if (str_starts_with($scaffold_file_path, '[web-root]')) {
continue;
}
$always_include[] = ltrim($scaffold_file_path, '/');
}
// Find any path repositories located inside the project root. These need
// to be included or Composer will break in the staging area.
$repositories = $this->composerInspector->getConfig('repositories', $project_root);
$repositories = Json::decode($repositories);
foreach ($repositories as $repository) {
if ($repository['type'] !== 'path') {
continue;
}
try {
// Ensure $path is relative to the project root, even if it's written as
// an absolute path in `composer.json`.
$path = Path::makeRelative($repository['url'], $project_root);
// Strip off everything except the top-level directory name. For
// example, if the repository path is `custom/module/foo`, always
// include `custom`.
$always_include[] = dirname($path, substr_count($path, '/') ?: 1);
}
catch (InvalidArgumentException) {
// The repository path is not relative to the project root, so we don't
// need to worry about it.
}
}
// Search for all files (including hidden ones) in the project root. We need
// to use readdir() and friends here, rather than glob(), since certain
// glob() flags aren't supported on all systems. We also can't use
// \Drupal\Core\File\FileSystemInterface::scanDirectory(), because it
// unconditionally ignores hidden files and directories.
$files_in_project_root = [];
$handle = opendir($project_root);
if (empty($handle)) {
throw new \RuntimeException("Could not scan for files in the project root.");
}
while ($entry = readdir($handle)) {
$files_in_project_root[] = $entry;
}
closedir($handle);
return array_diff($files_in_project_root, $always_include, ['.', '..']);
}
/**
* Excludes unknown paths from stage operations.
*
* @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event
* The event object.
*/
public function excludeUnknownPaths(CollectPathsToExcludeEvent $event): void {
// We can exclude the paths as-is; they are already relative to the project
// root.
$event->add(...$this->getExcludedPaths());
}
/**
* Logs the paths that will be excluded from stage operations.
*/
public function logExcludedPaths(): void {
$excluded_paths = $this->getExcludedPaths();
if ($excluded_paths) {
sort($excluded_paths);
$message = $this->t("The following paths in @project_root aren't recognized as part of your Drupal site, so to be safe, Package Manager is excluding them from all stage operations. If these files are not needed for Composer to work properly in your site, no action is needed. Otherwise, you can disable this behavior by setting the <code>package_manager.settings:include_unknown_files_in_project_root</code> config setting to <code>TRUE</code>.\n\n@list", [
'@project_root' => $this->pathLocator->getProjectRoot(),
'@list' => implode("\n", $excluded_paths),
]);
$this->logger?->info($message);
}
}
/**
* Gets the path of scaffold files, for example 'index.php' and 'robots.txt'.
*
* @return string[]
* The paths of scaffold files provided by `drupal/core`, relative to the
* project root.
*
* @todo Intelligently load scaffold files in https://drupal.org/i/3343802.
*/
private function getScaffoldFiles(): array {
$project_root = $this->pathLocator->getProjectRoot();
$packages = $this->composerInspector->getInstalledPackagesList($project_root);
$extra = Json::decode($this->composerInspector->getConfig('extra', $packages['drupal/core']->path . '/composer.json'));
$scaffold_files = $extra['drupal-scaffold']['file-mapping'] ?? [];
return str_replace('[project-root]', '', array_keys($scaffold_files));
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\PathExcluder;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Excludes vendor hardening files from stage operations.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class VendorHardeningExcluder implements EventSubscriberInterface {
public function __construct(private readonly PathLocator $pathLocator) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
CollectPathsToExcludeEvent::class => 'excludeVendorHardeningFiles',
];
}
/**
* Excludes vendor hardening files from stage operations.
*
* @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event
* The event object.
*/
public function excludeVendorHardeningFiles(CollectPathsToExcludeEvent $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 excluded.
$vendor_dir = $this->pathLocator->getVendorDirectory();
$event->addPathsRelativeToProjectRoot([$vendor_dir . '/.htaccess']);
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Composer\Autoload\ClassLoader;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
/**
* Computes file system paths that are needed to stage code changes.
*/
class PathLocator {
public function __construct(
protected string $appRoot,
protected ConfigFactoryInterface $configFactory,
protected FileSystemInterface $fileSystem,
) {}
/**
* Returns the absolute path of the project root.
*
* This is where the project-level composer.json should normally be found, and
* may or may not be the same path as the Drupal code base.
*
* @return string
* The absolute path of the project root.
*/
public function getProjectRoot(): string {
// Assume that the vendor directory is immediately below the project root.
return realpath($this->getVendorDirectory() . DIRECTORY_SEPARATOR . '..');
}
/**
* Returns the absolute path of the vendor directory.
*
* @return string
* The absolute path of the vendor directory.
*/
public function getVendorDirectory(): string {
// There may be multiple class loaders at work.
// ClassLoader::getRegisteredLoaders() keeps track of them all, indexed by
// the path of the vendor directory they load classes from.
$loaders = ClassLoader::getRegisteredLoaders();
// If there's only one class loader, we don't need to search for the right
// one.
if (count($loaders) === 1) {
return key($loaders);
}
// To determine which class loader is the one for Drupal's vendor directory,
// look for the loader whose vendor path starts the same way as the path to
// this file.
foreach (array_keys($loaders) as $path) {
if (str_starts_with(__FILE__, dirname($path))) {
return $path;
}
}
// If we couldn't find a match, assume that the first registered class
// loader is the one we want.
return key($loaders);
}
/**
* Returns the path of the Drupal installation, relative to the project root.
*
* @return string
* The path of the Drupal installation, relative to the project root and
* without leading or trailing slashes. Will return an empty string if the
* project root and Drupal root are the same.
*/
public function getWebRoot(): string {
$web_root = str_replace(trim($this->getProjectRoot(), DIRECTORY_SEPARATOR), '', trim($this->appRoot, DIRECTORY_SEPARATOR));
return trim($web_root, DIRECTORY_SEPARATOR);
}
/**
* Returns the directory where stage directories will be created.
*
* The stage root may be affected by site settings, so stages may wish to
* cache the value returned by this method, to ensure that they use the same
* stage root directory throughout their life cycle.
*
* @return string
* The absolute path of the directory where stage directories should be
* created.
*/
public function getStagingRoot(): string {
$site_id = $this->configFactory->get('system.site')->get('uuid');
return $this->fileSystem->getTempDirectory() . DIRECTORY_SEPARATOR . '.package_manager' . $site_id;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Drupal\package_manager\Plugin\QueueWorker;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Queue\Attribute\QueueWorker;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Processes a queue of defunct stage directories, deleting them.
*/
#[QueueWorker(
id: 'package_manager_cleanup',
title: new TranslatableMarkup('Stage directory cleaner'),
cron: ['time' => 30],
)]
final class Cleaner extends QueueWorkerBase implements ContainerFactoryPluginInterface {
public function __construct(array $configuration, string $plugin_id, mixed $plugin_definition, private readonly FileSystemInterface $fileSystem) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get(FileSystemInterface::class),
);
}
/**
* {@inheritdoc}
*/
public function processItem($dir): void {
assert(is_string($dir));
if (file_exists($dir)) {
$this->fileSystem->deleteRecursive($dir, function (string $path): void {
$this->fileSystem->chmod($path, is_dir($path) ? 0700 : 0600);
});
}
}
}

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface;
use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
/**
* A process callback for capturing output.
*
* @see \Symfony\Component\Process\Process
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ProcessOutputCallback implements OutputCallbackInterface, LoggerAwareInterface {
use LoggerAwareTrait;
/**
* The output buffer.
*
* @var array
*/
private array $outBuffer = [];
/**
* The error buffer.
*
* @var array
*/
private array $errorBuffer = [];
/**
* Constructs a ProcessOutputCallback object.
*/
public function __construct() {
$this->setLogger(new NullLogger());
}
/**
* {@inheritdoc}
*/
public function __invoke(OutputTypeEnum $type, string $buffer): void {
if ($type === OutputTypeEnum::OUT) {
$this->outBuffer[] = $buffer;
}
elseif ($type === OutputTypeEnum::ERR) {
$this->errorBuffer[] = $buffer;
}
}
/**
* Gets the output.
*
* If there is anything in the error buffer, it will be logged as a warning.
*
* @return array
* The output buffer.
*/
public function getOutput(): array {
$error_output = $this->getErrorOutput();
if ($error_output) {
$this->logger->warning(implode('', $error_output));
}
return $this->outBuffer;
}
/**
* Gets the parsed JSON output.
*
* @return mixed
* The decoded JSON output or NULL if there isn't any.
*/
public function parseJsonOutput(): mixed {
$output = $this->getOutput();
if ($output) {
return json_decode(trim(implode('', $output)), TRUE, flags: JSON_THROW_ON_ERROR);
}
return NULL;
}
/**
* Gets the error output.
*
* @return array
* The error output buffer.
*/
public function getErrorOutput(): array {
return $this->errorBuffer;
}
/**
* {@inheritdoc}
*/
public function clearErrorOutput(): void {
$this->errorBuffer = [];
}
/**
* {@inheritdoc}
*/
public function clearOutput(): void {
$this->outBuffer = [];
}
/**
* Resets buffers.
*
* @return self
* The current instance for method chaining.
*/
public function reset(): self {
$this->clearErrorOutput();
$this->clearOutput();
return $this;
}
}

View File

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Composer\Semver\Comparator;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\update\ProjectRelease;
use Drupal\Core\Extension\ExtensionVersion;
use Drupal\Core\Utility\Error;
use Drupal\update\UpdateManagerInterface;
/**
* Retrieves project information from the Update Status module.
*
* @internal
* This is an internal part of Automatic Updates and may be changed or removed
* at any time without warning. External code should use the Update Status API
* directly.
*/
final class ProjectInfo {
public function __construct(private readonly string $name) {
}
/**
* Determines if a release can be installed.
*
* @param \Drupal\update\ProjectRelease $release
* The project release.
* @param string[] $support_branches
* The supported branches.
*
* @return bool
* TRUE if the release is installable, otherwise FALSE. A release will be
* considered installable if it is secure, published, supported, and in
* a supported branch.
*/
private function isInstallable(ProjectRelease $release, array $support_branches): bool {
if ($release->isInsecure() || !$release->isPublished() || $release->isUnsupported()) {
return FALSE;
}
$version = ExtensionVersion::createFromVersionString($release->getVersion());
if ($version->getVersionExtra() === 'dev') {
return FALSE;
}
foreach ($support_branches as $support_branch) {
$support_branch_version = ExtensionVersion::createFromSupportBranch($support_branch);
if ($support_branch_version->getMajorVersion() === $version->getMajorVersion() && $support_branch_version->getMinorVersion() === $version->getMinorVersion()) {
return TRUE;
}
}
return FALSE;
}
/**
* Returns up-to-date project information.
*
* @return mixed[]|null
* The retrieved project information.
*
* @throws \RuntimeException
* If data about available updates cannot be retrieved.
*/
public function getProjectInfo(): ?array {
$available_updates = $this->getAvailableProjects();
$project_data = update_calculate_project_data($available_updates);
if (!isset($project_data[$this->name])) {
return $available_updates[$this->name] ?? NULL;
}
return $project_data[$this->name];
}
/**
* Gets all project releases to which the site can update.
*
* @return \Drupal\update\ProjectRelease[]|null
* If the project information is available, an array of releases that can be
* installed, keyed by version number; otherwise NULL. The releases are in
* descending order by version number (i.e., higher versions are listed
* first). The currently installed version of the project, and any older
* versions, are not considered installable releases.
*
* @throws \RuntimeException
* Thrown if there are no available releases.
*
* @todo Remove or simplify this function in https://www.drupal.org/i/3252190.
*/
public function getInstallableReleases(): ?array {
$project = $this->getProjectInfo();
if (!$project) {
return NULL;
}
$available_updates = $this->getAvailableProjects()[$this->name];
if ($available_updates['project_status'] !== 'published') {
throw new \RuntimeException("The project '{$this->name}' can not be updated because its status is " . $available_updates['project_status']);
}
$installed_version = $this->getInstalledVersion();
if ($installed_version && empty($available_updates['releases'])) {
// If project is installed but not current we should always have at
// least one release.
throw new \RuntimeException('There was a problem getting update information. Try again later.');
}
$support_branches = explode(',', $available_updates['supported_branches']);
$installable_releases = [];
foreach ($available_updates['releases'] as $release_info) {
try {
$release = ProjectRelease::createFromArray($release_info);
}
catch (\UnexpectedValueException $exception) {
// Ignore releases that are in an invalid format. Although this is
// unlikely we should still only process releases in the correct format.
\Drupal::logger('package_manager')
->error(sprintf('Invalid project format: %s', print_r($release_info, TRUE)), Error::decodeException($exception));
continue;
}
$version = $release->getVersion();
if ($installed_version) {
$semantic_version = LegacyVersionUtility::convertToSemanticVersion($version);
$semantic_installed_version = LegacyVersionUtility::convertToSemanticVersion($installed_version);
if (Comparator::lessThanOrEqualTo($semantic_version, $semantic_installed_version)) {
// If the project is installed stop searching for releases as soon as
// we find the installed version.
break;
}
}
if ($this->isInstallable($release, $support_branches)) {
$installable_releases[$version] = $release;
}
}
return $installable_releases;
}
/**
* Returns the installed project version via the Update Status module.
*
* @return string|null
* The installed project version as known to the Update Status module, or
* NULL if the project information is not available.
*/
public function getInstalledVersion(): ?string {
$project_data = $this->getProjectInfo();
if ($project_data && array_key_exists('existing_version', $project_data)) {
$existing_version = $project_data['existing_version'];
// Treat an unknown version the same as a project whose project
// information is not available, so return NULL.
// @see \update_process_project_info()
if ($existing_version instanceof TranslatableMarkup && $existing_version->getUntranslatedString() === 'Unknown') {
return NULL;
}
// TRICKY: Since this is relying on data coming from
// \Drupal\update\UpdateManager::getProjects(), we cannot be certain that
// we are actually receiving strings.
// @see \Drupal\update\UpdateManager::getProjects()
if (!is_string($existing_version)) {
return NULL;
}
return $existing_version;
}
return NULL;
}
/**
* Gets the available projects.
*
* @return array
* The available projects keyed by project machine name in the format
* returned by update_get_available(). If the project specified in ::name is
* not returned from update_get_available() this project will be explicitly
* fetched and added the return value of this function.
*
* @see \update_get_available()
*/
private function getAvailableProjects(): array {
$available_projects = update_get_available(TRUE);
// update_get_available() will only returns projects that are in the active
// codebase. If the project specified by ::name is not returned in
// $available_projects, it means it is not in the active codebase, therefore
// we will retrieve the project information from Package Manager's own
// update processor service.
if (!isset($available_projects[$this->name])) {
/** @var \Drupal\package_manager\PackageManagerUpdateProcessor $update_processor */
$update_processor = \Drupal::service(PackageManagerUpdateProcessor::class);
if ($project_data = $update_processor->getProjectData($this->name)) {
$available_projects[$this->name] = $project_data;
}
}
return $available_projects;
}
/**
* Checks if the installed version of this project is safe to use.
*
* @return bool
* TRUE if the installed version of this project is secure, supported, and
* published. Otherwise, or if the project information could not be
* retrieved, returns FALSE.
*/
public function isInstalledVersionSafe(): bool {
$project_data = $this->getProjectInfo();
if ($project_data) {
$unsafe = [
UpdateManagerInterface::NOT_SECURE,
UpdateManagerInterface::NOT_SUPPORTED,
UpdateManagerInterface::REVOKED,
];
return !in_array($project_data['status'], $unsafe, TRUE);
}
// If we couldn't get project data, assume the installed version is unsafe.
return FALSE;
}
/**
* Gets the supported branches of the project.
*
* @return string[]
* The supported branches.
*/
public function getSupportedBranches(): array {
$available_updates = $this->getAvailableProjects()[$this->name];
return isset($available_updates['supported_branches']) ? explode(',', $available_updates['supported_branches']) : [];
}
}

View File

@ -0,0 +1,913 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Composer\Semver\VersionParser;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\Random;
use Drupal\Core\Queue\QueueFactory;
use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TempStore\SharedTempStore;
use Drupal\Core\TempStore\SharedTempStoreFactory;
use Drupal\Core\Utility\Error;
use Drupal\package_manager\Attribute\AllowDirectWrite;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\Event\PostApplyEvent;
use Drupal\package_manager\Event\PostCreateEvent;
use Drupal\package_manager\Event\PostRequireEvent;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\SandboxEvent;
use Drupal\package_manager\Exception\ApplyFailedException;
use Drupal\package_manager\Exception\SandboxEventException;
use Drupal\package_manager\Exception\SandboxException;
use Drupal\package_manager\Exception\SandboxOwnershipException;
use PhpTuf\ComposerStager\API\Core\BeginnerInterface;
use PhpTuf\ComposerStager\API\Core\CommitterInterface;
use PhpTuf\ComposerStager\API\Core\StagerInterface;
use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException;
use PhpTuf\ComposerStager\API\Exception\PreconditionException;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Creates and manages a stage directory in which to install or update code.
*
* Allows calling code to copy the current Drupal site into a temporary stage
* directory, use Composer to require packages into it, sync changes from the
* stage directory back into the active code base, and then delete the
* stage directory.
*
* Only one stage directory can exist at any given time, and the stage is
* owned by the user or session that originally created it. Only the owner can
* perform operations on the stage directory, and the stage must be "claimed"
* by its owner before any such operations are done. A stage is claimed by
* presenting a unique token that is generated when the stage is created.
*
* Although a site can only have one stage directory, it is possible for
* privileged users to destroy a stage created by another user. To prevent such
* actions from putting the file system into an uncertain state (for example, if
* a stage is destroyed by another user while it is still being created), the
* stage directory has a randomly generated name. For additional cleanliness,
* all stage directories created by a specific site live in a single directory
* ,called the "stage root directory" and identified by the UUID of the current
* site (e.g. `/tmp/.package_managerSITE_UUID`), which is deleted when any stage
* created by that site is destroyed.
*/
abstract class SandboxManagerBase implements LoggerAwareInterface {
use LoggerAwareTrait;
use StringTranslationTrait;
/**
* The tempstore key under which to store the locking info for this stage.
*
* @var string
*/
final protected const TEMPSTORE_LOCK_KEY = 'lock';
/**
* The tempstore key under which to store arbitrary metadata for this stage.
*
* @var string
*/
final protected const TEMPSTORE_METADATA_KEY = 'metadata';
/**
* The tempstore key under which to store the path of stage root directory.
*
* @var string
*
* @see ::getStagingRoot()
*/
private const TEMPSTORE_STAGING_ROOT_KEY = 'staging_root';
/**
* The tempstore key under which to store the time that ::apply() was called.
*
* @var string
*
* @see ::apply()
* @see ::destroy()
*/
private const TEMPSTORE_APPLY_TIME_KEY = 'apply_time';
/**
* The tempstore key for whether staged operations have been applied.
*
* @var string
*
* @see ::apply()
* @see ::destroy()
*/
private const TEMPSTORE_CHANGES_APPLIED = 'changes_applied';
/**
* The tempstore key for information about previously destroyed stages.
*
* @var string
*
* @see ::apply()
* @see ::destroy()
*/
private const TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX = 'TEMPSTORE_DESTROYED_STAGES_INFO';
/**
* The regular expression to check if a package name is a platform package.
*
* @var string
*
* @see \Composer\Repository\PlatformRepository::PLATFORM_PACKAGE_REGEX
* @see ::validateRequirements()
*/
private const COMPOSER_PLATFORM_PACKAGE_REGEX = '{^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$}iD';
/**
* The regular expression to check if a package name is a regular package.
*
* If you try to require an invalid package name, this is the regular
* expression that Composer will, at the command line, tell you to match.
*
* @var string
*
* @see \Composer\Package\Loader\ValidatingArrayLoader::hasPackageNamingError()
* @see ::validateRequirements()
*/
private const COMPOSER_PACKAGE_REGEX = '/^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9](([_.]?|-{0,2})[a-z0-9]+)*$/';
/**
* The lock info for the stage.
*
* Consists of a unique random string and the current class name.
*
* @var string[]|null
*/
private ?array $lock = NULL;
/**
* The shared temp store.
*
* @var \Drupal\Core\TempStore\SharedTempStore
*/
protected SharedTempStore $tempStore;
/**
* The stage type.
*
* To ensure that stage classes do not unintentionally use another stage's
* type, all concrete subclasses MUST explicitly define this property.
* The recommended pattern is `MODULE:TYPE`.
*
* @var string
*/
protected string $type;
public function __construct(
protected readonly PathLocator $pathLocator,
protected readonly BeginnerInterface $beginner,
protected readonly StagerInterface $stager,
protected readonly CommitterInterface $committer,
protected readonly QueueFactory $queueFactory,
protected EventDispatcherInterface $eventDispatcher,
protected readonly SharedTempStoreFactory $tempStoreFactory,
protected readonly TimeInterface $time,
protected readonly PathFactoryInterface $pathFactory,
protected readonly FailureMarker $failureMarker,
) {
$this->tempStore = $tempStoreFactory->get('package_manager_stage');
}
/**
* Gets the stage type.
*
* The stage type can be used by stage event subscribers to implement logic
* specific to certain stages, without relying on the class name (which may
* not be part of module's public API).
*
* @return string
* The stage type.
*
* @throws \LogicException
* Thrown if $this->type is not explicitly overridden.
*/
final public function getType(): string {
$reflector = new \ReflectionProperty($this, 'type');
// The $type property must ALWAYS be overridden. This means that different
// subclasses can return the same value (thus allowing one stage to
// impersonate another one), but if that happens, it is intentional.
if ($reflector->getDeclaringClass()->getName() === static::class) {
return $this->type;
}
throw new \LogicException(static::class . ' must explicitly override the $type property.');
}
/**
* Determines if the stage directory can be created.
*
* @return bool
* TRUE if the stage directory can be created, otherwise FALSE.
*/
final public function isAvailable(): bool {
return empty($this->tempStore->getMetadata(static::TEMPSTORE_LOCK_KEY));
}
/**
* Returns a specific piece of metadata associated with this stage.
*
* Only the owner of the stage can access metadata, and the stage must either
* be claimed by its owner, or created during the current request.
*
* @param string $key
* The metadata key.
*
* @return mixed
* The metadata value, or NULL if it is not set.
*/
public function getMetadata(string $key) {
$this->checkOwnership();
$metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY) ?: [];
return $metadata[$key] ?? NULL;
}
/**
* Stores arbitrary metadata associated with this stage.
*
* Only the owner of the stage can set metadata, and the stage must either be
* claimed by its owner, or created during the current request.
*
* @param string $key
* The key under which to store the metadata. To prevent conflicts, it is
* strongly recommended that this be prefixed with the name of the module
* storing the data.
* @param mixed $data
* The metadata to store.
*/
public function setMetadata(string $key, $data): void {
$this->checkOwnership();
$metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY);
$metadata[$key] = $data;
$this->tempStore->set(static::TEMPSTORE_METADATA_KEY, $metadata);
}
/**
* Collects paths that Composer Stager should exclude.
*
* @return \PhpTuf\ComposerStager\API\Path\Value\PathListInterface
* A list of paths that Composer Stager should exclude when creating the
* stage directory and applying staged changes to the active directory.
*
* @throws \Drupal\package_manager\Exception\SandboxException
* Thrown if an exception occurs while collecting paths to exclude.
*
* @see ::create()
* @see ::apply()
*/
protected function getPathsToExclude(): PathListInterface {
$event = new CollectPathsToExcludeEvent($this, $this->pathLocator, $this->pathFactory);
try {
return $this->eventDispatcher->dispatch($event);
}
catch (\Throwable $e) {
$this->rethrowAsStageException($e);
}
}
/**
* Copies the active code base into the stage directory.
*
* This will automatically claim the stage, so external code does NOT need to
* call ::claim(). However, if it was created during another request, the
* stage must be claimed before operations can be performed on it.
*
* @param int|null $timeout
* (optional) How long to allow the file copying operation to run before
* timing out, in seconds, or NULL to never time out. Defaults to 300
* seconds.
*
* @return string
* Unique ID for the stage, which can be used to claim the stage before
* performing other operations on it. Calling code should store this ID for
* as long as the stage needs to exist.
*
* @throws \Drupal\package_manager\Exception\SandboxException
* Thrown if a stage directory already exists, or if an error occurs while
* creating the stage directory. In the latter situation, the stage
* directory will be destroyed.
*
* @see ::claim()
*/
public function create(?int $timeout = 300): string {
$this->failureMarker->assertNotExists();
if (!$this->isAvailable()) {
throw new SandboxException($this, 'Cannot create a new stage because one already exists.');
}
// Mark the stage as unavailable as early as possible, before dispatching
// the pre-create event. The idea is to prevent a race condition if the
// event subscribers take a while to finish, and two different users attempt
// to create a stage directory at around the same time. If an error occurs
// while the event is being processed, the stage is marked as available.
// @see ::dispatch()
// We specifically generate a random 32-character alphanumeric name in order
// to guarantee that the stage ID won't start with -, which could cause it
// to be interpreted as an option if it's used as a command-line argument.
// (For example, \Drupal\Component\Utility\Crypt::randomBytesBase64() would
// be vulnerable to this; the stage ID needs to be unique, but not
// cryptographically so.)
$id = (new Random())->name(32);
// Re-acquire the tempstore to ensure that the lock is written by whoever is
// actually logged in (or not) right now, since it's possible that the stage
// was instantiated (i.e., __construct() was called) by a different session,
// which would result in the lock having the wrong owner and the stage not
// being claimable by whoever is actually creating it.
$this->tempStore = $this->tempStoreFactory->get('package_manager_stage');
// For the lock value, we use both the stage's class and its type in order
// to prevent a stage from being manipulated by two different classes during
// a single life cycle.
$this->tempStore->set(static::TEMPSTORE_LOCK_KEY, [
$id,
static::class,
$this->getType(),
$this->isDirectWrite(),
]);
$this->claim($id);
$active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
$stage_dir = $this->pathFactory->create($this->getSandboxDirectory());
$excluded_paths = $this->getPathsToExclude();
$event = new PreCreateEvent($this, $excluded_paths);
// If an error occurs and we won't be able to create the stage, mark it as
// available.
$this->dispatch($event, [$this, 'markAsAvailable']);
try {
if ($this->isDirectWrite()) {
$this->logger?->info($this->t('Direct-write is enabled. Skipping sandboxing.'));
}
else {
$this->beginner->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout);
}
}
catch (\Throwable $error) {
$this->destroy();
$this->rethrowAsStageException($error);
}
$this->dispatch(new PostCreateEvent($this));
return $id;
}
/**
* Wraps an exception in a StageException and re-throws it.
*
* @param \Throwable $e
* The throwable to wrap.
*/
private function rethrowAsStageException(\Throwable $e): never {
throw new SandboxException($this, $e->getMessage(), $e->getCode(), $e);
}
/**
* Adds or updates packages in the sandbox directory.
*
* If this sandbox manager is running in direct-write mode, the changes will
* be made in the active directory.
*
* @param string[] $runtime
* The packages to add as regular top-level dependencies, in the form
* 'vendor/name' or 'vendor/name:version'.
* @param string[] $dev
* (optional) The packages to add as dev dependencies, in the form
* 'vendor/name' or 'vendor/name:version'. Defaults to an empty array.
* @param int|null $timeout
* (optional) How long to allow the Composer operation to run before timing
* out, in seconds, or NULL to never time out. Defaults to 300 seconds.
*
* @throws \Drupal\package_manager\Exception\SandboxException
* Thrown if the Composer operation cannot be started, or if an error occurs
* during the operation. In the latter situation, the stage directory will
* be destroyed.
*/
public function require(array $runtime, array $dev = [], ?int $timeout = 300): void {
$this->checkOwnership();
$this->dispatch(new PreRequireEvent($this, $runtime, $dev));
// A helper function to execute a command in the stage, destroying it if an
// exception occurs in the middle of a Composer operation.
$do_stage = function (array $command) use ($timeout): void {
$active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
$stage_dir = $this->pathFactory->create($this->getSandboxDirectory());
try {
$this->stager->stage($command, $active_dir, $stage_dir, NULL, $timeout);
}
catch (\Throwable $e) {
// If the caught exception isn't InvalidArgumentException or
// PreconditionException, a Composer operation was actually attempted,
// and failed. The stage should therefore be destroyed, because it's in
// an indeterminate and possibly unrecoverable state.
if (!$e instanceof InvalidArgumentException && !$e instanceof PreconditionException) {
$this->destroy();
}
$this->rethrowAsStageException($e);
}
};
// Change the runtime and dev requirements as needed, but don't update
// the installed packages yet.
if ($runtime) {
self::validateRequirements($runtime);
$command = array_merge(['require', '--no-update'], $runtime);
$do_stage($command);
}
if ($dev) {
self::validateRequirements($dev);
$command = array_merge(['require', '--dev', '--no-update'], $dev);
$do_stage($command);
}
// If constraints were changed, update those packages.
if ($runtime || $dev) {
$do_stage([
'update',
// Allow updating top-level dependencies.
'--with-all-dependencies',
// Always optimize the autoloader for better site performance.
'--optimize-autoloader',
// For extra safety and speed, make Composer do only the necessary
// changes to transitive (indirect) dependencies.
'--minimal-changes',
...$runtime,
...$dev,
]);
}
$this->dispatch(new PostRequireEvent($this, $runtime, $dev));
}
/**
* Applies staged changes to the active directory.
*
* After the staged changes are applied, the current request should be
* terminated as soon as possible. This is because the code loaded into the
* PHP runtime may no longer match the code that is physically present in the
* active code base, which means that the current request is running in an
* unreliable, inconsistent environment. In the next request,
* ::postApply() should be called as early as possible after Drupal is
* fully bootstrapped, to rebuild the service container, flush caches, and
* dispatch the post-apply event.
*
* @param int|null $timeout
* (optional) How long to allow the file copying operation to run before
* timing out, in seconds, or NULL to never time out. Defaults to 600
* seconds.
*
* @throws \Drupal\package_manager\Exception\ApplyFailedException
* Thrown if there is an error calling Composer Stager, which may indicate
* a failed commit operation.
*/
public function apply(?int $timeout = 600): void {
// In direct-write mode, changes are made directly to the running code base,
// so there is nothing to do.
if ($this->isDirectWrite()) {
$this->logger?->info($this->t('Direct-write is enabled. Changes have been made to the running code base.'));
return;
}
$this->checkOwnership();
$active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
$stage_dir = $this->pathFactory->create($this->getSandboxDirectory());
$excluded_paths = $this->getPathsToExclude();
$event = new PreApplyEvent($this, $excluded_paths);
// If an error occurs while dispatching the events, ensure that ::destroy()
// doesn't think we're in the middle of applying the staged changes to the
// active directory.
$this->tempStore->set(self::TEMPSTORE_APPLY_TIME_KEY, $this->time->getRequestTime());
$this->dispatch($event, $this->setNotApplying(...));
// Create a marker file so that we can tell later on if the commit failed.
$this->failureMarker->write($this, $this->getFailureMarkerMessage());
try {
$this->committer->commit($stage_dir, $active_dir, $excluded_paths, NULL, $timeout);
}
catch (InvalidArgumentException | PreconditionException $e) {
// The commit operation has not started yet, so we can clear the failure
// marker and release the flag that says we're applying.
$this->setNotApplying();
$this->failureMarker->clear();
$this->rethrowAsStageException($e);
}
catch (\Throwable $throwable) {
// The commit operation may have failed midway through, and the site code
// is in an indeterminate state. Release the flag which says we're still
// applying, because in this situation, the site owner should probably
// restore everything from a backup.
$this->setNotApplying();
// Update the marker file with the information from the throwable.
$this->failureMarker->write($this, $this->getFailureMarkerMessage(), $throwable);
throw new ApplyFailedException($this, $this->failureMarker->getMessage(), $throwable->getCode(), $throwable);
}
$this->failureMarker->clear();
$this->setMetadata(self::TEMPSTORE_CHANGES_APPLIED, TRUE);
}
/**
* Returns a closure that marks this stage as no longer being applied.
*/
private function setNotApplying(): void {
$this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY);
}
/**
* Performs post-apply tasks.
*
* This should be called as soon as possible after ::apply(), in a new
* request.
*
* @see ::apply()
*/
public function postApply(): void {
$this->checkOwnership();
if ($this->tempStore->get(self::TEMPSTORE_APPLY_TIME_KEY) === $this->time->getRequestTime()) {
$this->logger?->warning('Post-apply tasks are running in the same request during which staged changes were applied to the active code base. This can result in unpredictable behavior.');
}
// Rebuild the container and clear all caches, to ensure that new services
// are picked up.
drupal_flush_all_caches();
// Refresh the event dispatcher so that new or changed event subscribers
// will be called. The other services we depend on are either stateless or
// unlikely to call newly added code during the current request.
$this->eventDispatcher = \Drupal::service('event_dispatcher');
$release_apply = $this->setNotApplying(...);
$this->dispatch(new PostApplyEvent($this), $release_apply);
$release_apply();
}
/**
* Deletes the stage directory.
*
* @param bool $force
* (optional) If TRUE, the stage directory will be destroyed even if it is
* not owned by the current user or session. Defaults to FALSE.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message
* (optional) A message about why the stage was destroyed.
*
* @throws \Drupal\package_manager\Exception\SandboxException
* If the staged changes are being applied to the active directory.
* @throws \Drupal\Core\TempStore\TempStoreException
*/
public function destroy(bool $force = FALSE, ?TranslatableMarkup $message = NULL): void {
if (!$force) {
$this->checkOwnership();
}
if ($this->isApplying()) {
throw new SandboxException($this, 'Cannot destroy the stage directory while it is being applied to the active directory.');
}
// If the stage directory exists, queue it to be automatically cleaned up
// later by a queue (which may or may not happen during cron).
// @see \Drupal\package_manager\Plugin\QueueWorker\Cleaner
if ($this->sandboxDirectoryExists() && !$this->isDirectWrite()) {
$this->queueFactory->get('package_manager_cleanup')
->createItem($this->getSandboxDirectory());
}
$this->storeDestroyInfo($force, $message);
$this->markAsAvailable();
}
/**
* Marks the stage as available.
*/
protected function markAsAvailable(): void {
$this->tempStore->delete(static::TEMPSTORE_METADATA_KEY);
$this->tempStore->delete(static::TEMPSTORE_LOCK_KEY);
$this->tempStore->delete(self::TEMPSTORE_STAGING_ROOT_KEY);
$this->lock = NULL;
}
/**
* Dispatches an event and handles any errors that it collects.
*
* @param \Drupal\package_manager\Event\SandboxEvent $event
* The event object.
* @param callable|null $on_error
* (optional) A callback function to call if an error occurs, before any
* exceptions are thrown.
*
* @throws \Drupal\package_manager\Exception\SandboxEventException
* If the event collects any validation errors.
*/
protected function dispatch(SandboxEvent $event, ?callable $on_error = NULL): void {
try {
$this->eventDispatcher->dispatch($event);
if ($event instanceof SandboxValidationEvent) {
if ($event->getResults()) {
$error = new SandboxEventException($event);
}
}
}
catch (\Throwable $error) {
$error = new SandboxEventException($event, $error->getMessage(), $error->getCode(), $error);
}
if (isset($error)) {
// Ensure the error is logged for post-mortem diagnostics.
if ($this->logger) {
Error::logException($this->logger, $error);
}
if ($on_error) {
$on_error();
}
throw $error;
}
}
/**
* Attempts to claim the stage.
*
* Once a stage has been created, no operations can be performed on it until
* it is claimed. This is to ensure that stage operations across multiple
* requests are being done by the same code, running under the same user or
* session that created the stage in the first place. To claim a stage, the
* calling code must provide the unique identifier that was generated when the
* stage was created.
*
* The stage is claimed when it is created, so external code does NOT need to
* call this method after calling ::create() in the same request.
*
* @param string $unique_id
* The unique ID that was returned by ::create().
*
* @return $this
*
* @throws \Drupal\package_manager\Exception\SandboxOwnershipException
* If the stage cannot be claimed. This can happen if the current user or
* session did not originally create the stage, if $unique_id doesn't match
* the unique ID that was generated when the stage was created, or the
* current class is not the same one that was used to create the stage.
*
* @see ::create()
*/
final public function claim(string $unique_id): self {
$this->failureMarker->assertNotExists();
if ($this->isAvailable()) {
// phpcs:disable DrupalPractice.General.ExceptionT.ExceptionT
// @see https://www.drupal.org/project/auto_updates/issues/3338651
throw new SandboxException($this, $this->computeDestroyMessage(
$unique_id,
$this->t('Cannot claim the stage because no stage has been created.')
)->render());
}
$stored_lock = $this->tempStore->getIfOwner(static::TEMPSTORE_LOCK_KEY);
if (!$stored_lock) {
throw new SandboxOwnershipException($this, $this->computeDestroyMessage(
$unique_id,
$this->t('Cannot claim the stage because it is not owned by the current user or session.')
)->render());
}
if (array_slice($stored_lock, 0, 3) === [$unique_id, static::class, $this->getType()]) {
$this->lock = $stored_lock;
if ($this->isDirectWrite()) {
// Bypass a hard-coded set of Composer Stager preconditions that prevent
// the active directory from being modified directly.
DirectWritePreconditionBypass::activate();
}
return $this;
}
throw new SandboxOwnershipException($this, $this->computeDestroyMessage(
$unique_id,
$this->t('Cannot claim the stage because the current lock does not match the stored lock.')
)->render());
// phpcs:enable DrupalPractice.General.ExceptionT.ExceptionT
}
/**
* Returns the specific destroy message for the ID.
*
* @param string $unique_id
* The unique ID that was returned by ::create().
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $fallback_message
* A fallback message, in case no specific message was stored.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* A message describing why the stage with the given ID was destroyed, or if
* no message was associated with that destroyed stage, the provided
* fallback message.
*/
private function computeDestroyMessage(string $unique_id, TranslatableMarkup $fallback_message): TranslatableMarkup {
// Check to see if we have a specific message about a stage with a
// specific ID that was given.
return $this->tempStore->get(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $unique_id) ?? $fallback_message;
}
/**
* Validates the ownership of stage directory.
*
* The stage is considered under valid ownership if it was created by current
* user or session, using the current class.
*
* @throws \LogicException
* If ::claim() has not been previously called.
* @throws \Drupal\package_manager\Exception\SandboxOwnershipException
* If the current user or session does not own the stage directory, or it
* was created by a different class.
*/
final protected function checkOwnership(): void {
if (empty($this->lock)) {
throw new \LogicException('Stage must be claimed before performing any operations on it.');
}
$stored_lock = $this->tempStore->getIfOwner(static::TEMPSTORE_LOCK_KEY);
if ($stored_lock !== $this->lock) {
throw new SandboxOwnershipException($this, 'Stage is not owned by the current user or session.');
}
}
/**
* Returns the path of the directory where changes should be staged.
*
* @return string
* The absolute path of the directory where changes should be staged. If
* this sandbox manager is operating in direct-write mode, this will be
* path of the active directory.
*
* @throws \LogicException
* If this method is called before the stage has been created or claimed.
*/
public function getSandboxDirectory(): string {
if (!$this->lock) {
throw new \LogicException(__METHOD__ . '() cannot be called because the stage has not been created or claimed.');
}
if ($this->isDirectWrite()) {
return $this->pathLocator->getProjectRoot();
}
return $this->getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0];
}
/**
* Returns the directory where stage directories will be created.
*
* @return string
* The absolute path of the directory containing the stage directories
* managed by this class.
*/
private function getStagingRoot(): string {
// Since the stage root can depend on site settings, store it so that
// things won't break if the settings change during this stage's life
// cycle.
$dir = $this->tempStore->get(self::TEMPSTORE_STAGING_ROOT_KEY);
if (empty($dir)) {
$dir = $this->pathLocator->getStagingRoot();
$this->tempStore->set(self::TEMPSTORE_STAGING_ROOT_KEY, $dir);
}
return $dir;
}
/**
* Determines if the stage directory exists.
*
* @return bool
* TRUE if the directory exists, otherwise FALSE.
*/
public function sandboxDirectoryExists(): bool {
try {
return is_dir($this->getSandboxDirectory());
}
catch (\LogicException) {
return FALSE;
}
}
/**
* Checks if staged changes are being applied to the active directory.
*
* @return bool
* TRUE if the staged changes are being applied to the active directory, and
* it has been less than an hour since that operation began. If more than an
* hour has elapsed since the changes started to be applied, FALSE is
* returned even if the stage internally thinks that changes are still being
* applied.
*
* @see ::apply()
*/
final public function isApplying(): bool {
$apply_time = $this->tempStore->get(self::TEMPSTORE_APPLY_TIME_KEY);
return isset($apply_time) && $this->time->getRequestTime() - $apply_time < 3600;
}
/**
* Returns the failure marker message.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* The translated failure marker message.
*/
protected function getFailureMarkerMessage(): TranslatableMarkup {
return $this->t('Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.');
}
/**
* Validates a set of package names.
*
* Package names are considered invalid if they look like Drupal project
* names. The only exceptions to this are platform requirements, like `php`,
* `composer`, or `ext-json`, which are legitimate to Composer.
*
* @param string[] $requirements
* A set of package names (with or without version constraints), as passed
* to ::require().
*
* @throws \InvalidArgumentException
* Thrown if any of the given package names fail basic validation.
*/
protected static function validateRequirements(array $requirements): void {
$version_parser = new VersionParser();
foreach ($requirements as $requirement) {
$parts = explode(':', $requirement, 2);
$name = $parts[0];
if (!preg_match(self::COMPOSER_PLATFORM_PACKAGE_REGEX, $name) && !preg_match(self::COMPOSER_PACKAGE_REGEX, $name)) {
throw new \InvalidArgumentException("Invalid package name '$name'.");
}
if (count($parts) > 1) {
$version_parser->parseConstraints($parts[1]);
}
}
}
/**
* Stores information about the stage when it is destroyed.
*
* @param bool $force
* Whether the stage was force destroyed.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message
* A message about why the stage was destroyed or null.
*
* @throws \Drupal\Core\TempStore\TempStoreException
*/
protected function storeDestroyInfo(bool $force, ?TranslatableMarkup $message): void {
if (!$message) {
if ($this->tempStore->get(self::TEMPSTORE_CHANGES_APPLIED) === TRUE) {
$message = $this->t('This operation has already been applied.');
}
else {
if ($force) {
$message = $this->t('This operation was canceled by another user.');
}
else {
$message = $this->t('This operation was already canceled.');
}
}
}
[$id] = $this->tempStore->get(static::TEMPSTORE_LOCK_KEY);
$this->tempStore->set(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $id, $message);
}
/**
* Indicates whether the active directory will be changed directly.
*
* This can only happen if direct-write is globally enabled by the
* `package_manager_allow_direct_write` setting, AND this class explicitly
* allows it (by adding the AllowDirectWrite attribute).
*
* @return bool
* TRUE if the sandbox manager is operating in direct-write mode, otherwise
* FALSE.
*/
final public function isDirectWrite(): bool {
// The use of direct-write is stored as part of the lock so that it will
// remain consistent during the sandbox's entire life cycle, even if the
// underlying global settings are changed.
if ($this->lock) {
return $this->lock[3];
}
$reflector = new \ReflectionClass($this);
return Settings::get('package_manager_allow_direct_write', FALSE) && $reflector->getAttributes(AllowDirectWrite::class);
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Contains helper methods to run status checks on a stage.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not use or interact with
* this trait.
*/
trait StatusCheckTrait {
/**
* Runs a status check for a stage and returns the results, if any.
*
* @param \Drupal\package_manager\SandboxManagerBase $sandbox_manager
* The stage to run the status check for.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface|null $event_dispatcher
* (optional) The event dispatcher service.
* @param \Drupal\package_manager\PathLocator|null $path_locator
* (optional) The path locator service.
* @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface|null $path_factory
* (optional) The path factory service.
*
* @return \Drupal\package_manager\ValidationResult[]
* The results of the status check. If a readiness check was also done,
* its results will be included.
*/
protected function runStatusCheck(SandboxManagerBase $sandbox_manager, ?EventDispatcherInterface $event_dispatcher = NULL, ?PathLocator $path_locator = NULL, ?PathFactoryInterface $path_factory = NULL): array {
$event_dispatcher ??= \Drupal::service('event_dispatcher');
$path_locator ??= \Drupal::service(PathLocator::class);
$path_factory ??= \Drupal::service(PathFactoryInterface::class);
try {
$paths_to_exclude_event = new CollectPathsToExcludeEvent($sandbox_manager, $path_locator, $path_factory);
$event_dispatcher->dispatch($paths_to_exclude_event);
}
catch (\Throwable $throwable) {
$paths_to_exclude_event = $throwable;
}
$event = new StatusCheckEvent($sandbox_manager, $paths_to_exclude_event);
return $event_dispatcher->dispatch($event)->getResults();
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use PhpTuf\ComposerStager\API\Translation\Service\TranslatorInterface;
use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface;
use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface;
/**
* An adapter for interoperable string translation.
*
* This class is designed to adapt Drupal's style of string translation so it
* can be used with the Symfony-inspired architecture used by Composer Stager.
*
* If this object is cast to a string, it will be translated by Drupal's
* translation system. It will ONLY be translated by Composer Stager if the
* trans() method is explicitly called.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class TranslatableStringAdapter extends TranslatableMarkup implements TranslatableInterface, TranslationParametersInterface {
/**
* {@inheritdoc}
*/
public function getAll(): array {
return $this->getArguments();
}
/**
* {@inheritdoc}
*/
public function trans(?TranslatorInterface $translator = NULL, ?string $locale = NULL): string {
// This method is NEVER used by Drupal to translate the underlying string;
// it exists solely for Composer Stager's translation system to
// transparently translate Drupal strings using its own architecture.
return $translator->trans(
$this->getUntranslatedString(),
$this,
// The 'context' option is the closest analogue to the Symfony-inspired
// concept of translation domains.
$this->getOption('context'),
$locale ?? $this->getOption('langcode'),
);
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Core\StringTranslation\TranslationInterface;
use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface;
use PhpTuf\ComposerStager\API\Translation\Service\DomainOptionsInterface;
use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface;
use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface;
/**
* Creates translatable strings that can interoperate with Composer Stager.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class TranslatableStringFactory implements TranslatableFactoryInterface {
public function __construct(
private readonly TranslatableFactoryInterface $decorated,
private readonly TranslationInterface $translation,
) {}
/**
* {@inheritdoc}
*/
public function createDomainOptions(): DomainOptionsInterface {
return $this->decorated->createDomainOptions();
}
/**
* {@inheritdoc}
*/
public function createTranslatableMessage(string $message, ?TranslationParametersInterface $parameters = NULL, ?string $domain = NULL): TranslatableInterface {
return new TranslatableStringAdapter(
$message,
$parameters?->getAll() ?? [],
// TranslatableMarkup's 'context' option is the closest analogue to the
// $domain parameter.
['context' => $domain ?? ''],
$this->translation,
);
}
/**
* {@inheritdoc}
*/
public function createTranslationParameters(array $parameters = []): TranslationParametersInterface {
return $this->decorated->createTranslationParameters($parameters);
}
}

View File

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager;
use Drupal\Component\Assertion\Inspector;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use PhpTuf\ComposerStager\API\Exception\ExceptionInterface;
/**
* A value object to contain the results of a validation.
*
* @property \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
*/
final class ValidationResult {
/**
* Creates a ValidationResult object.
*
* @param int $severity
* The severity of the result. Should be one of the
* SystemManager::REQUIREMENT_* constants.
* @todo Refactor this to use RequirementSeverity in https://www.drupal.org/i/3525121.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup[]|string[] $messages
* The result messages.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
* A succinct summary of the result.
* @param bool $assert_translatable
* Whether to assert the messages are translatable. Internal use only.
*
* @throws \InvalidArgumentException
* Thrown if $messages is empty, or if it has 2 or more items but $summary
* is NULL.
*/
private function __construct(
public readonly int $severity,
private readonly array $messages,
public readonly ?TranslatableMarkup $summary,
bool $assert_translatable,
) {
if ($assert_translatable) {
assert(Inspector::assertAll(fn ($message) => $message instanceof TranslatableMarkup, $messages));
}
if (empty($messages)) {
throw new \InvalidArgumentException('At least one message is required.');
}
if (count($messages) > 1 && !$summary) {
throw new \InvalidArgumentException('If more than one message is provided, a summary is required.');
}
}
/**
* Implements magic ::__get() method.
*/
public function __get(string $name): mixed {
return match ($name) {
// The messages must be private so that they cannot be mutated by external
// code, but we want to allow callers to access them in the same way as
// $this->summary and $this->severity.
'messages' => $this->messages,
};
}
/**
* Creates an error ValidationResult object from a throwable.
*
* @param \Throwable $throwable
* The throwable.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
* The errors summary.
*
* @return static
*/
public static function createErrorFromThrowable(\Throwable $throwable, ?TranslatableMarkup $summary = NULL): static {
// All Composer Stager exceptions are translatable.
$is_translatable = $throwable instanceof ExceptionInterface;
$message = $is_translatable ? $throwable->getTranslatableMessage() : $throwable->getMessage();
return new static(RequirementSeverity::Error->value, [$message], $summary, $is_translatable);
}
/**
* Creates an error ValidationResult object.
*
* @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
* The error messages.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
* The errors summary.
*
* @return static
*/
public static function createError(array $messages, ?TranslatableMarkup $summary = NULL): static {
return new static(RequirementSeverity::Error->value, $messages, $summary, TRUE);
}
/**
* Creates a warning ValidationResult object.
*
* @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
* The error messages.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
* The errors summary.
*
* @return static
*/
public static function createWarning(array $messages, ?TranslatableMarkup $summary = NULL): static {
return new static(RequirementSeverity::Warning->value, $messages, $summary, TRUE);
}
/**
* Returns the overall severity for a set of validation results.
*
* @param \Drupal\package_manager\ValidationResult[] $results
* The validation results.
*
* @return int
* The overall severity of the results. Will be one of the
* SystemManager::REQUIREMENT_* constants.
*/
public static function getOverallSeverity(array $results): int {
foreach ($results as $result) {
if ($result->severity === RequirementSeverity::Error->value) {
return RequirementSeverity::Error->value;
}
}
// If there were no errors, then any remaining results must be warnings.
return $results ? RequirementSeverity::Warning->value : RequirementSeverity::OK->value;
}
/**
* Determines if two validation results are equivalent.
*
* @param self $a
* A validation result.
* @param self $b
* Another validation result.
*
* @return bool
* TRUE if the given validation results have the same severity, summary,
* and messages (in the same order); otherwise FALSE.
*/
public static function isEqual(self $a, self $b): bool {
return (
$a->severity === $b->severity &&
strval($a->summary) === strval($b->summary) &&
array_map('strval', $a->messages) === array_map('strval', $b->messages)
);
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Component\Serialization\Json;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\StatusCheckEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\PathLocator;
/**
* Validates the list of packages that are allowed to scaffold files.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class AllowedScaffoldPackagesValidator implements EventSubscriberInterface {
use StringTranslationTrait;
public function __construct(
private readonly ComposerInspector $composerInspector,
private readonly PathLocator $pathLocator,
) {}
/**
* Validates that only the implicitly allowed packages can use scaffolding.
*/
public function validate(SandboxValidationEvent $event): void {
$sandbox_manager = $event->sandboxManager;
$path = $event instanceof PreApplyEvent
? $sandbox_manager->getSandboxDirectory()
: $this->pathLocator->getProjectRoot();
// @see https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold
$implicitly_allowed_packages = [
"drupal/legacy-scaffold-assets",
"drupal/core",
];
$extra = Json::decode($this->composerInspector->getConfig('extra', $path . '/composer.json'));
$allowed_packages = $extra['drupal-scaffold']['allowed-packages'] ?? [];
$extra_packages = array_diff($allowed_packages, $implicitly_allowed_packages);
if (!empty($extra_packages)) {
$event->addError(
// phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString
array_map($this->t(...), $extra_packages),
$this->t('Any packages other than the implicitly allowed packages are not allowed to scaffold files. See <a href=":url">the scaffold documentation</a> for more information.', [
':url' => 'https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold',
])
);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() : array {
return [
StatusCheckEvent::class => 'validate',
PreCreateEvent::class => 'validate',
PreApplyEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
/**
* Provides methods for base requirement validators.
*
* This trait should only be used by validators that check base requirements,
* which means they run before
* \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator.
*
* Validators which use this trait should NOT stop event propagation.
*
* @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator
*/
trait BaseRequirementValidatorTrait {
/**
* Validates base requirements.
*
* @param \Drupal\package_manager\Event\SandboxValidationEvent $event
* The event being handled.
*/
abstract public function validate(SandboxValidationEvent $event): void;
/**
* Implements EventSubscriberInterface::getSubscribedEvents().
*/
public static function getSubscribedEvents(): array {
// Always run before the BaseRequirementsFulfilledValidator.
// @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator
$priority = BaseRequirementsFulfilledValidator::PRIORITY + 10;
return [
PreCreateEvent::class => ['validate', $priority],
PreRequireEvent::class => ['validate', $priority],
PreApplyEvent::class => ['validate', $priority],
StatusCheckEvent::class => ['validate', $priority],
];
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace Drupal\package_manager\Validator;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates that base requirements do not have any errors.
*
* Base requirements are the sorts of things that must be in a good state for
* Package Manager to be usable. For example, Composer must be available and
* usable; certain paths of the file system must be writable; the current site
* cannot be part of a multisite, and so on.
*
* This validator simply stops event propagation if any of the validators before
* it have added error results. Validators that check base requirements should
* run before this validator (they can use
* \Drupal\package_manager\Validator\BaseRequirementValidatorTrait to make this
* easier). To ensure that all base requirement errors are shown to the user, no
* base requirement validator should stop event propagation itself.
*
* Base requirement validators should not depend on each other or assume that
* Composer is usable in the current environment.
*
* @see \Drupal\package_manager\Validator\BaseRequirementValidatorTrait
*/
final class BaseRequirementsFulfilledValidator implements EventSubscriberInterface {
/**
* The priority of this validator.
*
* @see ::getSubscribedEvents()
*
* @var int
*/
public const PRIORITY = 200;
/**
* Validates that base requirements are fulfilled.
*
* @param \Drupal\package_manager\Event\SandboxValidationEvent $event
* The event.
*/
public function validate(SandboxValidationEvent $event): void {
// If there are any errors from the validators which ran before this one,
// base requirements are not fulfilled. Stop any further validators from
// running.
if ($event->getResults(RequirementSeverity::Error->value)) {
$event->stopPropagation();
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => ['validate', self::PRIORITY],
PreRequireEvent::class => ['validate', self::PRIORITY],
PreApplyEvent::class => ['validate', self::PRIORITY],
StatusCheckEvent::class => ['validate', self::PRIORITY],
];
}
}

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Composer\Semver\Semver;
use Composer\Semver\VersionParser;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that the packages to install meet the minimum stability.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ComposerMinimumStabilityValidator implements EventSubscriberInterface {
use StringTranslationTrait;
public function __construct(
private readonly PathLocator $pathLocator,
private readonly ComposerInspector $inspector,
) {}
/**
* Validates composer minimum stability.
*
* @param \Drupal\package_manager\Event\PreRequireEvent $event
* The stage event.
*/
public function validate(PreRequireEvent $event): void {
$dir = $this->pathLocator->getProjectRoot();
$minimum_stability = $this->inspector->getConfig('minimum-stability', $dir);
$requested_packages = array_merge($event->getDevPackages(), $event->getRuntimePackages());
foreach ($requested_packages as $package_name => $version) {
// In the root composer.json, a stability flag can also be specified. They
// take the form `constraint@stability`. A stability flag
// allows the project owner to deviate from the minimum-stability setting.
// @see https://getcomposer.org/doc/04-schema.md#package-links
// @see \Composer\Package\Loader\RootPackageLoader::extractStabilityFlags()
if (str_contains($version, '@')) {
continue;
}
$stability = VersionParser::parseStability($version);
// Because drupal/core prefers to not depend on composer/composer we need
// to compare two versions that are identical except for stability to
// determine if the package stability is less that the minimum stability.
if (Semver::satisfies("1.0.0-$stability", "< 1.0.0-$minimum_stability")) {
$event->addError([
$this->t("<code>@package_name</code>'s requested version @package_version is less stable (@package_stability) than the minimum stability (@minimum_stability) required in @file.",
[
'@package_name' => $package_name,
'@package_version' => $version,
'@package_stability' => $stability,
'@minimum_stability' => $minimum_stability,
'@file' => $this->pathLocator->getProjectRoot() . '/composer.json',
]
),
]);
}
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreRequireEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Composer\Semver\Semver;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates the configuration of the cweagans/composer-patches plugin.
*
* To ensure that applied patches remain consistent between the active and
* stage directories, the following rules are enforced if the patcher is
* installed:
* - It must be installed in both places, or in neither of them. It can't, for
* example, be installed in the active directory but not the stage directory
* (or vice versa).
* - It must be one of the project's direct runtime or dev dependencies.
* - It cannot be installed or removed by Package Manager. In other words, it
* must be added to the project at the command line by someone technical
* enough to install and configure it properly.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ComposerPatchesValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* The name of the plugin being analyzed.
*
* @var string
*/
private const PLUGIN_NAME = 'cweagans/composer-patches';
public function __construct(
private readonly ModuleHandlerInterface $moduleHandler,
private readonly ComposerInspector $composerInspector,
private readonly PathLocator $pathLocator,
) {}
/**
* Validates the status of the patcher plugin.
*
* @param \Drupal\package_manager\Event\SandboxValidationEvent $event
* The event object.
*/
public function validate(SandboxValidationEvent $event): void {
$messages = [];
[$plugin_installed_in_active, $is_active_root_requirement, $active_configuration_ok] = $this->computePatcherStatus($this->pathLocator->getProjectRoot());
if ($event instanceof PreApplyEvent) {
[$plugin_installed_in_stage, $is_stage_root_requirement, $stage_configuration_ok] = $this->computePatcherStatus($event->sandboxManager->getSandboxDirectory());
$has_staged_update = TRUE;
}
else {
// No staged update exists.
$has_staged_update = FALSE;
}
// If there's a staged update and the patcher has been installed or removed
// in the stage directory, that's a problem.
if ($has_staged_update && $plugin_installed_in_active !== $plugin_installed_in_stage) {
if ($plugin_installed_in_stage) {
$message = $this->t('It cannot be installed by Package Manager.');
}
else {
$message = $this->t('It cannot be removed by Package Manager.');
}
$messages[] = $this->createErrorMessage($message, 'package-manager-faq-composer-patches-installed-or-removed');
}
// If the patcher is not listed in the runtime or dev dependencies, that's
// an error as well.
if (($plugin_installed_in_active && !$is_active_root_requirement) || ($has_staged_update && $plugin_installed_in_stage && !$is_stage_root_requirement)) {
$messages[] = $this->createErrorMessage($this->t('It must be a root dependency.'), 'package-manager-faq-composer-patches-not-a-root-dependency');
}
// If the plugin is misconfigured in either the active or stage directories,
// flag an error.
if (($plugin_installed_in_active && !$active_configuration_ok) || ($has_staged_update && $plugin_installed_in_stage && !$stage_configuration_ok)) {
$messages[] = $this->t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.');
}
if ($messages) {
$summary = $this->t("Problems detected related to the Composer plugin <code>@plugin</code>.", [
'@plugin' => static::PLUGIN_NAME,
]);
$event->addError($messages, $summary);
}
}
/**
* Appends a link to online help to an error message.
*
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $message
* The error message.
* @param string $fragment
* The fragment of the online help to link to.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* The final, translated error message.
*/
private function createErrorMessage(TranslatableMarkup $message, string $fragment): TranslatableMarkup {
if ($this->moduleHandler->moduleExists('help')) {
$url = Url::fromRoute('help.page', ['name' => 'package_manager'])
->setOption('fragment', $fragment)
->toString();
return $this->t('@message See <a href=":url">the help page</a> for information on how to resolve the problem.', [
'@message' => $message,
':url' => $url,
]);
}
return $message;
}
/**
* Computes the status of the patcher plugin in a particular directory.
*
* @param string $working_dir
* The directory in which to run Composer.
*
* @return bool[]
* An indexed array containing three booleans, in order:
* - Whether the patcher plugin is installed.
* - Whether the patcher plugin is a root requirement in composer.json (in
* either the runtime or dev dependencies).
* - Whether the `composer-exit-on-patch-failure` flag is set in the `extra`
* section of composer.json.
*/
private function computePatcherStatus(string $working_dir): array {
$list = $this->composerInspector->getInstalledPackagesList($working_dir);
$installed_version = $list[static::PLUGIN_NAME]?->version;
$info = $this->composerInspector->getRootPackageInfo($working_dir);
$is_root_requirement = array_key_exists(static::PLUGIN_NAME, $info['requires'] ?? []) || array_key_exists(static::PLUGIN_NAME, $info['devRequires'] ?? []);
// The 2.x version of the plugin always exits with an error if a patch can't
// be applied.
if ($installed_version && Semver::satisfies($installed_version, '^2')) {
$exit_on_failure = TRUE;
}
else {
$extra = Json::decode($this->composerInspector->getConfig('extra', $working_dir));
$exit_on_failure = $extra['composer-exit-on-patch-failure'] ?? FALSE;
}
return [
is_string($installed_version),
$is_root_requirement,
$exit_on_failure,
];
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => 'validate',
PreApplyEvent::class => 'validate',
StatusCheckEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Composer\Semver\Semver;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use PhpTuf\ComposerStager\API\Exception\RuntimeException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates the allowed Composer plugins, both in active and stage.
*
* Composer plugins can make far-reaching changes on the filesystem. That is why
* they can cause Package Manager (more specifically the infrastructure it uses:
* php-tuf/composer-stager) to not work reliably; potentially even break a site!
*
* This validator restricts the use of Composer plugins:
* - Allowing all plugins to run indiscriminately is discouraged by Composer,
* but disallowed by this module (it is too risky):
* `config.allowed-plugins = true` is forbidden.
* - Installed Composer plugins that are not allowed (in composer.json's
* `config.allowed-plugins ) are not executed by Composer, so
* these are safe.
* - Installed Composer plugins that are allowed need to be either explicitly
* supported by this validator (they may still need their own validation to
* ensure their configuration is safe, for example Drupal core's vendor
* hardening plugin), or explicitly trusted by adding it to the
* `package_manager.settings` configuration's
* `additional_trusted_composer_plugins` list.
*
* @todo Determine how other Composer plugins will be supported in
* https://drupal.org/i/3339417.
*
* @see https://getcomposer.org/doc/04-schema.md#type
* @see https://getcomposer.org/doc/articles/plugins.md
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ComposerPluginsValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* Composer plugins known to modify other packages, but are validated.
*
* The validation guarantees they are safe to use.
*
* @var string[]
* Keys are Composer plugin package names, values are version constraints
* for those plugins that this validator explicitly supports.
*/
private const SUPPORTED_PLUGINS_THAT_DO_MODIFY = [
// @see \Drupal\package_manager\PathExcluder\VendorHardeningExcluder
'drupal/core-vendor-hardening' => '*',
'php-http/discovery' => '*',
];
/**
* Composer plugins known to NOT modify other packages.
*
* @var string[]
* Keys are Composer plugin package names, values are version constraints
* for those plugins that this validator explicitly supports.
*/
private const SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY = [
'composer/installers' => '^2.0',
'dealerdirect/phpcodesniffer-composer-installer' => '^0.7.1 || ^1.0.0',
'drupal/core-composer-scaffold' => '*',
'drupal/core-recipe-unpack' => '*',
'drupal/core-project-message' => '*',
'phpstan/extension-installer' => '^1.1',
PhpTufValidator::PLUGIN_NAME => '^1',
];
/**
* The additional trusted Composer plugin package names.
*
* The package names are normalized.
*
* @var string[]
* Keys are package names, values are version constraints.
*/
private array $additionalTrustedComposerPlugins;
public function __construct(
ConfigFactoryInterface $config_factory,
private readonly ComposerInspector $inspector,
private readonly PathLocator $pathLocator,
) {
$settings = $config_factory->get('package_manager.settings');
$this->additionalTrustedComposerPlugins = array_fill_keys(
array_map(
[__CLASS__, 'normalizePackageName'],
$settings->get('additional_trusted_composer_plugins')
),
// The additional_trusted_composer_plugins setting cannot specify a
// version constraint. The plugins are either trusted or they're not.
'*'
);
}
/**
* Normalizes a package name.
*
* @param string $package_name
* A package name.
*
* @return string
* The normalized package name.
*/
private static function normalizePackageName(string $package_name): string {
return strtolower($package_name);
}
/**
* Validates the allowed Composer plugins, both in active and stage.
*/
public function validate(SandboxValidationEvent $event): void {
$sandbox_manager = $event->sandboxManager;
// When about to copy the changes from the stage directory to the active
// directory, use the stage directory's composer instead of the active.
// Because composer plugins may be added or removed; the only thing that
// matters is the set of composer plugins that *will* apply — if a composer
// plugin is being removed, that's fine.
$dir = $event instanceof PreApplyEvent
? $sandbox_manager->getSandboxDirectory()
: $this->pathLocator->getProjectRoot();
try {
$allowed_plugins = $this->inspector->getAllowPluginsConfig($dir);
}
catch (RuntimeException $exception) {
$event->addErrorFromThrowable($exception, $this->t('Unable to determine Composer <code>allow-plugins</code> setting.'));
return;
}
if ($allowed_plugins === TRUE) {
$event->addError([$this->t('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.')]);
return;
}
// TRICKY: additional trusted Composer plugins is listed first, to allow
// site owners who know what they're doing to use unsupported versions of
// supported Composer plugins.
$trusted_plugins = $this->additionalTrustedComposerPlugins
+ self::SUPPORTED_PLUGINS_THAT_DO_MODIFY
+ self::SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY;
assert(is_array($allowed_plugins));
// Only packages with `true` as a value are actually executed by Composer.
$allowed_plugins = array_keys(array_filter($allowed_plugins));
// The keys are normalized package names, and the values are the original,
// non-normalized package names.
$allowed_plugins = array_combine(
array_map([__CLASS__, 'normalizePackageName'], $allowed_plugins),
$allowed_plugins
);
$installed_packages = $this->inspector->getInstalledPackagesList($dir);
// Determine which plugins are both trusted by us, AND allowed by Composer's
// configuration.
$supported_plugins = array_intersect_key($allowed_plugins, $trusted_plugins);
// Create an array whose keys are the names of those plugins, and the values
// are their installed versions.
$supported_plugins_installed_versions = array_combine(
$supported_plugins,
array_map(
fn (string $name): ?string => $installed_packages[$name]?->version,
$supported_plugins
)
);
// Find the plugins whose installed versions aren't in the supported range.
$unsupported_installed_versions = array_filter(
$supported_plugins_installed_versions,
fn (?string $version, string $name): bool => $version && !Semver::satisfies($version, $trusted_plugins[$name]),
ARRAY_FILTER_USE_BOTH
);
$untrusted_plugins = array_diff_key($allowed_plugins, $trusted_plugins);
$messages = array_map(
fn (string $raw_name) => $this->t('<code>@name</code>', ['@name' => $raw_name]),
$untrusted_plugins
);
foreach ($unsupported_installed_versions as $name => $installed_version) {
$messages[] = $this->t("<code>@name</code> is supported, but only version <code>@supported_version</code>, found <code>@installed_version</code>.", [
'@name' => $name,
'@supported_version' => $trusted_plugins[$name],
'@installed_version' => $installed_version,
]);
}
if ($messages) {
$summary = $this->formatPlural(
count($messages),
'An unsupported Composer plugin was detected.',
'Unsupported Composer plugins were detected.',
);
$event->addError($messages, $summary);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => 'validate',
PreApplyEvent::class => 'validate',
StatusCheckEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Url;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates the project can be used by the Composer Inspector.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ComposerValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait;
use StringTranslationTrait;
public function __construct(
private readonly ComposerInspector $composerInspector,
private readonly PathLocator $pathLocator,
private readonly ModuleHandlerInterface $moduleHandler,
) {}
/**
* Validates that the Composer executable is the correct version.
*/
public function validate(SandboxValidationEvent $event): void {
// If we can't stat processes, there's nothing else we can possibly do here.
// @see \Symfony\Component\Process\Process::__construct()
if (!\function_exists('proc_open')) {
$message = $this->t('Composer cannot be used because the <code>proc_open()</code> function is disabled.');
if ($this->moduleHandler->moduleExists('help')) {
$message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to resolve the problem.', [
'@message' => $message,
':package-manager-help' => self::getHelpUrl('package-manager-composer-related-faq'),
]);
}
$event->addError([$message]);
return;
}
$messages = [];
$dir = $event instanceof PreApplyEvent
? $event->sandboxManager->getSandboxDirectory()
: $this->pathLocator->getProjectRoot();
try {
$this->composerInspector->validate($dir);
}
catch (\Throwable $e) {
if ($this->moduleHandler->moduleExists('help')) {
$message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to resolve the problem.', [
'@message' => $e->getMessage(),
':package-manager-help' => self::getHelpUrl('package-manager-composer-related-faq'),
]);
$event->addError([$message]);
}
else {
$event->addErrorFromThrowable($e);
}
return;
}
$settings = [];
foreach (['disable-tls', 'secure-http'] as $key) {
try {
$settings[$key] = json_decode($this->composerInspector->getConfig($key, $dir));
}
catch (\Throwable $e) {
$event->addErrorFromThrowable($e, $this->t('Unable to determine Composer <code>@key</code> setting.', [
'@key' => $key,
]));
return;
}
}
// If disable-tls is enabled, it overrides secure-http and sets its value to
// FALSE, even if secure-http is set to TRUE explicitly.
if ($settings['disable-tls'] === TRUE) {
$message = $this->t('TLS must be enabled for HTTPS Composer downloads.');
// If the Help module is installed, link to our help page, which displays
// the commands for configuring Composer correctly. Otherwise, direct
// users straight to the Composer documentation, which is a little less
// helpful.
if ($this->moduleHandler->moduleExists('help')) {
$messages[] = $this->t('@message See <a href=":url">the help page</a> for more information on how to configure Composer to download packages securely.', [
'@message' => $message,
':url' => self::getHelpUrl('package-manager-requirements'),
]);
}
else {
$messages[] = $this->t('@message See <a href=":url">the Composer documentation</a> for more information.', [
'@message' => $message,
':url' => 'https://getcomposer.org/doc/06-config.md#disable-tls',
]);
}
$messages[] = $this->t('You should also check the value of <code>secure-http</code> and make sure that it is set to <code>true</code> or not set at all.');
}
elseif ($settings['secure-http'] !== TRUE) {
$message = $this->t('HTTPS must be enabled for Composer downloads.');
if ($this->moduleHandler->moduleExists('help')) {
$messages[] = $this->t('@message See <a href=":url">the help page</a> for more information on how to configure Composer to download packages securely.', [
'@message' => $message,
':url' => self::getHelpUrl('package-manager-requirements'),
]);
}
else {
$messages[] = $this->t('@message See <a href=":url">the Composer documentation</a> for more information.', [
'@message' => $message,
':url' => 'https://getcomposer.org/doc/06-config.md#secure-http',
]);
}
}
if ($messages) {
$event->addError($messages, $this->t("Composer settings don't satisfy Package Manager's requirements."));
}
}
/**
* Returns a URL to a specific fragment of Package Manager's online help.
*
* @param string $fragment
* The fragment to link to.
*
* @return string
* A URL to Package Manager's online help.
*/
private static function getHelpUrl(string $fragment): string {
return Url::fromRoute('help.page', ['name' => 'package_manager'])
->setOption('fragment', $fragment)
->toString();
}
}

View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\Component\FileSystem\FileSystem;
use Drupal\Component\Utility\Bytes;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates that there is enough free disk space to do stage operations.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class DiskSpaceValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait;
use StringTranslationTrait;
public function __construct(private readonly PathLocator $pathLocator) {
}
/**
* Wrapper around the disk_free_space() function.
*
* @param string $path
* The path for which to retrieve the amount of free disk space.
*
* @return float
* The number of bytes of free space on the disk.
*
* @throws \RuntimeException
* If the amount of free space could not be determined.
*/
protected function freeSpace(string $path): float {
$free_space = disk_free_space($path);
if ($free_space === FALSE) {
throw new \RuntimeException("Cannot get disk information for $path.");
}
return $free_space;
}
/**
* Wrapper around the stat() function.
*
* @param string $path
* The path to check.
*
* @return mixed[]
* The statistics for the path.
*
* @throws \RuntimeException
* If the statistics could not be determined.
*/
protected function stat(string $path): array {
$stat = stat($path);
if ($stat === FALSE) {
throw new \RuntimeException("Cannot get information for $path.");
}
return $stat;
}
/**
* Checks if two paths are located on the same logical disk.
*
* @param string $root
* The path of the project root.
* @param string $vendor
* The path of the vendor directory.
*
* @return bool
* TRUE if the project root and vendor directory are on the same logical
* disk, FALSE otherwise.
*/
protected function areSameLogicalDisk(string $root, string $vendor): bool {
$root_statistics = $this->stat($root);
$vendor_statistics = $this->stat($vendor);
return $root_statistics['dev'] === $vendor_statistics['dev'];
}
/**
* Validates that there is enough free disk space to do stage operations.
*/
public function validate(SandboxValidationEvent $event): void {
$root_path = $this->pathLocator->getProjectRoot();
$vendor_path = $this->pathLocator->getVendorDirectory();
$messages = [];
// @todo Make this configurable or set to a different value in
// https://www.drupal.org/i/3166416.
$minimum_mb = 1024;
$minimum_bytes = Bytes::toNumber($minimum_mb . 'M');
if (!$this->areSameLogicalDisk($root_path, $vendor_path)) {
if ($this->freeSpace($root_path) < $minimum_bytes) {
$messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [
'@root' => $root_path,
'@space' => $minimum_mb,
]);
}
if (is_dir($vendor_path) && $this->freeSpace($vendor_path) < $minimum_bytes) {
$messages[] = $this->t('Vendor filesystem "@vendor" has insufficient space. There must be at least @space megabytes free.', [
'@vendor' => $vendor_path,
'@space' => $minimum_mb,
]);
}
}
elseif ($this->freeSpace($root_path) < $minimum_bytes) {
$messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [
'@root' => $root_path,
'@space' => $minimum_mb,
]);
}
$temp = $this->temporaryDirectory();
if ($this->freeSpace($temp) < $minimum_bytes) {
$messages[] = $this->t('Directory "@temp" has insufficient space. There must be at least @space megabytes free.', [
'@temp' => $temp,
'@space' => $minimum_mb,
]);
}
if ($messages) {
$summary = count($messages) > 1
? $this->t("There is not enough disk space to create a stage directory.")
: NULL;
$event->addError($messages, $summary);
}
}
/**
* Returns the path of the system temporary directory.
*
* @return string
* The absolute path of the system temporary directory.
*/
protected function temporaryDirectory(): string {
return FileSystem::getOsTemporaryDirectory();
}
}

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\Extension\ExtensionDiscovery;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates the stage does not have duplicate info.yml not present in active.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class DuplicateInfoFileValidator implements EventSubscriberInterface {
use StringTranslationTrait;
public function __construct(private readonly PathLocator $pathLocator) {
}
/**
* Validates the stage does not have duplicate info.yml not present in active.
*/
public function validate(PreApplyEvent $event): void {
$active_dir = $this->pathLocator->getProjectRoot();
$stage_dir = $event->sandboxManager->getSandboxDirectory();
$active_info_files = $this->findInfoFiles($active_dir);
$stage_info_files = $this->findInfoFiles($stage_dir);
foreach ($stage_info_files as $stage_info_file => $stage_info_count) {
if (isset($active_info_files[$stage_info_file])) {
// Check if stage directory has more info.yml files matching
// $stage_info_file than in the active directory.
if ($stage_info_count > $active_info_files[$stage_info_file]) {
$event->addError([
$this->t('The stage directory has @stage_count instances of @stage_info_file as compared to @active_count in the active directory. This likely indicates that a duplicate extension was installed.', [
'@stage_info_file' => $stage_info_file,
'@stage_count' => $stage_info_count,
'@active_count' => $active_info_files[$stage_info_file],
]),
]);
}
}
// Check if stage directory has two or more info.yml files matching
// $stage_info_file which are not in active directory.
elseif ($stage_info_count > 1) {
$event->addError([
$this->t('The stage directory has @stage_count instances of @stage_info_file. This likely indicates that a duplicate extension was installed.', [
'@stage_info_file' => $stage_info_file,
'@stage_count' => $stage_info_count,
]),
]);
}
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreApplyEvent::class => 'validate',
];
}
/**
* Recursively finds info.yml files in a directory.
*
* @param string $dir
* The path of the directory to check.
*
* @return int[]
* Array of count of info.yml files in the directory keyed by file name.
*/
private function findInfoFiles(string $dir): array {
// Use the official extension discovery mechanism, but tweak it, because by
// default it resolves duplicates.
// @see \Drupal\Core\Extension\ExtensionDiscovery::process()
$duplicate_aware_extension_discovery = new class($dir, FALSE, []) extends ExtensionDiscovery {
/**
* {@inheritdoc}
*/
protected function process(array $all_files) {
// Unlike parent implementation: no processing, to retain duplicates.
return $all_files;
}
};
// Scan all 4 extension types, explicitly ignoring tests.
$extension_info_files = array_merge(
array_keys($duplicate_aware_extension_discovery->scan('module', FALSE)),
array_keys($duplicate_aware_extension_discovery->scan('theme', FALSE)),
array_keys($duplicate_aware_extension_discovery->scan('profile', FALSE)),
array_keys($duplicate_aware_extension_discovery->scan('theme_engine', FALSE)),
);
$info_files = [];
foreach ($extension_info_files as $info_file) {
$file_name = basename($info_file);
$info_files[$file_name] = ($info_files[$file_name] ?? 0) + 1;
}
return $info_files;
}
}

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates no enabled Drupal extensions are removed from the stage directory.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class EnabledExtensionsValidator implements EventSubscriberInterface {
use StringTranslationTrait;
public function __construct(
private readonly PathLocator $pathLocator,
private readonly ModuleHandlerInterface $moduleHandler,
private readonly ComposerInspector $composerInspector,
private readonly ThemeHandlerInterface $themeHandler,
) {}
/**
* Validates that no enabled Drupal extensions have been removed.
*
* @param \Drupal\package_manager\Event\PreApplyEvent $event
* The event object.
*/
public function validate(PreApplyEvent $event): void {
$active_packages_list = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot());
$stage_packages_list = $this->composerInspector->getInstalledPackagesList($event->sandboxManager->getSandboxDirectory());
$extensions_list = $this->moduleHandler->getModuleList() + $this->themeHandler->listInfo();
foreach ($extensions_list as $extension) {
$extension_name = $extension->getName();
$package = $active_packages_list->getPackageByDrupalProjectName($extension_name);
if ($package && $stage_packages_list->getPackageByDrupalProjectName($extension_name) === NULL) {
$removed_project_messages[] = $this->t("'@name' @type (provided by <code>@package</code>)", [
'@name' => $extension_name,
'@type' => $extension->getType(),
'@package' => $package->name,
]);
}
}
if (!empty($removed_project_messages)) {
$removed_packages_summary = $this->formatPlural(
count($removed_project_messages),
'The update cannot proceed because the following enabled Drupal extension was removed during the update.',
'The update cannot proceed because the following enabled Drupal extensions were removed during the update.'
);
$event->addError($removed_project_messages, $removed_packages_summary);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreApplyEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\Core\Url;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that the environment has support for Package Manager.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class EnvironmentSupportValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait {
getSubscribedEvents as private getSubscribedEventsFromTrait;
}
use StringTranslationTrait;
/**
* The name of the environment variable to check.
*
* This environment variable, if defined, should be parseable by
* \Drupal\Core\Url::fromUri() and link to an explanation of why Package
* Manager is not supported in the current environment.
*
* @var string
*/
public const VARIABLE_NAME = 'DRUPAL_PACKAGE_MANAGER_NOT_SUPPORTED_HELP_URL';
/**
* Checks that this environment supports Package Manager.
*/
public function validate(SandboxValidationEvent $event): void {
$message = $this->t('Package Manager is not supported by your environment.');
$help_url = getenv(static::VARIABLE_NAME);
if (empty($help_url)) {
return;
}
// If the URL is not parseable, catch the exception that Url::fromUri()
// would generate.
try {
$message = $this->t('<a href=":url">@message</a>', [
':url' => Url::fromUri($help_url)->toString(),
'@message' => $message,
]);
}
catch (\InvalidArgumentException) {
// No need to do anything here. The message just won't be a link.
}
$event->addError([$message]);
// If Package Manager is unsupported, there's no point in doing any more
// validation.
$event->stopPropagation();
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// Set priority to run before BaseRequirementsFulfilledValidator, and even
// before other base requirement validators.
// @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator
return array_map(fn () => ['validate', BaseRequirementsFulfilledValidator::PRIORITY + 1000], static::getSubscribedEventsFromTrait());
}
}

View File

@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PostApplyEvent;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that the active lock file is unchanged during stage operations.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class LockFileValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* The key under which to store the hash of the active lock file.
*
* @var string
*/
private const KEY = 'lock_hash';
/**
* The key-value store.
*
* @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
*/
private readonly KeyValueStoreInterface $keyValue;
public function __construct(
KeyValueFactoryInterface $keyValueFactory,
private readonly PathLocator $pathLocator,
) {
$this->keyValue = $keyValueFactory->get('package_manager');
}
/**
* Returns the XXH64 hash of a file.
*
* This method is a thin wrapper around hash_file() to facilitate testing. On
* failure, hash_file() emits a warning but doesn't throw an exception. In
* tests, however, PHPUnit converts warnings to exceptions, so we need to
* catch those and convert them to the value hash_file() will actually return
* on error, which is FALSE. We could also just call `hash_file` directly and
* use @ to suppress warnings, but those would be unclear and likely to be
* accidentally removed later.
*
* @param string $path
* Path of the file to hash.
*
* @return string|false
* The hash of the given file, or FALSE if the file doesn't exist or cannot
* be hashed.
*/
private function getHash(string $path): string|false {
try {
return @hash_file('xxh64', $path);
}
catch (\Throwable) {
return FALSE;
}
}
/**
* Stores the XXH64 hash of the active lock file.
*
* We store the hash of the lock file itself, rather than its content-hash
* value, which is actually a hash of certain parts of composer.json. Our aim
* is to verify that the actual installed packages have not changed
* unexpectedly; we don't care about the contents of composer.json.
*
* @param \Drupal\package_manager\Event\PreCreateEvent $event
* The event being handled.
*/
public function storeHash(PreCreateEvent $event): void {
$active_lock_file_path = $this->pathLocator->getProjectRoot() . DIRECTORY_SEPARATOR . 'composer.lock';
$hash = $this->getHash($active_lock_file_path);
if ($hash) {
$this->keyValue->set(static::KEY, $hash);
}
else {
$event->addError([
$this->t('The active lock file (@file) does not exist.', [
'@file' => $active_lock_file_path,
]),
]);
}
}
/**
* Checks that the active lock file is unchanged during stage operations.
*
* @param \Drupal\package_manager\Event\SandboxValidationEvent $event
* The event being handled.
*/
public function validate(SandboxValidationEvent $event): void {
$sandbox_manager = $event->sandboxManager;
// If we're going to change the active directory directly, we don't need to
// validate the lock file's consistency, since there is no separate
// sandbox directory to compare against.
if ($sandbox_manager->isDirectWrite()) {
return;
}
// Early return if the stage is not already created.
if ($event instanceof StatusCheckEvent && $sandbox_manager->isAvailable()) {
return;
}
$messages = [];
// Ensure we can get a current hash of the lock file.
$active_lock_file_path = $this->pathLocator->getProjectRoot() . DIRECTORY_SEPARATOR . 'composer.lock';
$active_lock_file_hash = $this->getHash($active_lock_file_path);
if (empty($active_lock_file_hash)) {
$messages[] = $this->t('The active lock file (@file) does not exist.', [
'@file' => $active_lock_file_path,
]);
}
// Ensure we also have a stored hash of the lock file.
$active_lock_file_stored_hash = $this->keyValue->get(static::KEY);
if (empty($active_lock_file_stored_hash)) {
throw new \LogicException('Stored hash key deleted.');
}
// If we have both hashes, ensure they match.
if ($active_lock_file_hash && !hash_equals($active_lock_file_stored_hash, $active_lock_file_hash)) {
$messages[] = $this->t('Unexpected changes were detected in the active lock file (@file), which indicates that other Composer operations were performed since this Package Manager operation started. This can put the code base into an unreliable state and therefore is not allowed.', [
'@file' => $active_lock_file_path,
]);
}
// Don't allow staged changes to be applied if the staged lock file has no
// apparent changes.
if (empty($messages) && $event instanceof PreApplyEvent) {
$staged_lock_file_path = $sandbox_manager->getSandboxDirectory() . DIRECTORY_SEPARATOR . 'composer.lock';
$staged_lock_file_hash = $this->getHash($staged_lock_file_path);
if ($staged_lock_file_hash && hash_equals($active_lock_file_hash, $staged_lock_file_hash)) {
$messages[] = $this->t('There appear to be no pending Composer operations because the active lock file (@active_file) and the staged lock file (@staged_file) are identical.', [
'@active_file' => $active_lock_file_path,
'@staged_file' => $staged_lock_file_path,
]);
}
}
if (!empty($messages)) {
$summary = $this->formatPlural(
count($messages),
'Problem detected in lock file during stage operations.',
'Problems detected in lock file during stage operations.',
);
$event->addError($messages, $summary);
}
}
/**
* Deletes the stored lock file hash.
*/
public function deleteHash(): void {
$this->keyValue->delete(static::KEY);
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => 'storeHash',
PreRequireEvent::class => 'validate',
PreApplyEvent::class => 'validate',
StatusCheckEvent::class => 'validate',
PostApplyEvent::class => 'deleteHash',
];
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that the current site is not part of a multisite.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class MultisiteValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait;
use StringTranslationTrait;
public function __construct(private readonly PathLocator $pathLocator) {
}
/**
* Validates that the current site is not part of a multisite.
*/
public function validate(SandboxValidationEvent $event): void {
if ($this->isMultisite()) {
$event->addError([
$this->t('Drupal multisite is not supported by Package Manager.'),
]);
}
}
/**
* Detects if the current site is part of a multisite.
*
* @return bool
* TRUE if the current site is part of a multisite, otherwise FALSE.
*/
private function isMultisite(): bool {
$web_root = $this->pathLocator->getWebRoot();
if ($web_root) {
$web_root .= '/';
}
$sites_php_path = $this->pathLocator->getProjectRoot() . '/' . $web_root . 'sites/sites.php';
if (!file_exists($sites_php_path)) {
return FALSE;
}
// @see \Drupal\Core\DrupalKernel::findSitePath()
$sites = [];
include $sites_php_path;
// @see example.sites.php
return count(array_unique($sites)) > 1;
}
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates that newly installed packages don't overwrite existing directories.
*
* Whether a new package in the stage directory would overwrite an existing
* directory in the active directory when the operation is applied is determined
* by inspecting the `path` property of the staged package.
*
* Certain packages, such as those with the `metapackage` type, don't have a
* `path` property and are ignored by this validator. The Composer facade at
* https://packages.drupal.org/8 currently uses the `metapackage` type for
* submodules of Drupal projects.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*
* @see https://getcomposer.org/doc/04-schema.md#type
*/
final class OverwriteExistingPackagesValidator implements EventSubscriberInterface {
use StringTranslationTrait;
public function __construct(
private readonly PathLocator $pathLocator,
private readonly ComposerInspector $composerInspector,
) {}
/**
* Validates that new installed packages don't overwrite existing directories.
*
* @param \Drupal\package_manager\Event\PreApplyEvent $event
* The event being handled.
*/
public function validate(PreApplyEvent $event): void {
$active_dir = $this->pathLocator->getProjectRoot();
$stage_dir = $event->sandboxManager->getSandboxDirectory();
$active_packages = $this->composerInspector->getInstalledPackagesList($active_dir);
$new_packages = $this->composerInspector->getInstalledPackagesList($stage_dir)
->getPackagesNotIn($active_packages);
foreach ($new_packages as $package) {
if (empty($package->path)) {
// Packages without a `path` cannot overwrite existing directories.
continue;
}
$relative_path = str_replace($stage_dir, '', $package->path);
if (is_dir($active_dir . DIRECTORY_SEPARATOR . $relative_path)) {
$event->addError([
$this->t('The new package @package will be installed in the directory @path, which already exists but is not managed by Composer.', [
'@package' => $package->name,
'@path' => $relative_path,
]),
]);
}
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreApplyEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Update\UpdateRegistry;
use Drupal\Core\Url;
use Drupal\package_manager\Event\StatusCheckEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates that there are no pending database updates.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class PendingUpdatesValidator implements EventSubscriberInterface {
use StringTranslationTrait;
public function __construct(
private readonly string $appRoot,
private readonly UpdateRegistry $updateRegistry,
) {}
/**
* Validates that there are no pending database updates.
*/
public function validate(SandboxValidationEvent $event): void {
if ($this->updatesExist()) {
$message = $this->t('Some modules have database updates pending. You should run the <a href=":update">database update script</a> immediately.', [
':update' => Url::fromRoute('system.db_update')->toString(),
]);
$event->addError([$message]);
}
}
/**
* Checks if there are any pending update or post-update hooks.
*
* @return bool
* TRUE if there are any pending update or post-update hooks, FALSE
* otherwise.
*/
public function updatesExist(): bool {
require_once $this->appRoot . '/core/includes/install.inc';
require_once $this->appRoot . '/core/includes/update.inc';
drupal_load_updates();
$hook_updates = update_get_update_list();
$post_updates = $this->updateRegistry->getPendingUpdateFunctions();
return $hook_updates || $post_updates;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => 'validate',
StatusCheckEvent::class => 'validate',
PreApplyEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Performs validation if certain PHP extensions are enabled.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class PhpExtensionsValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* Indicates if a particular PHP extension is loaded.
*
* @param string $name
* The name of the PHP extension to check for.
*
* @return bool
* TRUE if the given extension is loaded, FALSE otherwise.
*/
final protected function isExtensionLoaded(string $name): bool {
// If and ONLY if we're currently running a test, allow the list of loaded
// extensions to be overridden by a state variable.
if (self::insideTest()) {
// By default, assume OpenSSL is enabled and Xdebug isn't. This allows us
// to run tests in environments that we might not support in production,
// such as a configured CI environment.
$loaded_extensions = \Drupal::state()
->get('package_manager_loaded_php_extensions', ['openssl']);
return in_array($name, $loaded_extensions, TRUE);
}
return extension_loaded($name);
}
/**
* Flags a warning if Xdebug is enabled.
*
* @param \Drupal\package_manager\Event\StatusCheckEvent $event
* The event object.
*/
public function validateXdebug(StatusCheckEvent $event): void {
if ($this->isExtensionLoaded('xdebug')) {
$event->addWarning([
$this->t('Xdebug is enabled, which may have a negative performance impact on Package Manager and any modules that use it.'),
]);
}
}
/**
* Flags an error if the OpenSSL extension is not installed.
*
* @param \Drupal\package_manager\Event\SandboxValidationEvent $event
* The event object.
*/
public function validateOpenSsl(SandboxValidationEvent $event): void {
if (!$this->isExtensionLoaded('openssl')) {
$message = $this->t('The OpenSSL extension is not enabled, which is a security risk. See <a href=":url">the PHP documentation</a> for information on how to enable this extension.', [
':url' => 'https://www.php.net/manual/en/openssl.installation.php',
]);
$event->addError([$message]);
}
}
/**
* Whether this validator is running inside a test.
*
* @return bool
* TRUE if the validator is running in a test. FALSE otherwise.
*/
private static function insideTest(): bool {
// @see \Drupal\Core\CoreServiceProvider::registerTest()
$in_functional_test = drupal_valid_test_ua();
// @see \Drupal\Core\DependencyInjection\DependencySerializationTrait::__wakeup()
$in_kernel_test = isset($GLOBALS['__PHPUNIT_BOOTSTRAP']);
// @see \Drupal\BuildTests\Framework\BuildTestBase::setUp()
$in_build_test = str_contains(__FILE__, DrupalFilesystem::getOsTemporaryDirectory() . '/build_workspace_');
return $in_functional_test || $in_kernel_test || $in_build_test;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
StatusCheckEvent::class => [
['validateXdebug'],
['validateOpenSsl'],
],
PreCreateEvent::class => ['validateOpenSsl'],
PreApplyEvent::class => ['validateOpenSsl'],
];
}
}

View File

@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates that PHP-TUF is installed and correctly configured.
*
* In both the active and stage directories, this checks for the following
* conditions:
* - The PHP-TUF plugin is installed.
* - The plugin is not explicitly blocked by Composer's `allow-plugins`
* configuration.
* - Composer is aware of at least one repository that has TUF support
* explicitly enabled.
*
* Until it's more real world-tested, TUF protection is bypassed by default.
* Ultimately, though, Package Manager will not treat TUF as optional.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class PhpTufValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* The name of the PHP-TUF Composer integration plugin.
*
* @var string
*/
public const PLUGIN_NAME = 'php-tuf/composer-integration';
public function __construct(
private readonly PathLocator $pathLocator,
private readonly ComposerInspector $composerInspector,
private readonly ModuleHandlerInterface $moduleHandler,
private readonly Settings $settings,
private readonly array $repositories,
) {
assert(Inspector::assertAllStrings($repositories));
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
StatusCheckEvent::class => 'validate',
PreCreateEvent::class => 'validate',
PreRequireEvent::class => 'validate',
PreApplyEvent::class => 'validate',
];
}
/**
* Reacts to a stage event by validating PHP-TUF configuration as needed.
*
* @param \Drupal\package_manager\Event\SandboxValidationEvent $event
* The event object.
*/
public function validate(SandboxValidationEvent $event): void {
$messages = $this->validateTuf($this->pathLocator->getProjectRoot());
if ($messages) {
$event->addError($messages, $this->t('The active directory is not protected by PHP-TUF, which is required to use Package Manager securely.'));
}
$sandbox_manager = $event->sandboxManager;
if ($sandbox_manager->sandboxDirectoryExists()) {
$messages = $this->validateTuf($sandbox_manager->getSandboxDirectory());
if ($messages) {
$event->addError($messages, $this->t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.'));
}
}
}
/**
* Flags messages if PHP-TUF is not installed and configured properly.
*
* @param string $dir
* The directory to examine.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
* The error messages, if any.
*/
private function validateTuf(string $dir): array {
$messages = [];
// This setting will be removed without warning when no longer need.
if ($this->settings->get('package_manager_bypass_tuf', TRUE)) {
return $messages;
}
if ($this->moduleHandler->moduleExists('help')) {
$help_url = Url::fromRoute('help.page', ['name' => 'package_manager'])
->setOption('fragment', 'package-manager-tuf-info')
->toString();
}
// The Composer plugin must be installed.
$installed_packages = $this->composerInspector->getInstalledPackagesList($dir);
if (!isset($installed_packages[static::PLUGIN_NAME])) {
$message = $this->t('The <code>@plugin</code> plugin is not installed.', [
'@plugin' => static::PLUGIN_NAME,
]);
if (isset($help_url)) {
$message = $this->t('@message See <a href=":url">the help page</a> for more information on how to install the plugin.', [
'@message' => $message,
':url' => $help_url,
]);
}
$messages[] = $message;
}
// And it has to be explicitly enabled.
$allowed_plugins = $this->composerInspector->getAllowPluginsConfig($dir);
if ($allowed_plugins !== TRUE && empty($allowed_plugins[static::PLUGIN_NAME])) {
$message = $this->t('The <code>@plugin</code> plugin is not listed as an allowed plugin.', [
'@plugin' => static::PLUGIN_NAME,
]);
if (isset($help_url)) {
$message = $this->t('@message See <a href=":url">the help page</a> for more information on how to configure the plugin.', [
'@message' => $message,
':url' => $help_url,
]);
}
$messages[] = $message;
}
// Confirm that all repositories we're configured to look at have opted into
// TUF protection.
foreach ($this->getRepositoryStatus($dir) as $url => $is_protected) {
if ($is_protected) {
continue;
}
$message = $this->t('TUF is not enabled for the <code>@url</code> repository.', [
'@url' => $url,
]);
if (isset($help_url)) {
$message = $this->t('@message See <a href=":url">the help page</a> for more information on how to set up this repository.', [
'@message' => $message,
':url' => $help_url,
]);
}
$messages[] = $message;
}
return $messages;
}
/**
* Gets the TUF protection status of Composer repositories.
*
* @param string $dir
* The directory in which to run Composer.
*
* @return bool[]
* An array of booleans, keyed by repository URL, indicating whether TUF
* protection is enabled for that repository.
*/
private function getRepositoryStatus(string $dir): array {
$status = [];
$repositories = $this->composerInspector->getConfig('repositories', $dir);
$repositories = Json::decode($repositories);
foreach ($repositories as $repository) {
// Only Composer repositories can have TUF protection.
if ($repository['type'] === 'composer') {
$url = $repository['url'];
$status[$url] = !empty($repository['tuf']);
}
}
return array_intersect_key($status, array_flip($this->repositories));
}
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use PhpTuf\ComposerStager\API\Exception\LogicException;
use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that rsync is available.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class RsyncValidator implements EventSubscriberInterface {
use StringTranslationTrait;
public function __construct(
private readonly ExecutableFinderInterface $executableFinder,
private readonly ModuleHandlerInterface $moduleHandler,
) {}
/**
* Checks that rsync is available.
*
* @param \Drupal\package_manager\Event\SandboxValidationEvent $event
* The event being handled.
*/
public function validate(SandboxValidationEvent $event): void {
// If the we are going to change the active directory directly, we don't
// need rsync.
if ($event->sandboxManager->isDirectWrite()) {
return;
}
try {
$this->executableFinder->find('rsync');
$rsync_found = TRUE;
}
catch (LogicException) {
$rsync_found = FALSE;
}
if ($rsync_found === FALSE) {
$message = $this->t('<code>rsync</code> is not available.');
if ($this->moduleHandler->moduleExists('help')) {
$help_url = Url::fromRoute('help.page')
->setRouteParameter('name', 'package_manager')
->setOption('fragment', 'package-manager-faq-rsync')
->toString();
$message = $this->t('@message See the <a href=":url">Package Manager help</a> for more information on how to resolve this.', [
'@message' => $message,
':url' => $help_url,
]);
}
$event->addError([$message]);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
StatusCheckEvent::class => 'validate',
PreCreateEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Component\Assertion\Inspector;
use Drupal\Core\Extension\Extension;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ThemeExtensionList;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Flags a warning if there are database updates in a staged update.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class SandboxDatabaseUpdatesValidator implements EventSubscriberInterface {
use StringTranslationTrait;
public function __construct(
private readonly PathLocator $pathLocator,
private readonly ModuleExtensionList $moduleList,
private readonly ThemeExtensionList $themeList,
) {}
/**
* Checks that the staged update does not have changes to its install files.
*
* @param \Drupal\package_manager\Event\StatusCheckEvent $event
* The event object.
*/
public function checkForStagedDatabaseUpdates(StatusCheckEvent $event): void {
if (!$event->sandboxManager->sandboxDirectoryExists()) {
return;
}
$stage_dir = $event->sandboxManager->getSandboxDirectory();
$extensions_with_updates = $this->getExtensionsWithDatabaseUpdates($stage_dir);
if ($extensions_with_updates) {
// phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString
$extensions_with_updates = array_map($this->t(...), $extensions_with_updates);
$event->addWarning($extensions_with_updates, $this->t('Database updates have been detected in the following extensions.'));
}
}
/**
* Determines if a staged extension has changed update functions.
*
* @param string $stage_dir
* The path of the stage directory.
* @param \Drupal\Core\Extension\Extension $extension
* The extension to check.
*
* @return bool
* TRUE if the staged copy of the extension has changed update functions
* compared to the active copy, FALSE otherwise.
*
* @todo In https://drupal.org/i/3253828 use a more sophisticated method to
* detect changes in the staged extension. Right now, we just compare hashes
* of the .install and .post_update.php files in both copies of the given
* extension, but this will cause false positives for changes to comments,
* whitespace, or runtime code like requirements checks. It would be
* preferable to use a static analyzer to detect new or changed functions
* that are actually executed during an update. No matter what, this method
* must NEVER cause false negatives, since that could result in code which
* is incompatible with the current database schema being copied to the
* active directory.
*/
public function hasStagedUpdates(string $stage_dir, Extension $extension): bool {
$active_dir = $this->pathLocator->getProjectRoot();
$web_root = $this->pathLocator->getWebRoot();
if ($web_root) {
$active_dir .= DIRECTORY_SEPARATOR . $web_root;
$stage_dir .= DIRECTORY_SEPARATOR . $web_root;
}
$active_functions = $this->getUpdateFunctions($active_dir, $extension);
$staged_functions = $this->getUpdateFunctions($stage_dir, $extension);
return (bool) array_diff($staged_functions, $active_functions);
}
/**
* Returns a list of all update functions for a module.
*
* This method only exists because the API in core that scans for available
* updates can only examine the active (running) code base, but we need to be
* able to scan the staged code base as well to compare it against the active
* one.
*
* @param string $root_dir
* The root directory of the Drupal code base.
* @param \Drupal\Core\Extension\Extension $extension
* The module to check.
*
* @return string[]
* The names of the update functions in the module's .install and
* .post_update.php files.
*/
private function getUpdateFunctions(string $root_dir, Extension $extension): array {
$name = $extension->getName();
$path = implode(DIRECTORY_SEPARATOR, [
$root_dir,
$extension->getPath(),
$name,
]);
$function_names = [];
$patterns = [
'.install' => '/^' . $name . '_update_[0-9]+$/i',
'.post_update.php' => '/^' . $name . '_post_update_.+$/i',
];
foreach ($patterns as $suffix => $pattern) {
$file = $path . $suffix;
if (!file_exists($file)) {
continue;
}
// Parse the file and scan for named functions which match the pattern.
$code = file_get_contents($file);
$tokens = token_get_all($code);
for ($i = 0; $i < count($tokens); $i++) {
$chunk = array_slice($tokens, $i, 3);
if ($this->tokensMatchFunctionNamePattern($chunk, $pattern)) {
$function_names[] = $chunk[2][1];
}
}
}
return $function_names;
}
/**
* Determines if a set of tokens contain a function name matching a pattern.
*
* @param array[] $tokens
* A set of three tokens, part of a stream returned by token_get_all().
* @param string $pattern
* If the tokens declare a named function, a regular expression to test the
* function name against.
*
* @return bool
* TRUE if the given tokens declare a function whose name matches the given
* pattern; FALSE otherwise.
*
* @see token_get_all()
*/
private function tokensMatchFunctionNamePattern(array $tokens, string $pattern): bool {
if (count($tokens) !== 3 || !Inspector::assertAllStrictArrays($tokens)) {
return FALSE;
}
// A named function declaration will always be a T_FUNCTION (the word
// `function`), followed by T_WHITESPACE (or the code would be syntactically
// invalid), followed by a T_STRING (the function name). This will ignore
// anonymous functions, but match class methods (although class methods are
// highly unlikely to match the naming patterns of update hooks).
$names = array_map('token_name', array_column($tokens, 0));
if ($names === ['T_FUNCTION', 'T_WHITESPACE', 'T_STRING']) {
return (bool) preg_match($pattern, $tokens[2][1]);
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
StatusCheckEvent::class => 'checkForStagedDatabaseUpdates',
];
}
/**
* Gets extensions that have database updates in the stage directory.
*
* @param string $stage_dir
* The path of the stage directory.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
* The names of the extensions that have database updates.
*/
public function getExtensionsWithDatabaseUpdates(string $stage_dir): array {
$extensions_with_updates = [];
// Check all installed extensions for database updates.
$lists = [$this->moduleList, $this->themeList];
foreach ($lists as $list) {
foreach ($list->getAllInstalledInfo() as $name => $info) {
if ($this->hasStagedUpdates($stage_dir, $list->get($name))) {
$extensions_with_updates[] = $info['name'];
}
}
}
return $extensions_with_updates;
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates staging root is not a subdirectory of active.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class SandboxDirectoryValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait {
getSubscribedEvents as private getSubscribedEventsFromTrait;
}
use StringTranslationTrait;
public function __construct(private readonly PathLocator $pathLocator) {
}
/**
* Check if staging root is a subdirectory of active.
*/
public function validate(SandboxValidationEvent $event): void {
$project_root = $this->pathLocator->getProjectRoot();
$staging_root = $this->pathLocator->getStagingRoot();
if (str_starts_with($staging_root, $project_root)) {
$message = $this->t("The sandbox directory is a subdirectory of the active directory.");
$event->addError([$message]);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
$events = static::getSubscribedEventsFromTrait();
// We don't need to listen to PreApplyEvent because once the stage directory
// has been created, it's not going to be moved.
unset($events[PreApplyEvent::class]);
return $events;
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that Drupal's settings are valid for Package Manager.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class SettingsValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* Checks that Drupal's settings are valid for Package Manager.
*/
public function validate(SandboxValidationEvent $event): void {
if (Settings::get('update_fetch_with_http_fallback')) {
$event->addError([
$this->t('The <code>update_fetch_with_http_fallback</code> setting must be disabled.'),
]);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => 'validate',
PreApplyEvent::class => 'validate',
StatusCheckEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\PathLocator;
use Drupal\package_manager\ProjectInfo;
use Drupal\package_manager\LegacyVersionUtility;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PreApplyEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates that updated projects are secure and supported.
*
* @internal
* This class is an internal part of the module's update handling and
* should not be used by external code.
*/
final class SupportedReleaseValidator implements EventSubscriberInterface {
use StringTranslationTrait;
public function __construct(
private readonly ComposerInspector $composerInspector,
private readonly PathLocator $pathLocator,
) {}
/**
* Checks if the given version of a project is supported.
*
* Checks if the given version of the given project is in the core update
* system's list of known, secure, installable releases of that project.
* considered a supported release by verifying if the project is found in the
* core update system's list of known, secure, and installable releases.
*
* @param string $name
* The name of the project.
* @param string $semantic_version
* A semantic version number for the project.
*
* @return bool
* TRUE if the given version of the project is supported, otherwise FALSE.
* given version is not supported will return FALSE.
*/
private function isSupportedRelease(string $name, string $semantic_version): bool {
$supported_releases = (new ProjectInfo($name))->getInstallableReleases();
if (!$supported_releases) {
return FALSE;
}
// If this version is found in the list of installable releases, it is
// secured and supported.
if (array_key_exists($semantic_version, $supported_releases)) {
return TRUE;
}
// If the semantic version number wasn't in the list of
// installable releases, convert it to a legacy version number and see
// if the version number is in the list.
$legacy_version = LegacyVersionUtility::convertToLegacyVersion($semantic_version);
if ($legacy_version && array_key_exists($legacy_version, $supported_releases)) {
return TRUE;
}
// Neither the semantic version nor the legacy version are in the list
// of installable releases, so the release isn't supported.
return FALSE;
}
/**
* Checks that the packages are secure and supported.
*
* @param \Drupal\package_manager\Event\PreApplyEvent $event
* The event object.
*/
public function validate(PreApplyEvent $event): void {
$active = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot());
$staged = $this->composerInspector->getInstalledPackagesList($event->sandboxManager->getSandboxDirectory());
$updated_packages = array_merge(
$staged->getPackagesNotIn($active)->getArrayCopy(),
$staged->getPackagesWithDifferentVersionsIn($active)->getArrayCopy()
);
$unknown_packages = [];
$unsupported_packages = [];
foreach ($updated_packages as $package_name => $staged_package) {
// Only packages of the types 'drupal-module' or 'drupal-theme' that
// start with 'drupal/' will have update XML from drupal.org.
if (!in_array($staged_package->type, ['drupal-module', 'drupal-theme'], TRUE)
|| !str_starts_with($package_name, 'drupal/')) {
continue;
}
$project_name = $staged[$package_name]->getProjectName();
if (empty($project_name)) {
$unknown_packages[] = $package_name;
continue;
}
$semantic_version = $staged_package->version;
if (!$this->isSupportedRelease($project_name, $semantic_version)) {
$unsupported_packages[] = $this->t('@project_name (@package_name) @version', [
'@project_name' => $project_name,
'@package_name' => $package_name,
'@version' => $semantic_version,
]);
}
}
if ($unsupported_packages) {
$summary = $this->formatPlural(
count($unsupported_packages),
'Cannot update because the following project version is not in the list of installable releases.',
'Cannot update because the following project versions are not in the list of installable releases.'
);
$event->addError($unsupported_packages, $summary);
}
if ($unknown_packages) {
$event->addError([
$this->formatPlural(
count($unknown_packages),
'Cannot update because the following new or updated Drupal package does not have project information: @unknown_packages',
'Cannot update because the following new or updated Drupal packages do not have project information: @unknown_packages',
[
'@unknown_packages' => implode(', ', $unknown_packages),
],
),
]);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreApplyEvent::class => 'validate',
];
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\PathLocator;
use PhpTuf\ComposerStager\API\Exception\PreconditionException;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use PhpTuf\ComposerStager\API\Path\Factory\PathListFactoryInterface;
use PhpTuf\ComposerStager\API\Precondition\Service\NoUnsupportedLinksExistInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Flags errors if unsupported symbolic links are detected.
*
* @see https://github.com/php-tuf/composer-stager/tree/develop/src/Domain/Service/Precondition#symlinks
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class SymlinkValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait;
public function __construct(
private readonly PathLocator $pathLocator,
private readonly NoUnsupportedLinksExistInterface $precondition,
private readonly PathFactoryInterface $pathFactory,
private readonly PathListFactoryInterface $pathListFactory,
) {}
/**
* Flags errors if the project root or stage directory contain symbolic links.
*/
public function validate(SandboxValidationEvent $event): void {
if ($event instanceof PreRequireEvent) {
// We don't need to check symlinks again during PreRequireEvent; this was
// already just validated during PreCreateEvent.
return;
}
$active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
// The precondition requires us to pass both an active and stage directory,
// so if the stage hasn't been created or claimed yet, use the directory
// that contains this file, which contains only a few files and no symlinks,
// as the stage directory. The precondition itself doesn't care if the
// directory actually exists or not.
$stage_dir = __DIR__;
if ($event->sandboxManager->sandboxDirectoryExists()) {
$stage_dir = $event->sandboxManager->getSandboxDirectory();
}
$stage_dir = $this->pathFactory->create($stage_dir);
// Return early if no excluded paths were collected because this validator
// is dependent on knowing which paths to exclude when searching for
// symlinks.
// @see \Drupal\package_manager\StatusCheckTrait::runStatusCheck()
if ($event->excludedPaths === NULL) {
return;
}
// The list of excluded paths is immutable, but the precondition may need to
// mutate it, so convert it back to a normal, mutable path list.
$exclusions = $this->pathListFactory->create(...$event->excludedPaths->getAll());
try {
$this->precondition->assertIsFulfilled($active_dir, $stage_dir, $exclusions);
}
catch (PreconditionException $e) {
$event->addErrorFromThrowable($e);
}
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Drupal\package_manager\Validator;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that the file system is writable.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class WritableFileSystemValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait;
use StringTranslationTrait;
public function __construct(private readonly PathLocator $pathLocator) {
}
/**
* Checks that the file system is writable.
*
* @todo Determine if 'is_writable()' is a sufficiently robust test across
* different operating systems in https://drupal.org/i/3348253.
*/
public function validate(SandboxValidationEvent $event): void {
$messages = [];
$project_root = $this->pathLocator->getProjectRoot();
// If the web (Drupal) root and project root are different, validate the
// web root separately.
$web_root = $this->pathLocator->getWebRoot();
if ($web_root) {
$drupal_root = $project_root . DIRECTORY_SEPARATOR . $web_root;
if (!is_writable($drupal_root)) {
$messages[] = $this->t('The Drupal directory "@dir" is not writable.', [
'@dir' => $drupal_root,
]);
}
}
if (!is_writable($project_root)) {
$messages[] = $this->t('The project root directory "@dir" is not writable.', [
'@dir' => $project_root,
]);
}
$dir = $this->pathLocator->getVendorDirectory();
if (!is_writable($dir)) {
$messages[] = $this->t('The vendor directory "@dir" is not writable.', ['@dir' => $dir]);
}
// During pre-apply don't check whether the staging root is writable.
if ($event instanceof PreApplyEvent) {
if ($messages) {
$event->addError($messages, $this->t('The file system is not writable.'));
}
return;
}
// Ensure the staging root is writable. If it doesn't exist, ensure we will
// be able to create it.
$dir = $this->pathLocator->getStagingRoot();
if (!file_exists($dir)) {
$dir = dirname($dir);
if (!is_writable($dir)) {
$messages[] = $this->t('The stage root directory will not able to be created at "@dir".', [
'@dir' => $dir,
]);
}
}
elseif (!is_writable($dir)) {
$messages[] = $this->t('The stage root directory "@dir" is not writable.', [
'@dir' => $dir,
]);
}
if ($messages) {
$event->addError($messages, $this->t('The file system is not writable.'));
}
}
}