SandboxManagerBase.php

Namespace

Drupal\package_manager

File

core/modules/package_manager/src/SandboxManagerBase.php

View source
<?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
   */
  protected final const TEMPSTORE_LOCK_KEY = 'lock';
  
  /**
   * The tempstore key under which to store arbitrary metadata for this stage.
   *
   * @var string
   */
  protected final 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.
   */
  public final 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.
   */
  public final 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()
   */
  public final 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.
   */
  protected final 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()
   */
  public final 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.
   */
  public final 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);
  }

}

Classes

Title Deprecated Summary
SandboxManagerBase Creates and manages a stage directory in which to install or update code.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.