From ecf66176498e46d90f3932b9fe73b014e646d579 Mon Sep 17 00:00:00 2001 From: nevvermind Date: Mon, 11 May 2015 13:44:15 +0100 Subject: [PATCH 1/3] splitting patch strategy (sh and patch files) --- src/Inviqa/Downloader/Composer.php | 16 --- src/Inviqa/Patch/DotPatch.php | 36 +++++ src/Inviqa/Patch/Factory.php | 26 ++++ src/Inviqa/Patch/Patch.php | 203 +++++++++++++++++++++++++++++ src/Inviqa/Patch/Shell.php | 18 +++ src/Inviqa/Patcher.php | 61 ++------- 6 files changed, 294 insertions(+), 66 deletions(-) delete mode 100644 src/Inviqa/Downloader/Composer.php create mode 100644 src/Inviqa/Patch/DotPatch.php create mode 100644 src/Inviqa/Patch/Factory.php create mode 100644 src/Inviqa/Patch/Patch.php create mode 100644 src/Inviqa/Patch/Shell.php diff --git a/src/Inviqa/Downloader/Composer.php b/src/Inviqa/Downloader/Composer.php deleted file mode 100644 index d8ee0fc..0000000 --- a/src/Inviqa/Downloader/Composer.php +++ /dev/null @@ -1,16 +0,0 @@ -getPatchTemporaryPath()); + $process = new Process("patch -p 1 < $patchPath"); + $process->mustRun(); + return $process->getExitCode() === 0; + } + + protected function canApply() + { + $patchPath = ProcessUtils::escapeArgument($this->getPatchTemporaryPath()); + $process = new Process("patch --dry-run -p 1 < $patchPath"); + try { + $process->mustRun(); + return $process->getExitCode() === 0; + } catch (\Exception $e) { + return false; + } + } +} diff --git a/src/Inviqa/Patch/Factory.php b/src/Inviqa/Patch/Factory.php new file mode 100644 index 0000000..d411107 --- /dev/null +++ b/src/Inviqa/Patch/Factory.php @@ -0,0 +1,26 @@ +setName($name); + $this->setGroup($group); + + if (!empty($details['url'])) { + $this->setUrl($details['url']); + } + } + + /** + * @return boolean|null + * @throws \Exception + */ + public final function apply() + { + $namespace = $this->getNamespace(); + if ($this->canApply()) { + $res = (bool) $this->doApply(); + + if ($res) { + $this->getOutput()->writeln("Patch $namespace successfully applied."); + } else { + $this->getOutput()->writeln("Patch $namespace was not applied."); + } + + return $res; + } + $this->getOutput()->writeln("Patch $namespace skipped. Patch was already applied?"); + return null; + } + + /** + * @return string + */ + public function getNamespace() + { + return $this->getGroup() . '/' . $this->getName(); + } + + /** + * @return string + */ + public function getGroup() + { + return $this->group; + } + + /** + * @param string $group + */ + protected function setGroup($group) + { + $this->group = $group; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name + */ + protected function setName($name) + { + $this->name = $name; + } + + /** + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * @param string $title + */ + protected function setTitle($title) + { + $this->title = $title; + } + + /** + * @return string + */ + public function getDescription() + { + return $this->description; + } + + /** + * @param string $description + */ + protected function setDescription($description) + { + $this->description = $description; + } + + /** + * @return string + */ + public function getUrl() + { + return $this->url; + } + + /** + * @param string $url + */ + protected function setUrl($url) + { + $this->url = $url; + } + + /** + * @return string + * @throws \Exception + */ + protected function getPatchTemporaryPath() + { + if (is_null($this->tempPatchFilePath)) { + $this->getOutput()->writeln("Fetching patch {$this->getNamespace()}"); + + if (!$this->getUrl()) { + return $this->tempPatchFilePath = ''; + } + + if (!$patch = file_get_contents($this->getUrl())) { + throw new \Exception("Could not get contents from {$this->getUrl()}"); + } + + $patchFilePath = sprintf("%s/%s_%s.patch", sys_get_temp_dir(), $this->getGroup(), $this->getName()); + file_put_contents($patchFilePath, $patch); + + $this->tempPatchFilePath = $patchFilePath; + } + + return $this->tempPatchFilePath; + } + + /** + * @return Output + */ + public function getOutput() + { + if (!$this->output) { + $this->output = new ConsoleOutput(); + } + return $this->output; + } + + /** + * @param Output $output + */ + public function setOutput(Output $output) + { + $this->output = $output; + } +} diff --git a/src/Inviqa/Patch/Shell.php b/src/Inviqa/Patch/Shell.php new file mode 100644 index 0000000..15b19fe --- /dev/null +++ b/src/Inviqa/Patch/Shell.php @@ -0,0 +1,18 @@ +output = new ConsoleOutput(); $this->event = $event; - $this->fetchPatches(); - $this->applyPatches(); - } - - private function fetchPatches() - { - $downloader = new composerDownloader(); $extra = $this->event->getComposer()->getPackage()->getExtra(); - foreach ($extra['patches'] as $patchGroupName => $patchGroup) { - foreach ($patchGroup as $patchName => $patchInfo) { - $patchNamespace = $patchGroupName . '/' . $patchName; - $this->output->writeln("Fetching patch $patchNamespace"); - $patchContent = $downloader->getContents($patchInfo['url'], $patchGroupName . '_' . $patchName); - $this->patchFiles[$patchNamespace] = $patchContent; - } - } - } - - private function applyPatches() - { - $this->output->writeln("Applying patches..."); - - foreach ($this->patchFiles as $patchNamespace => $filesToPatch) { - if (!$this->canApplyPatch($filesToPatch)) { - $this->output->writeln('Patch skipped. Patch was already applied?'); - continue; - } - - $process = new Process("patch -p 1 < " . ProcessUtils::escapeArgument($filesToPatch)); - try { - $process->mustRun(); - $this->output->writeln("Patch $patchNamespace successfully applied."); - } catch (\Exception $e) { - $this->output->getErrorOutput()->writeln("Error applying patch $patchNamespace:"); - $this->output->getErrorOutput()->writeln("{$e->getMessage()}"); + foreach ($patchGroup as $patchName => $patchDetails) { + $patch = Factory::create($patchName, $patchGroupName, $patchDetails); + $patch->setOutput($this->output); + $this->applyPatch($patch); } } } - /** - * @param $filesToPatch - * @return bool - */ - private function canApplyPatch($filesToPatch) + private function applyPatch(Patch $patch) { - $process = new Process("patch --dry-run -p 1 < " . ProcessUtils::escapeArgument($filesToPatch)); try { - $process->mustRun(); - return $process->getExitCode() === 0; - } catch (ProcessFailedException $e) { - return false; + $patch->apply(); + } catch (\Exception $e) { + $this->output->writeln("Error applying patch {$patch->getNamespace()}:"); + $this->output->writeln("{$e->getMessage()}"); } } } From 43757e08602757cbe7638a5a596e928feb98537a Mon Sep 17 00:00:00 2001 From: nevvermind Date: Tue, 12 May 2015 10:30:07 +0100 Subject: [PATCH 2/3] patch using shell scripts --- README.md | 28 ++++++++++++----- src/Inviqa/EnvChecker.php | 57 ++++++++++++++++++++++++++++++++++ src/Inviqa/Patch/Factory.php | 5 +-- src/Inviqa/Patch/Patch.php | 48 +++++++++++++++++++++++++++-- src/Inviqa/Patch/Shell.php | 59 +++++++++++++++++++++++++++++++++++- src/Inviqa/Patcher.php | 40 +++++++++++++++++++++--- 6 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 src/Inviqa/EnvChecker.php diff --git a/README.md b/README.md index 400803d..6437e8b 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,11 @@ The patching is idempotent as much as the `patch` tool is, meaning patches will a) Patches need to be declared in the `extra` config area of Composer (root package only): ```json "extra": { + "magento-root-dir": "public", "patches": { "patch-group-1": { "patch-name-1": { + "type": "patch", "title": "Allow composer autoloader to be applied to Mage.php", "url": "https://url/to/file1.patch" } @@ -19,10 +21,26 @@ a) Patches need to be declared in the `extra` config area of Composer (root pack "title": "Fixes Windows 8.1", "url": "https://url/to/file2.patch" } - } + }, + "shell-patch-group-1": { + "magento-shell-patch-name-1": { + "type": "shell", + "title": "Magento security fix", + "url": "https://url/to/magento/shell/patch.sh" + } + } } } ``` + +There are two types of patches: +- type **"patch"** - generic patch/diff files, applied using the patch tool; +- type **"shell"** - official Magento shell patches, which are able to apply and/or revert themselves and are self-contained. +If no type is declared, **"patch"** is assumed. If you have such a patch type declared, you **must** set the **"magento-root-dir"** +extra config, pointing to the Mage root folder. + +"Shell" patches will be copied in the Mage root (set by the **"magento-root-dir"** extra config), triggered, then removed. + A patch's _group_ and _name_ will create its ID, used internally (i.e. `patch-group-1/patch-name-1`), so make sure you follow these 2 rules: - `patch-group-1` MUST be unique in the `patches` object literal - `patch-name-1` MUST be unique in its patch _group_ @@ -33,12 +51,8 @@ Examples of patch names: "CVS-1", "composer-autoloader". b) Additional scripts callbacks need to be added for automatic patching on `install` or `update` (root package only): ```json "scripts": { - "post-install-cmd": [ - "Inviqa\\Command::patch" - ], - "post-update-cmd": [ - "Inviqa\\Command::patch" - ] + "post-install-cmd": "Inviqa\\Command::patch", + "post-update-cmd": "Inviqa\\Command::patch" } ``` You can use whatever [Composer *Command* event](https://getcomposer.org/doc/articles/scripts.md#event-names) you want, diff --git a/src/Inviqa/EnvChecker.php b/src/Inviqa/EnvChecker.php new file mode 100644 index 0000000..0ce97e3 --- /dev/null +++ b/src/Inviqa/EnvChecker.php @@ -0,0 +1,57 @@ +event = $event; + } + + public function check() + { + $this->clientDeclaredMageRootOnShellPatches(); + } + + private function clientDeclaredMageRootOnShellPatches() + { + $extra = $this->event->getComposer()->getPackage()->getExtra(); + + if (empty($extra['patches'])) { + return; + } + + $containsShellPatches = false; + + foreach ($extra['patches'] as $patchGroupName => $patchGroup) { + foreach ($patchGroup as $patchName => $patchDetails) { + $patch = Factory::create( + $patchName, + $patchGroupName, + $patchDetails, + array() + ); + + if ($patch instanceof Shell) { + $containsShellPatches = true; + break 2; + } + } + } + + if ($containsShellPatches && empty($extra[Patcher::EXTRA_KEY_MAGE_ROOT_DIR])) { + throw new \Exception( + 'When using shell patches, you must declare the Mage root using the extra key: ' . + Patcher::EXTRA_KEY_MAGE_ROOT_DIR + ); + } + } +} diff --git a/src/Inviqa/Patch/Factory.php b/src/Inviqa/Patch/Factory.php index d411107..4b57a30 100644 --- a/src/Inviqa/Patch/Factory.php +++ b/src/Inviqa/Patch/Factory.php @@ -8,9 +8,10 @@ class Factory * @param string $patchName * @param string $patchGroup * @param array $patchDetails + * @param array $composerExtra * @return Patch */ - public static function create($patchName, $patchGroup, array $patchDetails) + public static function create($patchName, $patchGroup, array $patchDetails, array $composerExtra) { if (empty($patchDetails['type'])) { $patchDetails['type'] = DotPatch::TYPE; @@ -19,7 +20,7 @@ public static function create($patchName, $patchGroup, array $patchDetails) $type = $patchDetails['type'] === DotPatch::TYPE ? 'DotPatch' : 'Shell'; $patchClass = '\\Inviqa\\Patch\\' . $type; - $patch = new $patchClass($patchName, $patchGroup, $patchDetails); + $patch = new $patchClass($patchName, $patchGroup, $patchDetails, $composerExtra); return $patch; } diff --git a/src/Inviqa/Patch/Patch.php b/src/Inviqa/Patch/Patch.php index ad2c78a..0f8a51e 100644 --- a/src/Inviqa/Patch/Patch.php +++ b/src/Inviqa/Patch/Patch.php @@ -26,6 +26,11 @@ abstract class Patch private $log; + /** + * @var array + */ + private $composerExtra = array(); + /** * @return boolean */ @@ -36,10 +41,11 @@ abstract protected function doApply(); */ abstract protected function canApply(); - public function __construct($name, $group, array $details) + public function __construct($name, $group, array $details, array $composerExtra) { $this->setName($name); $this->setGroup($group); + $this->setComposerExtra($composerExtra); if (!empty($details['url'])) { $this->setUrl($details['url']); @@ -54,6 +60,7 @@ public final function apply() { $namespace = $this->getNamespace(); if ($this->canApply()) { + $this->beforeApply(); $res = (bool) $this->doApply(); if ($res) { @@ -62,12 +69,20 @@ public final function apply() $this->getOutput()->writeln("Patch $namespace was not applied."); } + $this->afterApply($res); + return $res; } $this->getOutput()->writeln("Patch $namespace skipped. Patch was already applied?"); return null; } + protected function beforeApply() + {} + + protected function afterApply($patchingWasSuccessful) + {} + /** * @return string */ @@ -173,8 +188,10 @@ protected function getPatchTemporaryPath() throw new \Exception("Could not get contents from {$this->getUrl()}"); } - $patchFilePath = sprintf("%s/%s_%s.patch", sys_get_temp_dir(), $this->getGroup(), $this->getName()); - file_put_contents($patchFilePath, $patch); + $patchFilePath = $this->getPatchTempAbsolutePath(); + if (!file_put_contents($patchFilePath, $patch)) { + throw new \Exception("Could not save patch content to $patchFilePath"); + } $this->tempPatchFilePath = $patchFilePath; } @@ -182,6 +199,15 @@ protected function getPatchTemporaryPath() return $this->tempPatchFilePath; } + /** + * @return string + */ + private function getPatchTempAbsolutePath() + { + // digest unsafe characters + return sys_get_temp_dir() . '/mage_patch_' . md5($this->getGroup() . $this->getName()) . '.tmp'; + } + /** * @return Output */ @@ -200,4 +226,20 @@ public function setOutput(Output $output) { $this->output = $output; } + + /** + * @return Output + */ + public function getComposerExtra() + { + return $this->composerExtra; + } + + /** + * @param array $composerExtra + */ + private function setComposerExtra(array $composerExtra) + { + $this->composerExtra = $composerExtra; + } } diff --git a/src/Inviqa/Patch/Shell.php b/src/Inviqa/Patch/Shell.php index 15b19fe..8e029d4 100644 --- a/src/Inviqa/Patch/Shell.php +++ b/src/Inviqa/Patch/Shell.php @@ -2,17 +2,74 @@ namespace Inviqa\Patch; +use Inviqa\Patcher; +use Symfony\Component\Process\Process; +use Symfony\Component\Process\ProcessUtils; +use Symfony\Component\Process\Exception\ProcessFailedException; + class Shell extends Patch { const TYPE = 'shell'; + const SHELL_SCRIPT_TMP_NAME = 'mage_shell_patch.sh'; + + private $shellScriptTmpPath; + + /** + * @throws ProcessFailedException + * @return boolean + */ protected function doApply() { - return true; + $patchPath = ProcessUtils::escapeArgument($this->shellScriptTmpPath); + $process = new Process("sh $patchPath"); + $process->mustRun(); + return $process->getExitCode() === 0; } + /** + * Official Magento patches check if they can be applied beforehand, + * so no need to check it ourselves. + * + * @return bool + */ protected function canApply() { return true; } + + /** + * Magento "sh" patch-scripts need to be in the Mage root when applying. + * + * @throws \Exception + */ + protected function beforeApply() + { + $tempPath = $this->getPatchTemporaryPath(); + $extra = $this->getComposerExtra(); + + $mageDir = $extra[Patcher::EXTRA_KEY_MAGE_ROOT_DIR]; + + $destinationFilePath = realpath("./$mageDir") . '/' . self::SHELL_SCRIPT_TMP_NAME; + + if (!@rename($tempPath, $destinationFilePath)) { + throw new \Exception("Could not move form $tempPath to $destinationFilePath"); + } + + if ($this->getOutput()->isDebug()) { + $this->getOutput()->writeln("Shell script moved from $tempPath to $destinationFilePath"); + } + + $this->shellScriptTmpPath = $destinationFilePath; + } + + protected function afterApply($patchWasOk) + { + if (file_exists($this->shellScriptTmpPath)) { + if ($this->getOutput()->isDebug()) { + $this->getOutput()->writeln("Deleting {$this->shellScriptTmpPath}"); + } + @unlink($this->shellScriptTmpPath); + } + } } diff --git a/src/Inviqa/Patcher.php b/src/Inviqa/Patcher.php index bdf4b5a..d8c3e2a 100644 --- a/src/Inviqa/Patcher.php +++ b/src/Inviqa/Patcher.php @@ -5,9 +5,12 @@ use Inviqa\Patch\Factory; use Inviqa\Patch\Patch; use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; class Patcher { + const EXTRA_KEY_MAGE_ROOT_DIR = 'magento-root-dir'; + /** @var ConsoleOutput */ private $output; @@ -16,19 +19,48 @@ class Patcher public function patch(\Composer\Script\Event $event) { - $this->output = new ConsoleOutput(); - $this->event = $event; + $this->init($event); + + $extraTmp = $extra = $this->event->getComposer()->getPackage()->getExtra(); + + if (empty($extra['patches'])) { + $this->output->writeln('No Magento patches were found'); + } + + // don't pass the patch information + unset($extraTmp['patches']); - $extra = $this->event->getComposer()->getPackage()->getExtra(); foreach ($extra['patches'] as $patchGroupName => $patchGroup) { foreach ($patchGroup as $patchName => $patchDetails) { - $patch = Factory::create($patchName, $patchGroupName, $patchDetails); + $patch = Factory::create( + $patchName, + $patchGroupName, + $patchDetails, + $extraTmp + ); $patch->setOutput($this->output); $this->applyPatch($patch); } } } + /** + * @param \Composer\Script\Event $event + */ + private function init(\Composer\Script\Event $event) + { + $this->output = new ConsoleOutput(); + + if ($event->getIo()->isDebug()) { + $this->output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); + } + + $this->event = $event; + + $checker = new EnvChecker($event); + $checker->check(); + } + private function applyPatch(Patch $patch) { try { From 4005354b46aeee48cc8368c387094788c5ba79b3 Mon Sep 17 00:00:00 2001 From: nevvermind Date: Tue, 12 May 2015 10:34:45 +0100 Subject: [PATCH 3/3] change doc --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6437e8b..077c688 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ a) Patches need to be declared in the `extra` config area of Composer (root pack There are two types of patches: - type **"patch"** - generic patch/diff files, applied using the patch tool; - type **"shell"** - official Magento shell patches, which are able to apply and/or revert themselves and are self-contained. + If no type is declared, **"patch"** is assumed. If you have such a patch type declared, you **must** set the **"magento-root-dir"** -extra config, pointing to the Mage root folder. +extra config, pointing to the Mage root folder, or else it will fail with an error. "Shell" patches will be copied in the Mage root (set by the **"magento-root-dir"** extra config), triggered, then removed.