diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 0000000..1f3f372 --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,15 @@ +name: E2E Test + +on: + push: + branches: + - 'main' + workflow_dispatch: + +jobs: + e2etest: + runs-on: ubuntu-latest + steps: + - name: Trigger E2E Test + run: | + curl -XPOST -u "sitepark-bot:${{ secrets.BOT_PAT }}" -H "Accept: application/vnd.github.everest-preview+json" -H "Content-Type: application/json" https://api.github.com/repos/sitepark/atoolo-e2e-test/actions/workflows/e2e-test.yml/dispatches --data '{"ref": "main"}' diff --git a/.gitignore b/.gitignore index 8ac6c87..9761240 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ !var/cache/.gitkeep /var/log/* !var/log/.gitkeep +/var/test/* /tools .phpactor.json diff --git a/composer.json b/composer.json index 5675a03..fd16556 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,9 @@ ], "require": { "php": ">=8.1 <8.4.0", - "composer-plugin-api": "^2.0" + "composer-plugin-api": "^2.1", + "symfony/filesystem": "^6.4 || ^7.0", + "ext-posix": "*" }, "require-dev": { "composer/composer": "^2.0", @@ -56,7 +58,19 @@ "sort-packages": true }, "extra": { - "class": "Atoolo\\Runtime\\Composer\\ComposerPlugin" + "class": "Atoolo\\Runtime\\Composer\\ComposerPlugin", + "branch-alias" : { + "dev-main" : "1.x-dev" + }, + "atoolo" : { + "runtime" : { + "executor" : [ + "Atoolo\\Runtime\\Executor\\IniSetter", + "Atoolo\\Runtime\\Executor\\UmaskSetter", + "Atoolo\\Runtime\\Executor\\UserValidator" + ] + } + } }, "scripts": { "post-install-cmd": "phive --no-progress install --force-accept-unsigned --trust-gpg-keys C00543248C87FB13,4AA394086372C20A,CF1A108D0E7AE720,51C67305FFC2E5C0", diff --git a/composer.lock b/composer.lock index 7e234a0..61762e9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,234 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "45a5d0542078d92ad375a4091f15cfe6", - "packages": [], + "content-hash": "539ad7ffbedb22ed5b6bb50ae7e315f6", + "packages": [ + { + "name": "symfony/filesystem", + "version": "v7.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "802e87002f919296c9f606457d9fa327a0b3d6b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/802e87002f919296c9f606457d9fa327a0b3d6b2", + "reference": "802e87002f919296c9f606457d9fa327a0b3d6b2", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-05-31T14:57:53+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.29.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4", + "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-29T20:11:03+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.29.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec", + "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-29T20:11:03+00:00" + } + ], "packages-dev": [ { "name": "colinodell/json5", @@ -176,16 +402,16 @@ }, { "name": "composer/class-map-generator", - "version": "1.3.3", + "version": "1.3.4", "source": { "type": "git", "url": "https://github.com/composer/class-map-generator.git", - "reference": "61804f9973685ec7bead0fb7fe022825e3cd418e" + "reference": "b1b3fd0b4eaf3ddf3ee230bc340bf3fff454a1a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/61804f9973685ec7bead0fb7fe022825e3cd418e", - "reference": "61804f9973685ec7bead0fb7fe022825e3cd418e", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/b1b3fd0b4eaf3ddf3ee230bc340bf3fff454a1a3", + "reference": "b1b3fd0b4eaf3ddf3ee230bc340bf3fff454a1a3", "shasum": "" }, "require": { @@ -229,7 +455,7 @@ ], "support": { "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.3.3" + "source": "https://github.com/composer/class-map-generator/tree/1.3.4" }, "funding": [ { @@ -245,7 +471,7 @@ "type": "tidelift" } ], - "time": "2024-06-10T11:53:54+00:00" + "time": "2024-06-12T14:13:04+00:00" }, { "name": "composer/composer", @@ -1247,16 +1473,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.11.1", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", + "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", "shasum": "" }, "require": { @@ -1264,11 +1490,12 @@ }, "conflict": { "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, "require-dev": { "doctrine/collections": "^1.6.8", "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", @@ -1294,7 +1521,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0" }, "funding": [ { @@ -1302,7 +1529,7 @@ "type": "tidelift" } ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2024-06-12T14:39:25+00:00" }, { "name": "nikic/php-parser", @@ -2222,12 +2449,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "cac81dc38cb1ea099552433245d0790b6e172211" + "reference": "2e530fa2b00d046ceb5660f3139af583170ea7f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/cac81dc38cb1ea099552433245d0790b6e172211", - "reference": "cac81dc38cb1ea099552433245d0790b6e172211", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/2e530fa2b00d046ceb5660f3139af583170ea7f9", + "reference": "2e530fa2b00d046ceb5660f3139af583170ea7f9", "shasum": "" }, "conflict": { @@ -2547,7 +2774,7 @@ "lms/routes": "<2.1.1", "localizationteam/l10nmgr": "<7.4|>=8,<8.7|>=9,<9.2", "luyadev/yii-helpers": "<1.2.1", - "magento/community-edition": "<2.4.3.0-patch3|>=2.4.4,<2.4.5", + "magento/community-edition": "<2.4.5|==2.4.5|>=2.4.5.0-patch1,<2.4.5.0-patch8|==2.4.6|>=2.4.6.0-patch1,<2.4.6.0-patch6|==2.4.7", "magento/core": "<=1.9.4.5", "magento/magento1ce": "<1.9.4.3-dev", "magento/magento1ee": ">=1,<1.14.4.3-dev", @@ -2915,7 +3142,7 @@ "winter/wn-dusk-plugin": "<2.1", "winter/wn-system-module": "<1.2.4", "wintercms/winter": "<=1.2.3", - "woocommerce/woocommerce": "<6.6", + "woocommerce/woocommerce": "<6.6|>=8.8,<8.8.5|>=8.9,<8.9.3", "wp-cli/wp-cli": ">=0.12,<2.5", "wp-graphql/wp-graphql": "<=1.14.5", "wp-premium/gravityforms": "<2.4.21", @@ -3017,7 +3244,7 @@ "type": "tidelift" } ], - "time": "2024-06-10T22:04:51+00:00" + "time": "2024-06-13T20:04:44+00:00" }, { "name": "sanmai/later", @@ -4477,72 +4704,6 @@ ], "time": "2024-04-18T09:32:20+00:00" }, - { - "name": "symfony/filesystem", - "version": "v7.1.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "802e87002f919296c9f606457d9fa327a0b3d6b2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/802e87002f919296c9f606457d9fa327a0b3d6b2", - "reference": "802e87002f919296c9f606457d9fa327a0b3d6b2", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.1.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-05-31T14:57:53+00:00" - }, { "name": "symfony/finder", "version": "v7.1.1", @@ -4607,85 +4768,6 @@ ], "time": "2024-05-31T14:57:53+00:00" }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.29.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "provide": { - "ext-ctype": "*" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-01-29T20:11:03+00:00" - }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.29.0", @@ -4845,86 +4927,6 @@ ], "time": "2024-01-29T20:11:03+00:00" }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.29.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-01-29T20:11:03+00:00" - }, { "name": "symfony/polyfill-php73", "version": "v1.29.0", @@ -5645,7 +5647,8 @@ "prefer-lowest": false, "platform": { "php": ">=8.1 <8.4.0", - "composer-plugin-api": "^2.0" + "composer-plugin-api": "^2.1", + "ext-posix": "*" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0a889e0..f67529f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,3 +3,27 @@ parameters: tmpDir: var/cache/phpstan paths: - src + typeAliases: + RuntimePackageOptions: ''' + array{ + executor?: array>, + umask?: string, + users?: array, + ini?: array{ + set?: array + } + } + ''' + RuntimeRootPackageOptions: ''' + array{ + template?: string, + class?: string, + executor?: array>, + umask?: string, + users?: array, + ini?: array{ + set?: array + } + } + ''' + RuntimeOptions: 'array' diff --git a/phpunit.xml b/phpunit.xml index c9c0798..631da8b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -12,7 +12,7 @@ displayDetailsOnTestsThatTriggerWarnings="true"> - + diff --git a/src/AtooloRuntime.php b/src/AtooloRuntime.php new file mode 100644 index 0000000..1587169 --- /dev/null +++ b/src/AtooloRuntime.php @@ -0,0 +1,43 @@ + + */ + private array $executor; + + /** + * @param string $projectDir + * @param RuntimeOptions $options + */ + public function __construct( + private readonly string $projectDir, + private readonly array $options + ) { + + $executors = []; + foreach ($options as $packageOptions) { + $executors[] = array_map( + static function (string $executorClass) { + return new $executorClass(); + }, + $packageOptions['executor'] ?? [] + ); + } + $this->executor = array_merge(...$executors); + } + + public function run(): void + { + foreach ($this->executor as $executor) { + $executor->execute($this->projectDir, $this->options); + } + } +} diff --git a/src/Composer/ComposerJson.php b/src/Composer/ComposerJson.php new file mode 100644 index 0000000..21bca66 --- /dev/null +++ b/src/Composer/ComposerJson.php @@ -0,0 +1,106 @@ + + * } + * } $jsonContent + */ + public function __construct( + private readonly Composer $composer, + string $composerJsonFile, + private readonly JsonManipulator $manipulator, + private array $jsonContent = [] + ) { + $this->jsonFile = new JsonFile($composerJsonFile); + } + + public function getPath(): string + { + return $this->jsonFile->getPath(); + } + + /** + * @return array{ + * autoload?: array{ + * files?: array + * } + * } + */ + public function getJsonContent(): array + { + return $this->jsonContent; + } + + /** + * @throws JsonException + */ + public function addAutoloadFile(string $autoloadFile): bool + { + $autoloadFiles = $this->jsonContent['autoload']['files'] ?? []; + + if (in_array($autoloadFile, $autoloadFiles, true)) { + return false; + } + $autoloadFiles[] = $autoloadFile; + $this->manipulator->addSubNode('autoload', 'files', $autoloadFiles); + $this->jsonContent['autoload']['files'] = $autoloadFiles; + + file_put_contents( + $this->jsonFile->getPath(), + $this->manipulator->getContents() + ); + + $this->updateAutoloadConfig(); + + return true; + } + + public function removeAutoloadFile(string $autoloadFile): bool + { + $autoloadFiles = $this->jsonContent['autoload']['files'] ?? []; + + $key = array_search($autoloadFile, $autoloadFiles, true); + if ($key === false) { + return false; + } + unset($autoloadFiles[$key]); + if (empty($autoloadFiles)) { + $this->manipulator->removeSubNode('autoload', 'files'); + unset($this->jsonContent['autoload']['files']); + } else { + $this->manipulator->addSubNode('autoload', 'files', $autoloadFiles); + $this->jsonContent['autoload']['files'] = $autoloadFiles; + } + file_put_contents( + $this->jsonFile->getPath(), + $this->manipulator->getContents() + ); + + $this->updateAutoloadConfig(); + + return true; + } + + public function updateAutoloadConfig(): void + { + $this->composer->getPackage()->setAutoload( + $this->jsonContent['autoload'] ?? [] + ); + } +} diff --git a/src/Composer/ComposerJsonFactory.php b/src/Composer/ComposerJsonFactory.php new file mode 100644 index 0000000..1ed6635 --- /dev/null +++ b/src/Composer/ComposerJsonFactory.php @@ -0,0 +1,76 @@ +loadContent($composerJsonRealPath); + $jsonContent = $this->parseJson($composerJsonRealPath, $content); + return new ComposerJson( + $composer, + $composerJsonRealPath, + new JsonManipulator($content), + $jsonContent + ); + } + + /** + * @throws JsonException + */ + private function loadContent(string $composerJsonFile): string + { + $content = @file_get_contents($composerJsonFile); + if ($content === false) { + throw new RuntimeException( + "Failed to read composer.json file: $composerJsonFile" + ); + } + return $content; + } + + /** + * @return array{ + * autoload?: array{ + * files?: array + * } + * } + * @throws JsonException + */ + private function parseJson(string $composerJsonFile, string $content): array + { + $jsonContent = json_decode( + $content, + true, + 512, + JSON_THROW_ON_ERROR + ); + + if (!is_array($jsonContent)) { + throw new JsonException( + "Failed to parse composer.json file: $composerJsonFile" + ); + } + + return $jsonContent; + } +} diff --git a/src/Composer/ComposerPlugin.php b/src/Composer/ComposerPlugin.php index 6b61736..908bfbb 100644 --- a/src/Composer/ComposerPlugin.php +++ b/src/Composer/ComposerPlugin.php @@ -6,45 +6,90 @@ use Composer\Composer; use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\Factory; use Composer\IO\IOInterface; use Composer\Plugin\PluginInterface; use Composer\Script\ScriptEvents; +use InvalidArgumentException; +use JsonException; class ComposerPlugin implements PluginInterface, EventSubscriberInterface { - private Composer $composer; private IOInterface $io; + + private RuntimeFile $runtimeFile; + + private ComposerJson $composerJson; + + private ComposerJsonFactory $composerJsonFactory; + + private RuntimeFileFactory $runtimeFileFactory; + private static bool $activated = false; - public function activate(Composer $composer, IOInterface $io): void - { - self::$activated = true; - $this->composer = $composer; - $this->io = $io; + public function __construct( + ?ComposerJsonFactory $composerJsonFactory = null, + ?RuntimeFileFactory $runtimeFileFactory = null + ) { + $this->composerJsonFactory = $composerJsonFactory + ?? new ComposerJsonFactory(); + $this->runtimeFileFactory = $runtimeFileFactory + ?? new RuntimeFileFactory(); } - public function deactivate(Composer $composer, IOInterface $io) + public static function getSubscribedEvents() { - self::$activated = false; + if (!self::$activated) { + return []; + } + + $method = 'updateRuntime'; + $priority = 1; + return [ + ScriptEvents::PRE_AUTOLOAD_DUMP => [$method, $priority] + ]; } - public function uninstall(Composer $composer, IOInterface $io) + /** + * @throws JsonException + */ + public function activate(Composer $composer, IOInterface $io): void { - //@unlink($composer->getConfig()->get('vendor-dir').'/autoload_runtime.php'); + $this->io = $io; + $this->composerJson = $this->composerJsonFactory->create( + $composer, + Factory::getComposerFile() + ); + $this->runtimeFile = $this->runtimeFileFactory->create( + $composer, + dirname($this->composerJson->getPath()) + ); + self::$activated = true; } - public function updateAutoloadFile(): void + /** + * @throws JsonException + * @throws InvalidArgumentException + * if the configured template file does not exist + */ + public function updateRuntime(): void { + $this->runtimeFile->updateRuntimeFile($this->io); + $this->composerJson->addAutoloadFile( + $this->runtimeFile->getRuntimeFilePath() + ); } - public static function getSubscribedEvents() + public function deactivate(Composer $composer, IOInterface $io) { - if (!self::$activated) { - return []; - } + self::$activated = false; + } - return [ - ScriptEvents::POST_AUTOLOAD_DUMP => 'updateAutoloadFile', - ]; + public function uninstall(Composer $composer, IOInterface $io) + { + $this->runtimeFile->removeRuntimeFile($this->io); + $this->composerJson->removeAutoloadFile( + $this->runtimeFile->getRuntimeFilePath() + ); } } diff --git a/src/Composer/RuntimeFile.php b/src/Composer/RuntimeFile.php new file mode 100644 index 0000000..7a439d9 --- /dev/null +++ b/src/Composer/RuntimeFile.php @@ -0,0 +1,192 @@ +getVendorDir(); + + $runtimeTemplateFile = $this->getRuntimeTemplateFile(); + $projectDir = $this->getProjectDir(); + + $rootRuntimeOptions = $this->getPackageRuntimeOptions( + $this->composer->getPackage() + ); + $runtimeClass = $rootRuntimeOptions['class'] + ?? AtooloRuntime::class; + + $runtimeOptions = $this->getRuntimeOptions(); + + $runtimeTemplate = @file_get_contents($runtimeTemplateFile); + if ($runtimeTemplate === false) { + throw new RuntimeException( + 'Failed to read runtime template file: ' + . $runtimeTemplateFile + ); + } + $code = strtr($runtimeTemplate, [ + '%project_dir%' => $projectDir, + '%runtime_class%' => var_export($runtimeClass, true), + '%runtime_options%' => var_export($runtimeOptions, true), + ]); + + $path = $vendorDir . '/atoolo_runtime.php'; + $fs = new \Composer\Util\Filesystem(); + if ($fs->filePutContentsIfModified($path, $code) !== 0) { + $io->write('' . 'Write ' . $path . ''); + } + } + + public function removeRuntimeFile(IOInterface $io): void + { + $vendorDir = $this->getVendorDir(); + $runtimeFile = $vendorDir . '/atoolo_runtime.php'; + if (file_exists($runtimeFile)) { + $io->write('' . 'Remove ' . $runtimeFile . ''); + unlink($runtimeFile); + } + } + + private function getVendorDir(): string + { + $vendorDir = $this->composer->getConfig()->get('vendor-dir'); + if (!is_string($vendorDir)) { + throw new RuntimeException( + 'Unable to determine the vendor directory: ' + . print_r($vendorDir, true) + ); + } + $vendorDir = realpath($vendorDir); + if ($vendorDir === false) { + throw new RuntimeException( + 'Unable to determine the vendor directory.' + ); + } + + return $vendorDir; + } + + /** + * @return RuntimeOptions + */ + private function getRuntimeOptions(): array + { + $options = []; + $repo = $this->composer->getRepositoryManager()->getLocalRepository(); + + $packages = array_merge( + $repo->getPackages(), + [$this->composer->getPackage()] + ); + foreach ($packages as $package) { + $packageOptions = $this->getPackageRuntimeOptions($package); + if (empty($packageOptions)) { + continue; + } + $options[$package->getName()] = $packageOptions; + } + + return $options; + } + + /** + * @throws InvalidArgumentException + * if a configured template file does not exist + */ + private function getRuntimeTemplateFile(): string + { + $fs = new Filesystem(); + + /** @var RuntimeRootPackageOptions $rootPackageOptions */ + $rootPackageOptions = $this->getPackageRuntimeOptions( + $this->composer->getPackage() + ); + $runtimeTemplateFile = $rootPackageOptions['template'] ?? null; + if ($runtimeTemplateFile === null) { + $runtimeTemplateFile = __DIR__ . '/atoolo_runtime.template'; + } + if (!$fs->isAbsolutePath($runtimeTemplateFile)) { + $runtimeTemplateFile = $this->projectDir . '/' + . $runtimeTemplateFile; + } + if (!is_file($runtimeTemplateFile)) { + throw new InvalidArgumentException( + sprintf( + 'File "%s" defined under ' + . '"extra.atoolo.runtime.template"' + . ' in your composer.json not found.', + $runtimeTemplateFile + ) + ); + } + + return $runtimeTemplateFile; + } + + /** + * @return RuntimePackageOptions|RuntimeRootPackageOptions + */ + private function getPackageRuntimeOptions(PackageInterface $package): array + { + /** + * @var array{ + * atoolo?: array{ + * runtime?: RuntimePackageOptions|RuntimeRootPackageOptions + * } + * } $extra + */ + $extra = $package->getExtra(); + return $extra['atoolo']['runtime'] ?? []; + } + + private function getProjectDir(): string + { + + $vendorDir = $this->getVendorDir(); + + $fs = new Filesystem(); + $projectDir = $fs->makePathRelative($this->projectDir, $vendorDir); + $nestingLevel = 0; + + while (str_starts_with($projectDir, '../')) { + ++$nestingLevel; + $projectDir = substr($projectDir, 3); + } + + $dirname = $nestingLevel === 0 + ? '__DIR__.' + : sprintf('dirname(__DIR__, %d)', $nestingLevel); + return '' !== $projectDir + ? $dirname . var_export('/' . $projectDir, true) + : $dirname; + } +} diff --git a/src/Composer/RuntimeFileFactory.php b/src/Composer/RuntimeFileFactory.php new file mode 100644 index 0000000..0f06f2c --- /dev/null +++ b/src/Composer/RuntimeFileFactory.php @@ -0,0 +1,17 @@ +run(); diff --git a/src/Executor/IniSetter.php b/src/Executor/IniSetter.php new file mode 100644 index 0000000..ac92070 --- /dev/null +++ b/src/Executor/IniSetter.php @@ -0,0 +1,109 @@ + + */ + private $alreadySet = []; + + public function __construct( + private readonly Platform $platform = new Platform() + ) { + } + + /** + * @param RuntimeOptions $options + * @throws RuntimeException if multiple packages define the same ini option + */ + public function execute(string $projectDir, array $options): void + { + /** + * @var array $settings + */ + $settings = []; + foreach ($options as $package => $packageOptions) { + foreach ($packageOptions['ini']['set'] ?? [] as $key => $value) { + $value = $this->validate($package, $key, $value); + if ($value !== null) { + $settings[$key] = [ + 'value' => $value, + 'package' => $package + ]; + } + } + } + + foreach ($settings as $key => $setting) { + $value = $setting['value']; + if ($this->platform->setIni($key, $value) === false) { + $package = $setting['package']; + throw new RuntimeException( + "[atoolo.runtime.init.set]: " + . "Failed to set $key to $value for, package: $package" + ); + } + } + } + + /** + * @return bool|float|int|string|null returns the typed value + * @throws RuntimeException + * if the ini option non-scalar or has already been set by this instance + */ + private function validate( + string $package, + string $key, + mixed $value + ): bool|float|int|string|null { + + if ($value === null) { + return null; + } + + if (is_scalar($value) === false) { + throw new RuntimeException( + "[atoolo.runtime.init.set]: " + . "Value for $key in package $package must be scalar" + ); + } + + if (isset($this->alreadySet[$key])) { + $existsValue = $this->alreadySet[$key]['value']; + if ($existsValue === $value) { + return null; + } + $package = $this->alreadySet[$key]['package']; + throw new RuntimeException( + "[atoolo.runtime.init.set]: " + . "$key is already set to '$existsValue', package: $package" + ); + } + + if (ini_set($key, $value) === false) { + throw new RuntimeException( + "[atoolo.runtime.init.set]: " + . "Failed to set $key to $value for, package: $package" + ); + } + + $this->alreadySet[$key] = [ + 'value' => $value, + 'package' => $package + ]; + + return $value; + } +} diff --git a/src/Executor/Platform.php b/src/Executor/Platform.php new file mode 100644 index 0000000..e3bc2a1 --- /dev/null +++ b/src/Executor/Platform.php @@ -0,0 +1,23 @@ + $packageOptions) { + if (!isset($packageOptions['umask'])) { + continue; + } + $value = $this->validate($package, $packageOptions['umask']); + if ($value !== false) { + $umask = $value; + } + } + if ($umask !== false) { + $this->platform->umask($umask); + } + } + + private function validate(string $package, mixed $value): false|int + { + if (!is_numeric($value)) { + throw new RuntimeException( + "[atoolo.runtime.umask]: ' + . 'umask must be an integer: " + . $value + ); + } + + $umask = (int)$value; + + if ($this->alreadySet !== null) { + $existsValue = $this->alreadySet['value']; + if ($existsValue === $umask) { + return false; + } + $package = $this->alreadySet['package']; + throw new RuntimeException( + "[atoolo.runtime.umask]: ' + . 'umask is already set to $existsValue ' + . ' for package $package" + ); + } + + $this->alreadySet = [ + 'value' => $umask, + 'package' => $package + ]; + + return $umask; + } +} diff --git a/src/Executor/UserValidator.php b/src/Executor/UserValidator.php new file mode 100644 index 0000000..6eb8024 --- /dev/null +++ b/src/Executor/UserValidator.php @@ -0,0 +1,72 @@ +getValidUsers($options); + $this->validateUser($validUsers); + } + + /** + * @param RuntimeOptions $options + * @return array + */ + private function getValidUsers(array $options): array + { + $validUser = []; + + foreach ($options as $package => $packageOptions) { + if (!isset($packageOptions['users'])) { + continue; + } + $users = $packageOptions['users']; + if (!is_array($users)) { + throw new RuntimeException( + "[atoolo.runtime.users]: ' + . 'users from package $package should be an array: $users" + ); + } + + $validUser = []; + foreach ($users as $user) { + if ($user === '{SCRIPT_OWNER}') { + // owner (name) of the current script + $validUser[] = get_current_user(); + // owner (uid) of the current script + $validUser[] = (string)getmyuid(); + continue; + } + $validUser[] = $user; + } + } + + return $validUser; + } + + /** + * @param array $validUsers + */ + private function validateUser(array $validUsers): void + { + if (empty($validUsers)) { + return; + } + $processUser = (posix_getpwuid(posix_geteuid()) + ?: ['name' => posix_geteuid()])['name']; + if (!in_array($processUser, $validUsers)) { + throw new RuntimeException( + "[atoolo.runtime.users]: " + . "The current user '$processUser'" + . " is not valid. Valid users are: " + . implode(', ', $validUsers) + ); + } + } +} diff --git a/test/AtooloRuntimeTest.php b/test/AtooloRuntimeTest.php new file mode 100644 index 0000000..baf59d9 --- /dev/null +++ b/test/AtooloRuntimeTest.php @@ -0,0 +1,32 @@ + [ + 'executor' => [ + AtooloRuntimeTestExecutor::class, + ], + ], + ]; + + $runtime = new AtooloRuntime($projectDir, $options); + $runtime->run(); + $this->assertTrue( + AtooloRuntimeTestExecutor::$executed, + 'The executor should have been executed' + ); + } +} diff --git a/test/AtooloRuntimeTestExecutor.php b/test/AtooloRuntimeTestExecutor.php new file mode 100644 index 0000000..3e1efa5 --- /dev/null +++ b/test/AtooloRuntimeTestExecutor.php @@ -0,0 +1,16 @@ +testDir) === false) { + if (mkdir($this->testDir, 0777, true) === false) { + throw new RuntimeException( + 'Failed to create directory: ' . $this->testDir + ); + } + } + } + + public function testCreateWithValidComposerFile(): void + { + $factory = new ComposerJsonFactory(); + $composer = $this->createStub(Composer::class); + $composerJson = $factory->create( + $composer, + $this->resourceDir . '/valid-composer.json' + ); + $this->assertEquals( + [ + 'name' => 'atoolo/runtime', + 'description' => 'valid composer.json test file' + ], + $composerJson->getJsonContent(), + 'Unexpected JSON content' + ); + } + + public function testCreateWithInvalidComposerFile(): void + { + $factory = new ComposerJsonFactory(); + $composer = $this->createStub(Composer::class); + $this->expectException(RuntimeException::class); + $factory->create( + $composer, + $this->resourceDir . '/notfound.json' + ); + } + + /** + * @throws Exception + */ + public function testCreateWithNonReadableFile(): void + { + $nonReadableFile = $this->testDir . '/notreadable.json'; + try { + touch($nonReadableFile); + chmod($nonReadableFile, 0000); + $this->expectException(RuntimeException::class); + $composer = $this->createStub(Composer::class); + $factory = new ComposerJsonFactory(); + $factory->create( + $composer, + $nonReadableFile + ); + } finally { + chmod($nonReadableFile, 0644); + unlink($nonReadableFile); + } + } + + public function testCreateWithInvalidJson(): void + { + $this->expectException(JsonException::class); + $composer = $this->createStub(Composer::class); + $factory = new ComposerJsonFactory(); + $factory->create( + $composer, + $this->resourceDir . '/string.txt' + ); + } +} diff --git a/test/Composer/ComposerJsonTest.php b/test/Composer/ComposerJsonTest.php new file mode 100644 index 0000000..646906c --- /dev/null +++ b/test/Composer/ComposerJsonTest.php @@ -0,0 +1,245 @@ +testDir) === false) { + if (mkdir($this->testDir, 0777, true) === false) { + throw new RuntimeException( + 'Failed to create directory: ' . $this->testDir + ); + } + } + } + + private function createComposerJson( + string $composerFilePath, + ): ComposerJson { + $factory = new ComposerJsonFactory(); + $composer = $this->createStub(Composer::class); + return $factory->create( + $composer, + $composerFilePath + ); + } + + public function testGetPath(): void + { + $composerFilePath = $this->resourceDir . '/valid-composer.json'; + $composerJson = $this->createComposerJson($composerFilePath); + self::assertEquals( + realpath($composerFilePath), + $composerJson->getPath(), + 'Failed to resolve composer.json file: composer.json' + ); + } + + public function testGetJsonContent(): void + { + $composerFilePath = $this->resourceDir . '/valid-composer.json'; + $composerJson = $this->createComposerJson($composerFilePath); + $this->assertEquals( + [ + 'name' => 'atoolo/runtime', + 'description' => 'valid composer.json test file' + ], + $composerJson->getJsonContent(), + 'Unexpected JSON content' + ); + } + + public function testAddAutoloadFile(): void + { + $file = $this->createTestFile( + 'composer-add-autoload-file.json', + [ + 'name' => 'atoolo/runtime' + ] + ); + + $composerJson = $this->createComposerJson($file); + $composerJson->addAutoloadFile('vendor/test.php'); + + $expected = [ + 'name' => 'atoolo/runtime', + 'autoload' => [ + 'files' => ['vendor/test.php'] + ] + ]; + + $this->assertEquals( + $expected, + json_decode( + file_get_contents($file), + true, + 512, + JSON_THROW_ON_ERROR + ), + 'Failed to add autoload file to composer.json' + ); + } + + public function testAddAutoloadFileWithExistsFile(): void + { + $file = $this->createTestFile( + 'composer-add-autoload-file-with-exists-file.json', + [ + 'name' => 'atoolo/runtime', + 'autoload' => [ + 'files' => ['vendor/test.php'] + ] + ] + ); + + $composerJson = $this->createComposerJson($file); + $composerJson->addAutoloadFile('vendor/test.php'); + + $expected = [ + 'name' => 'atoolo/runtime', + 'autoload' => [ + 'files' => ['vendor/test.php'] + ] + ]; + + $this->assertEquals( + $expected, + json_decode( + file_get_contents($file), + true, + 512, + JSON_THROW_ON_ERROR + ), + 'Failed to add autoload file to composer.json' + ); + } + + public function testRemoveAutoloadFile(): void + { + $file = $this->createTestFile( + 'composer-remove-autoload-file.json', + [ + 'name' => 'atoolo/runtime', + 'autoload' => [ + 'files' => ['vendor/test.php'] + ] + ] + ); + $composerJson = $this->createComposerJson($file); + $composerJson->removeAutoloadFile('vendor/test.php'); + + $expected = [ + 'name' => 'atoolo/runtime', + 'autoload' => [] + ]; + + $this->assertEquals( + $expected, + json_decode( + file_get_contents($file), + true, + 512, + JSON_THROW_ON_ERROR + ), + 'Failed to add autoload file to composer.json' + ); + } + + public function testRemoveAutoloadFileWithoutFile(): void + { + $file = $this->createTestFile( + 'composer-remove-autoload-file.json', + [ + 'name' => 'atoolo/runtime', + 'autoload' => [] + ] + ); + + $composerJson = $this->createComposerJson($file); + $composerJson->removeAutoloadFile('vendor/test.php'); + + $expected = [ + 'name' => 'atoolo/runtime', + 'autoload' => [] + ]; + + $this->assertEquals( + $expected, + json_decode( + file_get_contents($file), + true, + 512, + JSON_THROW_ON_ERROR + ), + 'Failed to add autoload file to composer.json' + ); + } + + public function testRemoveAutoloadFileWithOtherAutoloads(): void + { + $file = $this->createTestFile( + 'composer-remove-autoload-file.json', + [ + 'name' => 'atoolo/runtime', + 'autoload' => [ + 'files' => [ + 'test/abc.php', + 'vendor/test.php', + ] + ] + ] + ); + + $composerJson = $this->createComposerJson($file); + $composerJson->removeAutoloadFile('vendor/test.php'); + + $expected = [ + 'name' => 'atoolo/runtime', + 'autoload' => [ + 'files' => [ + 'test/abc.php' + ] + ] + ]; + + $this->assertEquals( + $expected, + json_decode( + file_get_contents($file), + true, + 512, + JSON_THROW_ON_ERROR + ), + 'Failed to add autoload file to composer.json' + ); + } + + private function createTestFile(string $filenname, array $content): string + { + $file = $this->testDir . '/' . $filenname; + file_put_contents( + $file, + json_encode($content, JSON_THROW_ON_ERROR) + ); + + return $file; + } +} diff --git a/test/Composer/ComposerPluginTest.php b/test/Composer/ComposerPluginTest.php new file mode 100644 index 0000000..323f830 --- /dev/null +++ b/test/Composer/ComposerPluginTest.php @@ -0,0 +1,110 @@ +createStub(Composer::class); + $io = $this->createStub(IOInterface::class); + $plugin = new ComposerPlugin(); + $plugin->deactivate($composer, $io); + + $this->assertEquals( + [], + ComposerPlugin::getSubscribedEvents(), + 'Failed to return empty array' + ); + } + + public function testGetSubscribedEventsWithActivated(): void + { + + $composer = $this->createStub(Composer::class); + $io = $this->createStub(IOInterface::class); + $plugin = new ComposerPlugin(); + $plugin->activate($composer, $io); + + $method = 'updateRuntime'; + $priority = 1; + $exprected = [ + ScriptEvents::PRE_AUTOLOAD_DUMP => [$method, $priority] + ]; + + $this->assertEquals( + $exprected, + ComposerPlugin::getSubscribedEvents(), + 'Failed to return empty array' + ); + } + + public function testUpdateRuntime(): void + { + + $composer = $this->createStub(Composer::class); + $io = $this->createStub(IOInterface::class); + $composerJsonFactory = $this->createStub(ComposerJsonFactory::class); + $runtimeFileFactory = $this->createStub(RuntimeFileFactory::class); + $plugin = new ComposerPlugin( + $composerJsonFactory, + $runtimeFileFactory + ); + + $runtimeFile = $this->createMock(RuntimeFile::class); + $runtimeFile->expects($this->once()) + ->method('updateRuntimeFile'); + $runtimeFileFactory->method('create') + ->willReturn($runtimeFile); + $composerJson = $this->createMock(ComposerJson::class); + $composerJson->expects($this->once()) + ->method('addAutoloadFile'); + $composerJsonFactory->method('create') + ->willReturn($composerJson); + + $plugin->activate($composer, $io); + $plugin->updateRuntime(); + } + + public function testUninstall(): void + { + + $composer = $this->createStub(Composer::class); + $io = $this->createStub(IOInterface::class); + $composerJsonFactory = $this->createStub(ComposerJsonFactory::class); + $runtimeFileFactory = $this->createStub(RuntimeFileFactory::class); + $plugin = new ComposerPlugin( + $composerJsonFactory, + $runtimeFileFactory + ); + + $runtimeFile = $this->createMock(RuntimeFile::class); + $runtimeFile->expects($this->once()) + ->method('removeRuntimeFile'); + $runtimeFileFactory->method('create') + ->willReturn($runtimeFile); + $composerJson = $this->createMock(ComposerJson::class); + $composerJson->expects($this->once()) + ->method('removeAutoloadFile'); + $composerJsonFactory->method('create') + ->willReturn($composerJson); + + $plugin->activate($composer, $io); + $plugin->uninstall($composer, $io); + } +} diff --git a/test/Composer/RuntimeFileFactoryTest.php b/test/Composer/RuntimeFileFactoryTest.php new file mode 100644 index 0000000..0e1f682 --- /dev/null +++ b/test/Composer/RuntimeFileFactoryTest.php @@ -0,0 +1,26 @@ +createStub(Composer::class); + $runtimeFile = $factory->create( + $composer, + '' + ); + self::assertInstanceOf(RuntimeFile::class, $runtimeFile); + } +} diff --git a/test/Composer/RuntimeFileTest.php b/test/Composer/RuntimeFileTest.php new file mode 100644 index 0000000..82fc926 --- /dev/null +++ b/test/Composer/RuntimeFileTest.php @@ -0,0 +1,342 @@ +runtimeFile = $this->testDir + . '/vendor/atoolo_runtime.php'; + $dir = dirname($this->runtimeFile); + if (is_dir($dir) === false) { + if (mkdir($dir, 0777, true) === false) { + throw new RuntimeException( + 'Failed to create directory: ' . $dir + ); + } + } + + if (is_file($this->runtimeFile)) { + unlink($this->runtimeFile); + } + + $config = $this->createStub(Config::class); + $config->method('get') + ->willReturn($this->testDir . '/vendor'); + $this->composer = $this->createStub(Composer::class); + $this->composer->method('getConfig') + ->willReturn($config); + + $this->repositoryManager = $this->createStub(RepositoryManager::class); + $this->composer->method('getRepositoryManager') + ->willReturn($this->repositoryManager); + $this->localRepository = $this->createStub( + InstalledRepositoryInterface::class + ); + $this->repositoryManager->method('getLocalRepository') + ->willReturn($this->localRepository); + } + + public function testGetRuntimeFile(): void + { + $runtimeFile = new RuntimeFile($this->composer, ''); + self::assertEquals( + 'vendor/atoolo_runtime.php', + $runtimeFile->getRuntimeFilePath(), + 'Failed to get runtime file path' + ); + } + + /** + * @throws Exception + */ + public function testCreateRuntimeFile(): void + { + $projectDir = $this->testDir + . '/testCreateRuntimeFile'; + mkdir($projectDir . '/vendor', 0777, true); + + $runtimeFileTemplate = $this->resourceDir . '/atoolo_runtime.template'; + + $config = $this->createStub(Config::class); + $config->method('get') + ->willReturn($projectDir . '/vendor'); + $composer = $this->createStub(Composer::class); + $composer->method('getConfig') + ->willReturn($config); + $composer->method('getRepositoryManager') + ->willReturn($this->repositoryManager); + + $rootPackage = $this->createStub(RootPackageInterface::class); + $rootPackage->method('getName') + ->willReturn('test/root-package'); + $rootPackage->method('getExtra') + ->willReturn([ + 'atoolo' => [ + 'runtime' => [ + 'template' => $runtimeFileTemplate, + 'option' => 'B' + ] + ] + ]); + $composer->method('getPackage') + ->willReturn($rootPackage); + $this->localRepository->method('getPackages') + ->willReturn([]); + + $io = $this->createStub(IOInterface::class); + + $runtimeFile = new RuntimeFile($composer, $projectDir); + $runtimeFile->updateRuntimeFile($io); + + $runtimeFile = $projectDir . '/vendor/atoolo_runtime.php'; + + $this->assertEquals( + << + array ( + 'template' => '$runtimeFileTemplate', + 'option' => 'B', + ), + ) + + EOF, + file_get_contents($runtimeFile), + 'Unexpected runtime file content' + ); + } + + public function testNonStringVendorDir(): void + { + $io = $this->createStub(IOInterface::class); + $config = $this->createStub(Config::class); + $config->method('get') + ->willReturn([123]); + $composer = $this->createStub(Composer::class); + $composer->method('getConfig') + ->willReturn($config); + + $runtimeFile = new RuntimeFile( + $composer, + $this->testDir + ); + $this->expectException(RuntimeException::class); + $runtimeFile->updateRuntimeFile($io); + } + + public function testInvalidVendorDir(): void + { + $io = $this->createStub(IOInterface::class); + $config = $this->createStub(Config::class); + $config->method('get') + ->willReturn('abc'); + $composer = $this->createStub(Composer::class); + $composer->method('getConfig') + ->willReturn($config); + + $runtimeFile = new RuntimeFile( + $composer, + $this->testDir + ); + $this->expectException(RuntimeException::class); + $runtimeFile->updateRuntimeFile($io); + } + + public function testUpdateRuntimeFile(): void + { + $io = $this->createStub(IOInterface::class); + + $package = $this->createPackage( + 'test/package', + [ + 'atoolo' => [ + 'runtime' => ['option' => 'A'] + ] + ] + ); + + $packageWithoutExtra = $this->createPackage( + 'test/package', + [] + ); + + $this->localRepository->method('getPackages') + ->willReturn([ + $package, + $packageWithoutExtra + ]); + + $rootPackage = $this->createStub(RootPackageInterface::class); + $rootPackage->method('getName') + ->willReturn('test/root-package'); + $rootPackage->method('getExtra') + ->willReturn([ + 'atoolo' => [ + 'runtime' => ['option' => 'B'] + ] + ]); + $this->composer->method('getPackage') + ->willReturn($rootPackage); + + $runtimeFile = new RuntimeFile( + $this->composer, + $this->testDir + ); + $runtimeFile->updateRuntimeFile($io); + $content = file_get_contents($this->runtimeFile); + + $expectedOptions = var_export([ + 'test/package' => ['option' => 'A'], + 'test/root-package' => ['option' => 'B'] + ], true); + + $this->assertStringContainsString( + $expectedOptions, + $content, + 'Failed to update runtime file' + ); + } + + public function testUpdateRuntimeFileWithNonNestedProjectDir(): void + { + $io = $this->createStub(IOInterface::class); + + $this->localRepository->method('getPackages') + ->willReturn([]); + + $runtimeFile = new RuntimeFile( + $this->composer, + realpath($this->testDir . '/vendor') + ); + $runtimeFile->updateRuntimeFile($io); + $content = file_get_contents($this->runtimeFile); + $this->assertStringContainsString( + ' __' . 'DIR__', + $content, + 'Failed to update runtime file' + ); + } + + public function testUpdateRuntimeFileWithInvalidTemplateFile(): void + { + $io = $this->createStub(IOInterface::class); + $rootPackage = $this->createStub(RootPackageInterface::class); + $rootPackage->method('getName') + ->willReturn('test/root-package'); + $rootPackage->method('getExtra') + ->willReturn([ + 'atoolo' => [ + 'runtime' => ['template' => 'invalid-path'] + ] + ]); + $this->composer->method('getPackage') + ->willReturn($rootPackage); + + $runtimeFile = new RuntimeFile( + $this->composer, + $this->testDir + ); + + $this->expectException(InvalidArgumentException::class); + $runtimeFile->updateRuntimeFile($io); + } + + public function testUpdateRuntimeFileWithUnreadableTemplateFile(): void + { + + $nonReadableFile = $this->testDir . '/nonreadable.template'; + + $io = $this->createStub(IOInterface::class); + $rootPackage = $this->createStub(RootPackageInterface::class); + $rootPackage->method('getName') + ->willReturn('test/root-package'); + $rootPackage->method('getExtra') + ->willReturn([ + 'atoolo' => [ + 'runtime' => ['template' => 'nonreadable.template'] + ] + ]); + $this->composer->method('getPackage') + ->willReturn($rootPackage); + $this->localRepository->method('getPackages') + ->willReturn([]); + + $runtimeFile = new RuntimeFile( + $this->composer, + $this->testDir + ); + + + try { + touch($nonReadableFile); + chmod($nonReadableFile, 0000); + + $this->expectException(RuntimeException::class); + $runtimeFile->updateRuntimeFile($io); + } finally { + chmod($nonReadableFile, 0644); + unlink($nonReadableFile); + } + } + + public function testRemoveRuntimeFile(): void + { + $io = $this->createStub(IOInterface::class); + $vendorDir = $this->composer->getConfig()->get('vendor-dir'); + $file = $vendorDir . '/atoolo_runtime.php'; + touch($file); + $runtimeFile = new RuntimeFile( + $this->composer, + $this->testDir + ); + $runtimeFile->removeRuntimeFile($io); + $this->assertFileDoesNotExist( + $file, + 'Failed to remove runtime file' + ); + } + + private function createPackage(string $name, mixed $extra): BasePackage + { + $package = $this->createStub(BasePackage::class); + $package->method('getName') + ->willReturn($name); + $package->method('getExtra') + ->willReturn($extra); + return $package; + } +} diff --git a/test/Executor/IniSetterTest.php b/test/Executor/IniSetterTest.php new file mode 100644 index 0000000..59aec87 --- /dev/null +++ b/test/Executor/IniSetterTest.php @@ -0,0 +1,158 @@ +createMock(Platform::class); + $iniSetter = new IniSetter($platform); + + $platform->expects($this->once()) + ->method('setIni') + ->with('user_agent', 'Test') + ->willReturn(''); + + $iniSetter->execute('', [ + 'package1' => [ + 'ini' => [ + 'set' => [ + 'user_agent' => 'Test' + ] + ] + ] + ]); + } + + public function testSetIniTwiceWithSameValueAgain(): void + { + $platform = $this->createMock(Platform::class); + $iniSetter = new IniSetter($platform); + + $platform->expects($this->once()) + ->method('setIni') + ->with('user_agent', 'Test') + ->willReturn(''); + + + $iniSetter->execute('', [ + 'package1' => [ + 'ini' => [ + 'set' => [ + 'user_agent' => 'Test' + ] + ] + ], + 'package2' => [ + 'ini' => [ + 'set' => [ + 'user_agent' => 'Test' + ] + ] + ] + ]); + } + + public function testSetIniTwiceWithDifferentValues(): void + { + $iniSetter = new IniSetter(); + + $this->expectException(RuntimeException::class); + $iniSetter->execute('', [ + 'package1' => [ + 'ini' => [ + 'set' => [ + 'user_agent' => 'Test' + ] + ] + ], + 'package2' => [ + 'ini' => [ + 'set' => [ + 'user_agent' => 'Test2' + ] + ] + ] + ]); + } + + public function testSetIniSystemDirective(): void + { + $iniSetter = new IniSetter(); + $this->expectException(RuntimeException::class); + $iniSetter->execute('', [ + 'package1' => [ + 'ini' => [ + 'set' => [ + 'allow_url_fopen' => 0 + ] + ] + ] + ]); + } + + public function testSetIniWithNonScalar(): void + { + $iniSetter = new IniSetter(); + $this->expectException(RuntimeException::class); + $iniSetter->execute('', [ + 'package1' => [ + 'ini' => [ + 'set' => [ + 'user_agent' => ['non' => 'scalar'] + ] + ] + ] + ]); + } + + public function testSetIniWithNull(): void + { + $platform = $this->createMock(Platform::class); + $iniSetter = new IniSetter($platform); + + $platform->expects($this->never()) + ->method('setIni'); + + $iniSetter->execute('', [ + 'package1' => [ + 'ini' => [ + 'set' => [ + 'user_agent' => null + ] + ] + ] + ]); + } + + public function testSetIniFailed(): void + { + $platform = $this->createMock(Platform::class); + $iniSetter = new IniSetter($platform); + + $platform->expects($this->once()) + ->method('setIni') + ->with('user_agent', 'Test') + ->willReturn(false); + $this->expectException(RuntimeException::class); + $iniSetter->execute('', [ + 'package1' => [ + 'ini' => [ + 'set' => [ + 'user_agent' => 'Test' + ] + ] + ] + ]); + } +} diff --git a/test/Executor/UmaskSetterTest.php b/test/Executor/UmaskSetterTest.php new file mode 100644 index 0000000..de3ab6f --- /dev/null +++ b/test/Executor/UmaskSetterTest.php @@ -0,0 +1,102 @@ +createMock(Platform::class); + $umaskSetter = new UmaskSetter($platform); + + $platform->expects($this->once()) + ->method('umask') + ->with(123) + ->willReturn(123); + $umaskSetter->execute('', [ + 'package1' => [ + 'umask' => '0123' + ] + ]); + } + + /** + * @throws Exception + */ + public function testSetUmaskWithoutUmask(): void + { + $platform = $this->createMock(Platform::class); + $umaskSetter = new UmaskSetter($platform); + + $platform->expects($this->never()) + ->method('umask'); + + $umaskSetter->execute('', [ + 'package1' => [ + ] + ]); + } + + /** + * @throws Exception + */ + public function testSetUmaskTwiceWithSameValue(): void + { + $platform = $this->createMock(Platform::class); + $umaskSetter = new UmaskSetter($platform); + + $platform->expects($this->once()) + ->method('umask') + ->with(123) + ->willReturn(123); + + $umaskSetter->execute('', [ + 'package1' => [ + 'umask' => '0123' + ], + 'package2' => [ + 'umask' => '0123' + ] + ]); + } + + public function testSetUmaskTwiceWithDifferentValues(): void + { + + $umaskSetter = new UmaskSetter(); + + $this->expectException(RuntimeException::class); + $umaskSetter->execute('', [ + 'package1' => [ + 'umask' => '0123' + ], + 'package2' => [ + 'umask' => '0456' + ] + ]); + } + + public function testSetUmaskWithNonNumericValue(): void + { + $umaskSetter = new UmaskSetter(); + $this->expectException(RuntimeException::class); + $umaskSetter->execute('', [ + 'package1' => [ + 'umask' => 'abc' + ], + ]); + } +} diff --git a/test/Executor/UserValidatorTest.php b/test/Executor/UserValidatorTest.php new file mode 100644 index 0000000..9079e00 --- /dev/null +++ b/test/Executor/UserValidatorTest.php @@ -0,0 +1,70 @@ +expectException(RuntimeException::class); + $userValidator->execute('', [ + 'package1' => [ + 'users' => ['abc'] + ] + ]); + } + + public function testValidateUser(): void + { + $userValidator = new UserValidator(); + $processUser = posix_getpwuid(posix_geteuid())['name']; + + $this->expectNotToPerformAssertions(); + $userValidator->execute('', [ + 'package1' => [ + 'users' => [$processUser] + ] + ]); + } + + public function testValidateUserWithoutUser(): void + { + $userValidator = new UserValidator(); + $this->expectNotToPerformAssertions(); + $userValidator->execute('', [ + 'package1' => [ + ] + ]); + } + + public function testValidateUserWithNonArray(): void + { + $userValidator = new UserValidator(); + $this->expectException(RuntimeException::class); + $userValidator->execute('', [ + 'package1' => [ + 'users' => 'non-array' + ] + ]); + } + + public function testValidateUserWithScriptOwner(): void + { + $userValidator = new UserValidator(); + $this->expectNotToPerformAssertions(); + $userValidator->execute('', [ + 'package1' => [ + 'users' => ['{SCRIPT_OWNER}'] + ] + ]); + } +} diff --git a/test/resources/Composer/ComposerJson/valid-composer.json b/test/resources/Composer/ComposerJson/valid-composer.json new file mode 100644 index 0000000..4b764da --- /dev/null +++ b/test/resources/Composer/ComposerJson/valid-composer.json @@ -0,0 +1,4 @@ +{ + "name": "atoolo/runtime", + "description": "valid composer.json test file" +} diff --git a/test/resources/Composer/ComposerJsonFactory/invalid-json.json b/test/resources/Composer/ComposerJsonFactory/invalid-json.json new file mode 100644 index 0000000..83a24e4 --- /dev/null +++ b/test/resources/Composer/ComposerJsonFactory/invalid-json.json @@ -0,0 +1,4 @@ +XXX{ + "name": "atoolo/runtime", + "description": "invalid json test file" +} diff --git a/test/resources/Composer/ComposerJsonFactory/string.txt b/test/resources/Composer/ComposerJsonFactory/string.txt new file mode 100644 index 0000000..1f13d5d --- /dev/null +++ b/test/resources/Composer/ComposerJsonFactory/string.txt @@ -0,0 +1 @@ +"string" \ No newline at end of file diff --git a/test/resources/Composer/ComposerJsonFactory/valid-composer.json b/test/resources/Composer/ComposerJsonFactory/valid-composer.json new file mode 100644 index 0000000..4b764da --- /dev/null +++ b/test/resources/Composer/ComposerJsonFactory/valid-composer.json @@ -0,0 +1,4 @@ +{ + "name": "atoolo/runtime", + "description": "valid composer.json test file" +} diff --git a/test/resources/Composer/RuntimeFile/atoolo_runtime.template b/test/resources/Composer/RuntimeFile/atoolo_runtime.template new file mode 100644 index 0000000..1ac20c9 --- /dev/null +++ b/test/resources/Composer/RuntimeFile/atoolo_runtime.template @@ -0,0 +1,3 @@ +runtime_class=%runtime_class% +project_dir=%project_dir% +runtime_options=%runtime_options%