c1851112b834ada5e6936b31290622df41b46ae8
[yaffs-website] / vendor / cweagans / composer-patches / src / Patches.php
1 <?php
2
3 /**
4  * @file
5  * Provides a way to patch Composer packages after installation.
6  */
7
8 namespace cweagans\Composer;
9
10 use Composer\Composer;
11 use Composer\DependencyResolver\Operation\InstallOperation;
12 use Composer\DependencyResolver\Operation\UninstallOperation;
13 use Composer\DependencyResolver\Operation\UpdateOperation;
14 use Composer\DependencyResolver\Operation\OperationInterface;
15 use Composer\EventDispatcher\EventSubscriberInterface;
16 use Composer\IO\IOInterface;
17 use Composer\Package\AliasPackage;
18 use Composer\Package\PackageInterface;
19 use Composer\Plugin\PluginInterface;
20 use Composer\Installer\PackageEvents;
21 use Composer\Script\Event;
22 use Composer\Script\ScriptEvents;
23 use Composer\Installer\PackageEvent;
24 use Composer\Util\ProcessExecutor;
25 use Composer\Util\RemoteFilesystem;
26 use Symfony\Component\Process\Process;
27
28 class Patches implements PluginInterface, EventSubscriberInterface {
29
30   /**
31    * @var Composer $composer
32    */
33   protected $composer;
34   /**
35    * @var IOInterface $io
36    */
37   protected $io;
38   /**
39    * @var EventDispatcher $eventDispatcher
40    */
41   protected $eventDispatcher;
42   /**
43    * @var ProcessExecutor $executor
44    */
45   protected $executor;
46   /**
47    * @var array $patches
48    */
49   protected $patches;
50
51   /**
52    * Apply plugin modifications to composer
53    *
54    * @param Composer    $composer
55    * @param IOInterface $io
56    */
57   public function activate(Composer $composer, IOInterface $io) {
58     $this->composer = $composer;
59     $this->io = $io;
60     $this->eventDispatcher = $composer->getEventDispatcher();
61     $this->executor = new ProcessExecutor($this->io);
62     $this->patches = array();
63     $this->installedPatches = array();
64   }
65
66   /**
67    * Returns an array of event names this subscriber wants to listen to.
68    */
69   public static function getSubscribedEvents() {
70     return array(
71       ScriptEvents::PRE_INSTALL_CMD => array('checkPatches'),
72       ScriptEvents::PRE_UPDATE_CMD => array('checkPatches'),
73       PackageEvents::PRE_PACKAGE_INSTALL => array('gatherPatches'),
74       PackageEvents::PRE_PACKAGE_UPDATE => array('gatherPatches'),
75       // The following is a higher weight for compatibility with
76       // https://github.com/AydinHassan/magento-core-composer-installer and more generally for compatibility with
77       // every Composer plugin which deploys downloaded packages to other locations.
78       // In such cases you want that those plugins deploy patched files so they have to run after
79       // the "composer-patches" plugin.
80       // @see: https://github.com/cweagans/composer-patches/pull/153
81       PackageEvents::POST_PACKAGE_INSTALL => array('postInstall', 10),
82       PackageEvents::POST_PACKAGE_UPDATE => array('postInstall', 10),
83     );
84   }
85
86   /**
87    * Before running composer install,
88    * @param Event $event
89    */
90   public function checkPatches(Event $event) {
91     if (!$this->isPatchingEnabled()) {
92       return;
93     }
94
95     try {
96       $repositoryManager = $this->composer->getRepositoryManager();
97       $localRepository = $repositoryManager->getLocalRepository();
98       $installationManager = $this->composer->getInstallationManager();
99       $packages = $localRepository->getPackages();
100
101       $tmp_patches = $this->grabPatches();
102       foreach ($packages as $package) {
103         $extra = $package->getExtra();
104         if (isset($extra['patches'])) {
105           $this->installedPatches[$package->getName()] = $extra['patches'];
106         }
107         $patches = isset($extra['patches']) ? $extra['patches'] : array();
108         $tmp_patches = array_merge_recursive($tmp_patches, $patches);
109       }
110
111       if ($tmp_patches == FALSE) {
112         $this->io->write('<info>No patches supplied.</info>');
113         return;
114       }
115
116       // Remove packages for which the patch set has changed.
117       foreach ($packages as $package) {
118         if (!($package instanceof AliasPackage)) {
119           $package_name = $package->getName();
120           $extra = $package->getExtra();
121           $has_patches = isset($tmp_patches[$package_name]);
122           $has_applied_patches = isset($extra['patches_applied']);
123           if (($has_patches && !$has_applied_patches)
124             || (!$has_patches && $has_applied_patches)
125             || ($has_patches && $has_applied_patches && $tmp_patches[$package_name] !== $extra['patches_applied'])) {
126             $uninstallOperation = new UninstallOperation($package, 'Removing package so it can be re-installed and re-patched.');
127             $this->io->write('<info>Removing package ' . $package_name . ' so that it can be re-installed and re-patched.</info>');
128             $installationManager->uninstall($localRepository, $uninstallOperation);
129           }
130         }
131       }
132     }
133       // If the Locker isn't available, then we don't need to do this.
134       // It's the first time packages have been installed.
135     catch (\LogicException $e) {
136       return;
137     }
138   }
139
140   /**
141    * Gather patches from dependencies and store them for later use.
142    *
143    * @param PackageEvent $event
144    */
145   public function gatherPatches(PackageEvent $event) {
146     // If we've already done this, then don't do it again.
147     if (isset($this->patches['_patchesGathered'])) {
148       $this->io->write('<info>Patches already gathered. Skipping</info>', TRUE, IOInterface::VERBOSE);
149       return;
150     }
151     // If patching has been disabled, bail out here.
152     elseif (!$this->isPatchingEnabled()) {
153       $this->io->write('<info>Patching is disabled. Skipping.</info>', TRUE, IOInterface::VERBOSE);
154       return;
155     }
156
157     $this->patches = $this->grabPatches();
158     if (empty($this->patches)) {
159       $this->io->write('<info>No patches supplied.</info>');
160     }
161
162     $extra = $this->composer->getPackage()->getExtra();
163     $patches_ignore = isset($extra['patches-ignore']) ? $extra['patches-ignore'] : array();
164
165     // Now add all the patches from dependencies that will be installed.
166     $operations = $event->getOperations();
167     $this->io->write('<info>Gathering patches for dependencies. This might take a minute.</info>');
168     foreach ($operations as $operation) {
169       if ($operation->getJobType() == 'install' || $operation->getJobType() == 'update') {
170         $package = $this->getPackageFromOperation($operation);
171         $extra = $package->getExtra();
172         if (isset($extra['patches'])) {
173           if (isset($patches_ignore[$package->getName()])) {
174             foreach ($patches_ignore[$package->getName()] as $package_name => $patches) {
175               if (isset($extra['patches'][$package_name])) {
176                 $extra['patches'][$package_name] = array_diff($extra['patches'][$package_name], $patches);
177               }
178             }
179           }
180           $this->patches = $this->arrayMergeRecursiveDistinct($this->patches, $extra['patches']);
181         }
182         // Unset installed patches for this package
183         if(isset($this->installedPatches[$package->getName()])) {
184           unset($this->installedPatches[$package->getName()]);
185         }
186       }
187     }
188
189     // Merge installed patches from dependencies that did not receive an update.
190     foreach ($this->installedPatches as $patches) {
191       $this->patches = array_merge_recursive($this->patches, $patches);
192     }
193
194     // If we're in verbose mode, list the projects we're going to patch.
195     if ($this->io->isVerbose()) {
196       foreach ($this->patches as $package => $patches) {
197         $number = count($patches);
198         $this->io->write('<info>Found ' . $number . ' patches for ' . $package . '.</info>');
199       }
200     }
201
202     // Make sure we don't gather patches again. Extra keys in $this->patches
203     // won't hurt anything, so we'll just stash it there.
204     $this->patches['_patchesGathered'] = TRUE;
205   }
206
207   /**
208    * Get the patches from root composer or external file
209    * @return Patches
210    * @throws \Exception
211    */
212   public function grabPatches() {
213       // First, try to get the patches from the root composer.json.
214     $extra = $this->composer->getPackage()->getExtra();
215     if (isset($extra['patches'])) {
216       $this->io->write('<info>Gathering patches for root package.</info>');
217       $patches = $extra['patches'];
218       return $patches;
219     }
220     // If it's not specified there, look for a patches-file definition.
221     elseif (isset($extra['patches-file'])) {
222       $this->io->write('<info>Gathering patches from patch file.</info>');
223       $patches = file_get_contents($extra['patches-file']);
224       $patches = json_decode($patches, TRUE);
225       $error = json_last_error();
226       if ($error != 0) {
227         switch ($error) {
228           case JSON_ERROR_DEPTH:
229             $msg = ' - Maximum stack depth exceeded';
230             break;
231           case JSON_ERROR_STATE_MISMATCH:
232             $msg =  ' - Underflow or the modes mismatch';
233             break;
234           case JSON_ERROR_CTRL_CHAR:
235             $msg = ' - Unexpected control character found';
236             break;
237           case JSON_ERROR_SYNTAX:
238             $msg =  ' - Syntax error, malformed JSON';
239             break;
240           case JSON_ERROR_UTF8:
241             $msg =  ' - Malformed UTF-8 characters, possibly incorrectly encoded';
242             break;
243           default:
244             $msg =  ' - Unknown error';
245             break;
246           }
247           throw new \Exception('There was an error in the supplied patches file:' . $msg);
248         }
249       if (isset($patches['patches'])) {
250         $patches = $patches['patches'];
251         return $patches;
252       }
253       elseif(!$patches) {
254         throw new \Exception('There was an error in the supplied patch file');
255       }
256     }
257     else {
258       return array();
259     }
260   }
261
262   /**
263    * @param PackageEvent $event
264    * @throws \Exception
265    */
266   public function postInstall(PackageEvent $event) {
267
268     // Check if we should exit in failure.
269     $extra = $this->composer->getPackage()->getExtra();
270     $exitOnFailure = getenv('COMPOSER_EXIT_ON_PATCH_FAILURE') || !empty($extra['composer-exit-on-patch-failure']);
271
272     // Get the package object for the current operation.
273     $operation = $event->getOperation();
274     /** @var PackageInterface $package */
275     $package = $this->getPackageFromOperation($operation);
276     $package_name = $package->getName();
277
278     if (!isset($this->patches[$package_name])) {
279       if ($this->io->isVerbose()) {
280         $this->io->write('<info>No patches found for ' . $package_name . '.</info>');
281       }
282       return;
283     }
284     $this->io->write('  - Applying patches for <info>' . $package_name . '</info>');
285
286     // Get the install path from the package object.
287     $manager = $event->getComposer()->getInstallationManager();
288     $install_path = $manager->getInstaller($package->getType())->getInstallPath($package);
289
290     // Set up a downloader.
291     $downloader = new RemoteFilesystem($this->io, $this->composer->getConfig());
292
293     // Track applied patches in the package info in installed.json
294     $localRepository = $this->composer->getRepositoryManager()->getLocalRepository();
295     $localPackage = $localRepository->findPackage($package_name, $package->getVersion());
296     $extra = $localPackage->getExtra();
297     $extra['patches_applied'] = array();
298
299     foreach ($this->patches[$package_name] as $description => $url) {
300       $this->io->write('    <info>' . $url . '</info> (<comment>' . $description. '</comment>)');
301       try {
302         $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::PRE_PATCH_APPLY, $package, $url, $description));
303         $this->getAndApplyPatch($downloader, $install_path, $url);
304         $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::POST_PATCH_APPLY, $package, $url, $description));
305         $extra['patches_applied'][$description] = $url;
306       }
307       catch (\Exception $e) {
308         $this->io->write('   <error>Could not apply patch! Skipping. The error was: ' . $e->getMessage() . '</error>');
309         if ($exitOnFailure) {
310           throw new \Exception("Cannot apply patch $description ($url)!");
311         }
312       }
313     }
314     $localPackage->setExtra($extra);
315
316     $this->io->write('');
317     $this->writePatchReport($this->patches[$package_name], $install_path);
318   }
319
320   /**
321    * Get a Package object from an OperationInterface object.
322    *
323    * @param OperationInterface $operation
324    * @return PackageInterface
325    * @throws \Exception
326    */
327   protected function getPackageFromOperation(OperationInterface $operation) {
328     if ($operation instanceof InstallOperation) {
329       $package = $operation->getPackage();
330     }
331     elseif ($operation instanceof UpdateOperation) {
332       $package = $operation->getTargetPackage();
333     }
334     else {
335       throw new \Exception('Unknown operation: ' . get_class($operation));
336     }
337
338     return $package;
339   }
340
341   /**
342    * Apply a patch on code in the specified directory.
343    *
344    * @param RemoteFilesystem $downloader
345    * @param $install_path
346    * @param $patch_url
347    * @throws \Exception
348    */
349   protected function getAndApplyPatch(RemoteFilesystem $downloader, $install_path, $patch_url) {
350
351     // Local patch file.
352     if (file_exists($patch_url)) {
353       $filename = realpath($patch_url);
354     }
355     else {
356       // Generate random (but not cryptographically so) filename.
357       $filename = uniqid(sys_get_temp_dir().'/') . ".patch";
358
359       // Download file from remote filesystem to this location.
360       $hostname = parse_url($patch_url, PHP_URL_HOST);
361       $downloader->copy($hostname, $patch_url, $filename, FALSE);
362     }
363
364     // The order here is intentional. p1 is most likely to apply with git apply.
365     // p0 is next likely. p2 is extremely unlikely, but for some special cases,
366     // it might be useful. p4 is useful for Magento 2 patches
367     $patch_levels = array('-p1', '-p0', '-p2', '-p4');
368
369     // Attempt to apply with git apply.
370     $patched = $this->applyPatchWithGit($install_path, $patch_levels, $filename);
371
372     // In some rare cases, git will fail to apply a patch, fallback to using
373     // the 'patch' command.
374     if (!$patched) {
375       foreach ($patch_levels as $patch_level) {
376         // --no-backup-if-mismatch here is a hack that fixes some
377         // differences between how patch works on windows and unix.
378         if ($patched = $this->executeCommand("patch %s --no-backup-if-mismatch -d %s < %s", $patch_level, $install_path, $filename)) {
379           break;
380         }
381       }
382     }
383
384     // Clean up the temporary patch file.
385     if (isset($hostname)) {
386       unlink($filename);
387     }
388     // If the patch *still* isn't applied, then give up and throw an Exception.
389     // Otherwise, let the user know it worked.
390     if (!$patched) {
391       throw new \Exception("Cannot apply patch $patch_url");
392     }
393   }
394
395   /**
396    * Checks if the root package enables patching.
397    *
398    * @return bool
399    *   Whether patching is enabled. Defaults to TRUE.
400    */
401   protected function isPatchingEnabled() {
402     $extra = $this->composer->getPackage()->getExtra();
403
404     if (empty($extra['patches']) && empty($extra['patches-ignore']) && !isset($extra['patches-file'])) {
405       // The root package has no patches of its own, so only allow patching if
406       // it has specifically opted in.
407       return isset($extra['enable-patching']) ? $extra['enable-patching'] : FALSE;
408     }
409     else {
410       return TRUE;
411     }
412   }
413
414   /**
415    * Writes a patch report to the target directory.
416    *
417    * @param array $patches
418    * @param string $directory
419    */
420   protected function writePatchReport($patches, $directory) {
421     $output = "This file was automatically generated by Composer Patches (https://github.com/cweagans/composer-patches)\n";
422     $output .= "Patches applied to this directory:\n\n";
423     foreach ($patches as $description => $url) {
424       $output .= $description . "\n";
425       $output .= 'Source: ' . $url . "\n\n\n";
426     }
427     file_put_contents($directory . "/PATCHES.txt", $output);
428   }
429
430   /**
431    * Executes a shell command with escaping.
432    *
433    * @param string $cmd
434    * @return bool
435    */
436   protected function executeCommand($cmd) {
437     // Shell-escape all arguments except the command.
438     $args = func_get_args();
439     foreach ($args as $index => $arg) {
440       if ($index !== 0) {
441         $args[$index] = escapeshellarg($arg);
442       }
443     }
444
445     // And replace the arguments.
446     $command = call_user_func_array('sprintf', $args);
447     $output = '';
448     if ($this->io->isVerbose()) {
449       $this->io->write('<comment>' . $command . '</comment>');
450       $io = $this->io;
451       $output = function ($type, $data) use ($io) {
452         if ($type == Process::ERR) {
453           $io->write('<error>' . $data . '</error>');
454         }
455         else {
456           $io->write('<comment>' . $data . '</comment>');
457         }
458       };
459     }
460     return ($this->executor->execute($command, $output) == 0);
461   }
462
463   /**
464    * Recursively merge arrays without changing data types of values.
465    *
466    * Does not change the data types of the values in the arrays. Matching keys'
467    * values in the second array overwrite those in the first array, as is the
468    * case with array_merge.
469    *
470    * @param array $array1
471    *   The first array.
472    * @param array $array2
473    *   The second array.
474    * @return array
475    *   The merged array.
476    *
477    * @see http://php.net/manual/en/function.array-merge-recursive.php#92195
478    */
479   protected function arrayMergeRecursiveDistinct(array $array1, array $array2) {
480     $merged = $array1;
481
482     foreach ($array2 as $key => &$value) {
483       if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
484         $merged[$key] = $this->arrayMergeRecursiveDistinct($merged[$key], $value);
485       }
486       else {
487         $merged[$key] = $value;
488       }
489     }
490
491     return $merged;
492   }
493
494   /**
495    * Attempts to apply a patch with git apply.
496    *
497    * @param $install_path
498    * @param $patch_levels
499    * @param $filename
500    *
501    * @return bool
502    *   TRUE if patch was applied, FALSE otherwise.
503    */
504   protected function applyPatchWithGit($install_path, $patch_levels, $filename) {
505     // Do not use git apply unless the install path is itself a git repo
506     // @see https://stackoverflow.com/a/27283285
507     if (!is_dir($install_path . '/.git')) {
508       return FALSE;
509     }
510
511     $patched = FALSE;
512     foreach ($patch_levels as $patch_level) {
513       if ($this->io->isVerbose()) {
514         $comment = 'Testing ability to patch with git apply.';
515         $comment .= ' This command may produce errors that can be safely ignored.';
516         $this->io->write('<comment>' . $comment . '</comment>');
517       }
518       $checked = $this->executeCommand('git -C %s apply --check -v %s %s', $install_path, $patch_level, $filename);
519       $output = $this->executor->getErrorOutput();
520       if (substr($output, 0, 7) == 'Skipped') {
521         // Git will indicate success but silently skip patches in some scenarios.
522         //
523         // @see https://github.com/cweagans/composer-patches/pull/165
524         $checked = FALSE;
525       }
526       if ($checked) {
527         // Apply the first successful style.
528         $patched = $this->executeCommand('git -C %s apply %s %s', $install_path, $patch_level, $filename);
529         break;
530       }
531     }
532     return $patched;
533   }
534
535 }