diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:30:08 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:30:08 +0000 |
commit | 4ce65d59ca91871cfd126497158200a818720bce (patch) | |
tree | e277def01fc7eba7dbc21c4a4ae5576e8aa2cf1f /vendor/cweagans/composer-patches/src/Patches.php | |
parent | Initial commit. (diff) | |
download | icinga-php-library-upstream/0.13.1.tar.xz icinga-php-library-upstream/0.13.1.zip |
Adding upstream version 0.13.1.upstream/0.13.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'vendor/cweagans/composer-patches/src/Patches.php')
-rw-r--r-- | vendor/cweagans/composer-patches/src/Patches.php | 599 |
1 files changed, 599 insertions, 0 deletions
diff --git a/vendor/cweagans/composer-patches/src/Patches.php b/vendor/cweagans/composer-patches/src/Patches.php new file mode 100644 index 0000000..3d7d864 --- /dev/null +++ b/vendor/cweagans/composer-patches/src/Patches.php @@ -0,0 +1,599 @@ +<?php + +/** + * @file + * Provides a way to patch Composer packages after installation. + */ + +namespace cweagans\Composer; + +use Composer\Composer; +use Composer\DependencyResolver\Operation\InstallOperation; +use Composer\DependencyResolver\Operation\UninstallOperation; +use Composer\DependencyResolver\Operation\UpdateOperation; +use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\IO\IOInterface; +use Composer\Package\AliasPackage; +use Composer\Package\PackageInterface; +use Composer\Plugin\PluginInterface; +use Composer\Installer\PackageEvents; +use Composer\Script\Event; +use Composer\Script\ScriptEvents; +use Composer\Installer\PackageEvent; +use Composer\Util\ProcessExecutor; +use Composer\Util\RemoteFilesystem; +use Symfony\Component\Process\Process; + +class Patches implements PluginInterface, EventSubscriberInterface { + + /** + * @var Composer $composer + */ + protected $composer; + /** + * @var IOInterface $io + */ + protected $io; + /** + * @var EventDispatcher $eventDispatcher + */ + protected $eventDispatcher; + /** + * @var ProcessExecutor $executor + */ + protected $executor; + /** + * @var array $patches + */ + protected $patches; + + /** + * @var array $installedPatches + */ + protected $installedPatches; + + /** + * Apply plugin modifications to composer + * + * @param Composer $composer + * @param IOInterface $io + */ + public function activate(Composer $composer, IOInterface $io) { + $this->composer = $composer; + $this->io = $io; + $this->eventDispatcher = $composer->getEventDispatcher(); + $this->executor = new ProcessExecutor($this->io); + $this->patches = array(); + $this->installedPatches = array(); + } + + /** + * Returns an array of event names this subscriber wants to listen to. + */ + public static function getSubscribedEvents() { + return array( + ScriptEvents::PRE_INSTALL_CMD => array('checkPatches'), + ScriptEvents::PRE_UPDATE_CMD => array('checkPatches'), + PackageEvents::PRE_PACKAGE_INSTALL => array('gatherPatches'), + PackageEvents::PRE_PACKAGE_UPDATE => array('gatherPatches'), + // The following is a higher weight for compatibility with + // https://github.com/AydinHassan/magento-core-composer-installer and more generally for compatibility with + // every Composer plugin which deploys downloaded packages to other locations. + // In such cases you want that those plugins deploy patched files so they have to run after + // the "composer-patches" plugin. + // @see: https://github.com/cweagans/composer-patches/pull/153 + PackageEvents::POST_PACKAGE_INSTALL => array('postInstall', 10), + PackageEvents::POST_PACKAGE_UPDATE => array('postInstall', 10), + ); + } + + /** + * Before running composer install, + * @param Event $event + */ + public function checkPatches(Event $event) { + if (!$this->isPatchingEnabled()) { + return; + } + + try { + $repositoryManager = $this->composer->getRepositoryManager(); + $localRepository = $repositoryManager->getLocalRepository(); + $installationManager = $this->composer->getInstallationManager(); + $packages = $localRepository->getPackages(); + + $extra = $this->composer->getPackage()->getExtra(); + $patches_ignore = isset($extra['patches-ignore']) ? $extra['patches-ignore'] : array(); + + $tmp_patches = $this->grabPatches(); + foreach ($packages as $package) { + $extra = $package->getExtra(); + if (isset($extra['patches'])) { + if (isset($patches_ignore[$package->getName()])) { + foreach ($patches_ignore[$package->getName()] as $package_name => $patches) { + if (isset($extra['patches'][$package_name])) { + $extra['patches'][$package_name] = array_diff($extra['patches'][$package_name], $patches); + } + } + } + $this->installedPatches[$package->getName()] = $extra['patches']; + } + $patches = isset($extra['patches']) ? $extra['patches'] : array(); + $tmp_patches = $this->arrayMergeRecursiveDistinct($tmp_patches, $patches); + } + + if ($tmp_patches == FALSE) { + $this->io->write('<info>No patches supplied.</info>'); + return; + } + + // Remove packages for which the patch set has changed. + $promises = array(); + foreach ($packages as $package) { + if (!($package instanceof AliasPackage)) { + $package_name = $package->getName(); + $extra = $package->getExtra(); + $has_patches = isset($tmp_patches[$package_name]); + $has_applied_patches = isset($extra['patches_applied']) && count($extra['patches_applied']) > 0; + if (($has_patches && !$has_applied_patches) + || (!$has_patches && $has_applied_patches) + || ($has_patches && $has_applied_patches && $tmp_patches[$package_name] !== $extra['patches_applied'])) { + $uninstallOperation = new UninstallOperation($package, 'Removing package so it can be re-installed and re-patched.'); + $this->io->write('<info>Removing package ' . $package_name . ' so that it can be re-installed and re-patched.</info>'); + $promises[] = $installationManager->uninstall($localRepository, $uninstallOperation); + } + } + } + $promises = array_filter($promises); + if ($promises) { + $this->composer->getLoop()->wait($promises); + } + } + // If the Locker isn't available, then we don't need to do this. + // It's the first time packages have been installed. + catch (\LogicException $e) { + return; + } + } + + /** + * Gather patches from dependencies and store them for later use. + * + * @param PackageEvent $event + */ + public function gatherPatches(PackageEvent $event) { + // If we've already done this, then don't do it again. + if (isset($this->patches['_patchesGathered'])) { + $this->io->write('<info>Patches already gathered. Skipping</info>', TRUE, IOInterface::VERBOSE); + return; + } + // If patching has been disabled, bail out here. + elseif (!$this->isPatchingEnabled()) { + $this->io->write('<info>Patching is disabled. Skipping.</info>', TRUE, IOInterface::VERBOSE); + return; + } + + $this->patches = $this->grabPatches(); + if (empty($this->patches)) { + $this->io->write('<info>No patches supplied.</info>'); + } + + $extra = $this->composer->getPackage()->getExtra(); + $patches_ignore = isset($extra['patches-ignore']) ? $extra['patches-ignore'] : array(); + + // Now add all the patches from dependencies that will be installed. + $operations = $event->getOperations(); + $this->io->write('<info>Gathering patches for dependencies. This might take a minute.</info>'); + foreach ($operations as $operation) { + if ($operation instanceof InstallOperation || $operation instanceof UpdateOperation) { + $package = $this->getPackageFromOperation($operation); + $extra = $package->getExtra(); + if (isset($extra['patches'])) { + if (isset($patches_ignore[$package->getName()])) { + foreach ($patches_ignore[$package->getName()] as $package_name => $patches) { + if (isset($extra['patches'][$package_name])) { + $extra['patches'][$package_name] = array_diff($extra['patches'][$package_name], $patches); + } + } + } + $this->patches = $this->arrayMergeRecursiveDistinct($this->patches, $extra['patches']); + } + // Unset installed patches for this package + if(isset($this->installedPatches[$package->getName()])) { + unset($this->installedPatches[$package->getName()]); + } + } + } + + // Merge installed patches from dependencies that did not receive an update. + foreach ($this->installedPatches as $patches) { + $this->patches = $this->arrayMergeRecursiveDistinct($this->patches, $patches); + } + + // If we're in verbose mode, list the projects we're going to patch. + if ($this->io->isVerbose()) { + foreach ($this->patches as $package => $patches) { + $number = count($patches); + $this->io->write('<info>Found ' . $number . ' patches for ' . $package . '.</info>'); + } + } + + // Make sure we don't gather patches again. Extra keys in $this->patches + // won't hurt anything, so we'll just stash it there. + $this->patches['_patchesGathered'] = TRUE; + } + + /** + * Get the patches from root composer or external file + * @return Patches + * @throws \Exception + */ + public function grabPatches() { + // First, try to get the patches from the root composer.json. + $extra = $this->composer->getPackage()->getExtra(); + if (isset($extra['patches'])) { + $this->io->write('<info>Gathering patches for root package.</info>'); + $patches = $extra['patches']; + return $patches; + } + // If it's not specified there, look for a patches-file definition. + elseif (isset($extra['patches-file'])) { + $this->io->write('<info>Gathering patches from patch file.</info>'); + $patches = file_get_contents($extra['patches-file']); + $patches = json_decode($patches, TRUE); + $error = json_last_error(); + if ($error != 0) { + switch ($error) { + case JSON_ERROR_DEPTH: + $msg = ' - Maximum stack depth exceeded'; + break; + case JSON_ERROR_STATE_MISMATCH: + $msg = ' - Underflow or the modes mismatch'; + break; + case JSON_ERROR_CTRL_CHAR: + $msg = ' - Unexpected control character found'; + break; + case JSON_ERROR_SYNTAX: + $msg = ' - Syntax error, malformed JSON'; + break; + case JSON_ERROR_UTF8: + $msg = ' - Malformed UTF-8 characters, possibly incorrectly encoded'; + break; + default: + $msg = ' - Unknown error'; + break; + } + throw new \Exception('There was an error in the supplied patches file:' . $msg); + } + if (isset($patches['patches'])) { + $patches = $patches['patches']; + return $patches; + } + elseif(!$patches) { + throw new \Exception('There was an error in the supplied patch file'); + } + } + else { + return array(); + } + } + + /** + * @param PackageEvent $event + * @throws \Exception + */ + public function postInstall(PackageEvent $event) { + + // Check if we should exit in failure. + $extra = $this->composer->getPackage()->getExtra(); + $exitOnFailure = getenv('COMPOSER_EXIT_ON_PATCH_FAILURE') || !empty($extra['composer-exit-on-patch-failure']); + $skipReporting = getenv('COMPOSER_PATCHES_SKIP_REPORTING') || !empty($extra['composer-patches-skip-reporting']); + + // Get the package object for the current operation. + $operation = $event->getOperation(); + /** @var PackageInterface $package */ + $package = $this->getPackageFromOperation($operation); + $package_name = $package->getName(); + + if (!isset($this->patches[$package_name])) { + if ($this->io->isVerbose()) { + $this->io->write('<info>No patches found for ' . $package_name . '.</info>'); + } + return; + } + $this->io->write(' - Applying patches for <info>' . $package_name . '</info>'); + + // Get the install path from the package object. + $manager = $event->getComposer()->getInstallationManager(); + $install_path = $manager->getInstaller($package->getType())->getInstallPath($package); + + // Set up a downloader. + $downloader = new RemoteFilesystem($this->io, $this->composer->getConfig()); + + // Track applied patches in the package info in installed.json + $localRepository = $this->composer->getRepositoryManager()->getLocalRepository(); + $localPackage = $localRepository->findPackage($package_name, $package->getVersion()); + $extra = $localPackage->getExtra(); + $extra['patches_applied'] = array(); + + foreach ($this->patches[$package_name] as $description => $url) { + $this->io->write(' <info>' . $url . '</info> (<comment>' . $description. '</comment>)'); + try { + $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::PRE_PATCH_APPLY, $package, $url, $description)); + $this->getAndApplyPatch($downloader, $install_path, $url, $package); + $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::POST_PATCH_APPLY, $package, $url, $description)); + $extra['patches_applied'][$description] = $url; + } + catch (\Exception $e) { + $this->io->write(' <error>Could not apply patch! Skipping. The error was: ' . $e->getMessage() . '</error>'); + if ($exitOnFailure) { + throw new \Exception("Cannot apply patch $description ($url)!"); + } + } + } + $localPackage->setExtra($extra); + + $this->io->write(''); + + if (true !== $skipReporting) { + $this->writePatchReport($this->patches[$package_name], $install_path); + } + } + + /** + * Get a Package object from an OperationInterface object. + * + * @param OperationInterface $operation + * @return PackageInterface + * @throws \Exception + */ + protected function getPackageFromOperation(OperationInterface $operation) { + if ($operation instanceof InstallOperation) { + $package = $operation->getPackage(); + } + elseif ($operation instanceof UpdateOperation) { + $package = $operation->getTargetPackage(); + } + else { + throw new \Exception('Unknown operation: ' . get_class($operation)); + } + + return $package; + } + + /** + * Apply a patch on code in the specified directory. + * + * @param RemoteFilesystem $downloader + * @param $install_path + * @param $patch_url + * @param PackageInterface $package + * @throws \Exception + */ + protected function getAndApplyPatch(RemoteFilesystem $downloader, $install_path, $patch_url, PackageInterface $package) { + + // Local patch file. + if (file_exists($patch_url)) { + $filename = realpath($patch_url); + } + else { + // Generate random (but not cryptographically so) filename. + $filename = uniqid(sys_get_temp_dir().'/') . ".patch"; + + // Download file from remote filesystem to this location. + $hostname = parse_url($patch_url, PHP_URL_HOST); + + try { + $downloader->copy($hostname, $patch_url, $filename, false); + } catch (\Exception $e) { + // In case of an exception, retry once as the download might + // have failed due to intermittent network issues. + $downloader->copy($hostname, $patch_url, $filename, false); + } + } + + // The order here is intentional. p1 is most likely to apply with git apply. + // p0 is next likely. p2 is extremely unlikely, but for some special cases, + // it might be useful. p4 is useful for Magento 2 patches + $patch_levels = array('-p1', '-p0', '-p2', '-p4'); + + // Check for specified patch level for this package. + $extra = $this->composer->getPackage()->getExtra(); + if (!empty($extra['patchLevel'][$package->getName()])){ + $patch_levels = array($extra['patchLevel'][$package->getName()]); + } + // Attempt to apply with git apply. + $patched = $this->applyPatchWithGit($install_path, $patch_levels, $filename); + + // In some rare cases, git will fail to apply a patch, fallback to using + // the 'patch' command. + if (!$patched) { + foreach ($patch_levels as $patch_level) { + // --no-backup-if-mismatch here is a hack that fixes some + // differences between how patch works on windows and unix. + if ($patched = $this->executeCommand("patch %s --no-backup-if-mismatch -d %s < %s", $patch_level, $install_path, $filename)) { + break; + } + } + } + + // Clean up the temporary patch file. + if (isset($hostname)) { + unlink($filename); + } + // If the patch *still* isn't applied, then give up and throw an Exception. + // Otherwise, let the user know it worked. + if (!$patched) { + throw new \Exception("Cannot apply patch $patch_url"); + } + } + + /** + * Checks if the root package enables patching. + * + * @return bool + * Whether patching is enabled. Defaults to TRUE. + */ + protected function isPatchingEnabled() { + $extra = $this->composer->getPackage()->getExtra(); + + if (empty($extra['patches']) && empty($extra['patches-ignore']) && !isset($extra['patches-file'])) { + // The root package has no patches of its own, so only allow patching if + // it has specifically opted in. + return isset($extra['enable-patching']) ? $extra['enable-patching'] : FALSE; + } + else { + return TRUE; + } + } + + /** + * Writes a patch report to the target directory. + * + * @param array $patches + * @param string $directory + */ + protected function writePatchReport($patches, $directory) { + $output = "This file was automatically generated by Composer Patches (https://github.com/cweagans/composer-patches)\n"; + $output .= "Patches applied to this directory:\n\n"; + foreach ($patches as $description => $url) { + $output .= $description . "\n"; + $output .= 'Source: ' . $url . "\n\n\n"; + } + file_put_contents($directory . "/PATCHES.txt", $output); + } + + /** + * Executes a shell command with escaping. + * + * @param string $cmd + * @return bool + */ + protected function executeCommand($cmd) { + // Shell-escape all arguments except the command. + $args = func_get_args(); + foreach ($args as $index => $arg) { + if ($index !== 0) { + $args[$index] = escapeshellarg($arg); + } + } + + // And replace the arguments. + $command = call_user_func_array('sprintf', $args); + $output = ''; + if ($this->io->isVerbose()) { + $this->io->write('<comment>' . $command . '</comment>'); + $io = $this->io; + $output = function ($type, $data) use ($io) { + if ($type == Process::ERR) { + $io->write('<error>' . $data . '</error>'); + } + else { + $io->write('<comment>' . $data . '</comment>'); + } + }; + } + return ($this->executor->execute($command, $output) == 0); + } + + /** + * Recursively merge arrays without changing data types of values. + * + * Does not change the data types of the values in the arrays. Matching keys' + * values in the second array overwrite those in the first array, as is the + * case with array_merge. + * + * @param array $array1 + * The first array. + * @param array $array2 + * The second array. + * @return array + * The merged array. + * + * @see http://php.net/manual/en/function.array-merge-recursive.php#92195 + */ + protected function arrayMergeRecursiveDistinct(array $array1, array $array2) { + $merged = $array1; + + foreach ($array2 as $key => &$value) { + if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) { + $merged[$key] = $this->arrayMergeRecursiveDistinct($merged[$key], $value); + } + else { + $merged[$key] = $value; + } + } + + return $merged; + } + + /** + * Attempts to apply a patch with git apply. + * + * @param $install_path + * @param $patch_levels + * @param $filename + * + * @return bool + * TRUE if patch was applied, FALSE otherwise. + */ + protected function applyPatchWithGit($install_path, $patch_levels, $filename) { + // Do not use git apply unless the install path is itself a git repo + // @see https://stackoverflow.com/a/27283285 + if (!is_dir($install_path . '/.git')) { + return FALSE; + } + + $patched = FALSE; + foreach ($patch_levels as $patch_level) { + if ($this->io->isVerbose()) { + $comment = 'Testing ability to patch with git apply.'; + $comment .= ' This command may produce errors that can be safely ignored.'; + $this->io->write('<comment>' . $comment . '</comment>'); + } + $checked = $this->executeCommand('git -C %s apply --check -v %s %s', $install_path, $patch_level, $filename); + $output = $this->executor->getErrorOutput(); + if (substr($output, 0, 7) == 'Skipped') { + // Git will indicate success but silently skip patches in some scenarios. + // + // @see https://github.com/cweagans/composer-patches/pull/165 + $checked = FALSE; + } + if ($checked) { + // Apply the first successful style. + $patched = $this->executeCommand('git -C %s apply %s %s', $install_path, $patch_level, $filename); + break; + } + } + return $patched; + } + + /** + * Indicates if a package has been patched. + * + * @param \Composer\Package\PackageInterface $package + * The package to check. + * + * @return bool + * TRUE if the package has been patched. + */ + public static function isPackagePatched(PackageInterface $package) { + return array_key_exists('patches_applied', $package->getExtra()); + } + + /** + * {@inheritDoc} + */ + public function deactivate(Composer $composer, IOInterface $io) + { + } + + /** + * {@inheritDoc} + */ + public function uninstall(Composer $composer, IOInterface $io) + { + } + +} |