diff --git a/assets/plugins/updater/plugin.updater.php b/assets/plugins/updater/plugin.updater.php index 617f7b3cea..f7064422c5 100644 --- a/assets/plugins/updater/plugin.updater.php +++ b/assets/plugins/updater/plugin.updater.php @@ -366,7 +366,10 @@ function updaterHandleSystemTaskRequest() updaterJsonResponse([ 'ok' => false, 'error_code' => 'TOKEN_INVALID', - 'message' => 'System task token is invalid.', + // the plugin may run on a core older than this class while it is being updated + 'message' => class_exists(\EvolutionCMS\Support\CsrfMessage::class) + ? \EvolutionCMS\Support\CsrfMessage::text() + : 'System task token is invalid.', ]); } } diff --git a/core/composer.lock b/core/composer.lock index c9bdac6ff7..436b350130 100644 --- a/core/composer.lock +++ b/core/composer.lock @@ -223,16 +223,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.6.0", + "version": "1.6.1", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "2c99cf2e92516985dea4914812b93d9daf01cdd6" + "reference": "0c64479fae6952ba4cf021a72c0ac530394c91d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/2c99cf2e92516985dea4914812b93d9daf01cdd6", - "reference": "2c99cf2e92516985dea4914812b93d9daf01cdd6", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/0c64479fae6952ba4cf021a72c0ac530394c91d4", + "reference": "0c64479fae6952ba4cf021a72c0ac530394c91d4", "shasum": "" }, "require": { @@ -279,7 +279,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.6.0" + "source": "https://github.com/composer/ca-bundle/tree/1.6.1" }, "funding": [ { @@ -291,7 +291,7 @@ "type": "github" } ], - "time": "2026-10-20T20:14:05+00:00" + "time": "2026-10-29T14:10:42+00:00" }, { "name": "composer/class-map-generator", @@ -364,21 +364,21 @@ }, { "name": "composer/composer", - "version": "2.10.3", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "f0de0bf90226853b841672f086d8b58b02332504" + "reference": "e52c65bed1de6aa272cdde4b7725ca6653d7b06a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/f0de0bf90226853b841672f086d8b58b02332504", - "reference": "f0de0bf90226853b841672f086d8b58b02332504", + "url": "https://api.github.com/repos/composer/composer/zipball/e52c65bed1de6aa272cdde4b7725ca6653d7b06a", + "reference": "e52c65bed1de6aa272cdde4b7725ca6653d7b06a", "shasum": "" }, "require": { - "composer/ca-bundle": "^1.5", - "composer/class-map-generator": "^1.4.0", + "composer/ca-bundle": "^1.6", + "composer/class-map-generator": "^1.8.0", "composer/metadata-minifier": "^1.0", "composer/pcre": "^2.3 || ^3.3", "composer/semver": "^3.3", @@ -387,7 +387,7 @@ "ext-filter": "*", "ext-hash": "*", "ext-json": "*", - "justinrainbow/json-schema": "^6.5.1", + "justinrainbow/json-schema": "^6.6.3", "php": "^7.2.5 || ^8.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "react/promise": "^3.3", @@ -404,7 +404,7 @@ "symfony/process": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.11.8", + "phpstan/phpstan": "1.12.33", "phpstan/phpstan-deprecation-rules": "^1.2.0", "phpstan/phpstan-phpunit": "^1.4.0", "phpstan/phpstan-strict-rules": "^1.6.0", @@ -428,7 +428,7 @@ ] }, "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -463,7 +463,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.10.3" + "source": "https://github.com/composer/composer/tree/2.11.0" }, "funding": [ { @@ -475,7 +475,7 @@ "type": "github" } ], - "time": "2026-08-27T11:34:23+00:00" + "time": "2026-10-29T09:23:20+00:00" }, { "name": "composer/metadata-minifier", @@ -620,16 +620,16 @@ }, { "name": "composer/semver", - "version": "3.5.1", + "version": "3.6.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "528948210cc46f967a4b98e83b19e76f2b504993" + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", - "reference": "528948210cc46f967a4b98e83b19e76f2b504993", + "url": "https://api.github.com/repos/composer/semver/zipball/d92543b7ede9c4ec6b59853cefb061e39866d2cf", + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf", "shasum": "" }, "require": { @@ -681,7 +681,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.5.1" + "source": "https://github.com/composer/semver/tree/3.6.0" }, "funding": [ { @@ -693,7 +693,7 @@ "type": "github" } ], - "time": "2026-10-05T13:12:12+00:00" + "time": "2026-10-29T09:13:34+00:00" }, { "name": "composer/spdx-licenses", @@ -1081,23 +1081,23 @@ }, { "name": "doctrine/inflector", - "version": "2.1.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + "reference": "46351b6812b7b6d52b82c3efa5dfb786a198ec88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", - "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/46351b6812b7b6d52b82c3efa5dfb786a198ec88", + "reference": "46351b6812b7b6d52b82c3efa5dfb786a198ec88", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^12.0 || ^13.0", + "doctrine/coding-standard": "^12.0 || ^14.0", "phpstan/phpstan": "^1.12 || ^2.0", "phpstan/phpstan-phpunit": "^1.4 || ^2.0", "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", @@ -1151,7 +1151,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.1.0" + "source": "https://github.com/doctrine/inflector/tree/2.1.1" }, "funding": [ { @@ -1167,7 +1167,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T19:31:58+00:00" + "time": "2026-10-26T08:14:14+00:00" }, { "name": "doctrine/lexer", @@ -1582,16 +1582,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.16.0", + "version": "7.16.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "ffb62ec787050ae4fa9449cdd6b80335db69bda2" + "reference": "81755d989b3007ca7f88a069857553cd08e7dc2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ffb62ec787050ae4fa9449cdd6b80335db69bda2", - "reference": "ffb62ec787050ae4fa9449cdd6b80335db69bda2", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/81755d989b3007ca7f88a069857553cd08e7dc2e", + "reference": "81755d989b3007ca7f88a069857553cd08e7dc2e", "shasum": "" }, "require": { @@ -1690,7 +1690,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.16.0" + "source": "https://github.com/guzzle/guzzle/tree/7.16.1" }, "funding": [ { @@ -1706,7 +1706,7 @@ "type": "tidelift" } ], - "time": "2026-10-18T13:38:36+00:00" + "time": "2026-10-25T21:35:21+00:00" }, { "name": "guzzlehttp/promises", @@ -1999,7 +1999,7 @@ }, { "name": "illuminate/bus", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/bus.git", @@ -2052,7 +2052,7 @@ }, { "name": "illuminate/cache", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/cache.git", @@ -2114,7 +2114,7 @@ }, { "name": "illuminate/collections", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", @@ -2174,7 +2174,7 @@ }, { "name": "illuminate/conditionable", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -2220,7 +2220,7 @@ }, { "name": "illuminate/config", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/config.git", @@ -2268,7 +2268,7 @@ }, { "name": "illuminate/console", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/console.git", @@ -2334,7 +2334,7 @@ }, { "name": "illuminate/container", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/container.git", @@ -2396,7 +2396,7 @@ }, { "name": "illuminate/contracts", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", @@ -2444,7 +2444,7 @@ }, { "name": "illuminate/database", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/database.git", @@ -2517,7 +2517,7 @@ }, { "name": "illuminate/events", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/events.git", @@ -2572,7 +2572,7 @@ }, { "name": "illuminate/filesystem", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/filesystem.git", @@ -2639,7 +2639,7 @@ }, { "name": "illuminate/http", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/http.git", @@ -2701,7 +2701,7 @@ }, { "name": "illuminate/log", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/log.git", @@ -2754,7 +2754,7 @@ }, { "name": "illuminate/macroable", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -2800,7 +2800,7 @@ }, { "name": "illuminate/pagination", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/pagination.git", @@ -2850,7 +2850,7 @@ }, { "name": "illuminate/pipeline", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/pipeline.git", @@ -2902,7 +2902,7 @@ }, { "name": "illuminate/queue", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/queue.git", @@ -2969,7 +2969,7 @@ }, { "name": "illuminate/redis", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/redis.git", @@ -3023,7 +3023,7 @@ }, { "name": "illuminate/reflection", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/reflection.git", @@ -3074,7 +3074,7 @@ }, { "name": "illuminate/routing", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/routing.git", @@ -3140,7 +3140,7 @@ }, { "name": "illuminate/session", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/session.git", @@ -3197,16 +3197,16 @@ }, { "name": "illuminate/support", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "9e9ff99fc4405664faa02f231a25aaf968c6f02b" + "reference": "6427b436b592d05050b506930566f47f85ecebf0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/9e9ff99fc4405664faa02f231a25aaf968c6f02b", - "reference": "9e9ff99fc4405664faa02f231a25aaf968c6f02b", + "url": "https://api.github.com/repos/illuminate/support/zipball/6427b436b592d05050b506930566f47f85ecebf0", + "reference": "6427b436b592d05050b506930566f47f85ecebf0", "shasum": "" }, "require": { @@ -3273,11 +3273,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-10-06T13:17:38+00:00" + "time": "2026-10-24T14:38:23+00:00" }, { "name": "illuminate/translation", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/translation.git", @@ -3328,7 +3328,7 @@ }, { "name": "illuminate/validation", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/validation.git", @@ -3391,7 +3391,7 @@ }, { "name": "illuminate/view", - "version": "v12.70.0", + "version": "v12.70.2", "source": { "type": "git", "url": "https://github.com/illuminate/view.git", @@ -3502,16 +3502,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.13.1", + "version": "6.14.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "e54aacc4c0657b3a4af4f628428d2702146828c6" + "reference": "bae415e135dd68260c2d020ba392850231e9b1ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/e54aacc4c0657b3a4af4f628428d2702146828c6", - "reference": "e54aacc4c0657b3a4af4f628428d2702146828c6", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bae415e135dd68260c2d020ba392850231e9b1ab", + "reference": "bae415e135dd68260c2d020ba392850231e9b1ab", "shasum": "" }, "require": { @@ -3561,9 +3561,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.13.1" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.14.0" }, - "time": "2026-09-30T07:42:28+00:00" + "time": "2026-10-24T08:53:34+00:00" }, { "name": "laravel/prompts", @@ -4052,16 +4052,16 @@ }, { "name": "nesbot/carbon", - "version": "3.15.0", + "version": "3.15.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "217d38553209bdb44e01590d9e9a2e4df8354cce" + "reference": "ce0e5636fae7d1e99856eb70cae4c719a284aa08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/217d38553209bdb44e01590d9e9a2e4df8354cce", - "reference": "217d38553209bdb44e01590d9e9a2e4df8354cce", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ce0e5636fae7d1e99856eb70cae4c719a284aa08", + "reference": "ce0e5636fae7d1e99856eb70cae4c719a284aa08", "shasum": "" }, "require": { @@ -4154,7 +4154,7 @@ "type": "tidelift" } ], - "time": "2026-10-18T18:27:53+00:00" + "time": "2026-10-25T14:32:48+00:00" }, { "name": "nunomaduro/termwind", @@ -5973,16 +5973,16 @@ }, { "name": "symfony/console", - "version": "v7.4.21", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820" + "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/21f1bc2296f7ff6043241a8dcbc09559b3732820", - "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820", + "url": "https://api.github.com/repos/symfony/console/zipball/8012f842110b414fd1cc641adb0ed8423353b0bb", + "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb", "shasum": "" }, "require": { @@ -6047,7 +6047,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.21" + "source": "https://github.com/symfony/console/tree/v7.4.22" }, "funding": [ { @@ -6067,7 +6067,7 @@ "type": "tidelift" } ], - "time": "2026-10-12T18:45:38+00:00" + "time": "2026-10-28T11:08:27+00:00" }, { "name": "symfony/deprecation-contracts", @@ -6142,16 +6142,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.20", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0" + "reference": "545852cfe9c4979c82389155b4d8d05f8864e4f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", - "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/545852cfe9c4979c82389155b4d8d05f8864e4f1", + "reference": "545852cfe9c4979c82389155b4d8d05f8864e4f1", "shasum": "" }, "require": { @@ -6200,7 +6200,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.20" + "source": "https://github.com/symfony/error-handler/tree/v7.4.22" }, "funding": [ { @@ -6220,7 +6220,7 @@ "type": "tidelift" } ], - "time": "2026-09-22T18:56:40+00:00" + "time": "2026-10-25T14:40:16+00:00" }, { "name": "symfony/event-dispatcher", @@ -6459,16 +6459,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.20", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" + "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", - "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "url": "https://api.github.com/repos/symfony/finder/zipball/e13420a8e0a76bdde1e7be09f923e62ad13739f2", + "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2", "shasum": "" }, "require": { @@ -6503,7 +6503,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.20" + "source": "https://github.com/symfony/finder/tree/v7.4.22" }, "funding": [ { @@ -6523,20 +6523,20 @@ "type": "tidelift" } ], - "time": "2026-09-26T06:31:10+00:00" + "time": "2026-10-19T16:56:54+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.21", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31" + "reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", - "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/63eda1d0c4de229d6b3e825709e49dd5c149699a", + "reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a", "shasum": "" }, "require": { @@ -6585,7 +6585,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.21" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.22" }, "funding": [ { @@ -6605,20 +6605,20 @@ "type": "tidelift" } ], - "time": "2026-10-08T16:56:12+00:00" + "time": "2026-10-27T13:26:03+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.21", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "4779fce7dedacb8801b2b61c703ef99159502331" + "reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/4779fce7dedacb8801b2b61c703ef99159502331", - "reference": "4779fce7dedacb8801b2b61c703ef99159502331", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/87e727b33983238d3f2ea50f9f62265499ca1a6d", + "reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d", "shasum": "" }, "require": { @@ -6704,7 +6704,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.21" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.22" }, "funding": [ { @@ -6724,20 +6724,20 @@ "type": "tidelift" } ], - "time": "2026-10-14T06:44:08+00:00" + "time": "2026-10-28T18:18:28+00:00" }, { "name": "symfony/mime", - "version": "v7.4.19", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693" + "reference": "94a4c017435bc515f5ac80421a2c555fb9ca3676" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", - "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "url": "https://api.github.com/repos/symfony/mime/zipball/94a4c017435bc515f5ac80421a2c555fb9ca3676", + "reference": "94a4c017435bc515f5ac80421a2c555fb9ca3676", "shasum": "" }, "require": { @@ -6793,7 +6793,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.19" + "source": "https://github.com/symfony/mime/tree/v7.4.22" }, "funding": [ { @@ -6813,7 +6813,7 @@ "type": "tidelift" } ], - "time": "2026-09-04T10:45:44+00:00" + "time": "2026-10-28T17:57:58+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6900,16 +6900,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.43.0", + "version": "v1.43.3", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", - "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5a8f33c87aeecffce24e721b5064fab05f4becf8", + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8", "shasum": "" }, "require": { @@ -6958,7 +6958,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.3" }, "funding": [ { @@ -6978,7 +6978,7 @@ "type": "tidelift" } ], - "time": "2026-09-25T15:46:47+00:00" + "time": "2026-10-15T09:18:53+00:00" }, { "name": "symfony/polyfill-intl-idn", @@ -7154,16 +7154,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.43.0", + "version": "v1.43.3", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", - "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a", "shasum": "" }, "require": { @@ -7215,7 +7215,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.3" }, "funding": [ { @@ -7235,7 +7235,7 @@ "type": "tidelift" } ], - "time": "2026-09-24T15:19:04+00:00" + "time": "2026-10-28T09:35:49+00:00" }, { "name": "symfony/polyfill-php84", @@ -7482,16 +7482,16 @@ }, { "name": "symfony/process", - "version": "v7.4.21", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83" + "reference": "a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/e5247f4a5e0797d232ad8e8e6803aeb341843c83", - "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "url": "https://api.github.com/repos/symfony/process/zipball/a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a", + "reference": "a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a", "shasum": "" }, "require": { @@ -7523,7 +7523,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.21" + "source": "https://github.com/symfony/process/tree/v7.4.22" }, "funding": [ { @@ -7543,20 +7543,20 @@ "type": "tidelift" } ], - "time": "2026-10-08T16:56:12+00:00" + "time": "2026-10-25T14:40:16+00:00" }, { "name": "symfony/routing", - "version": "v7.4.21", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b" + "reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/7a344cf871532d081875bfdd18f3dff8dca36c5b", - "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b", + "url": "https://api.github.com/repos/symfony/routing/zipball/f79ed6fbf60d9e32d358054f1915295f3b8a6f7a", + "reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a", "shasum": "" }, "require": { @@ -7608,7 +7608,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.21" + "source": "https://github.com/symfony/routing/tree/v7.4.22" }, "funding": [ { @@ -7628,7 +7628,7 @@ "type": "tidelift" } ], - "time": "2026-10-11T18:40:59+00:00" + "time": "2026-10-26T21:52:50+00:00" }, { "name": "symfony/service-contracts", @@ -7719,16 +7719,16 @@ }, { "name": "symfony/string", - "version": "v7.4.21", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981" + "reference": "8dff7da3a56638f1d189c5368183294102de4ced" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", - "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "url": "https://api.github.com/repos/symfony/string/zipball/8dff7da3a56638f1d189c5368183294102de4ced", + "reference": "8dff7da3a56638f1d189c5368183294102de4ced", "shasum": "" }, "require": { @@ -7786,7 +7786,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.21" + "source": "https://github.com/symfony/string/tree/v7.4.22" }, "funding": [ { @@ -7806,20 +7806,20 @@ "type": "tidelift" } ], - "time": "2026-10-03T15:15:30+00:00" + "time": "2026-10-23T16:51:52+00:00" }, { "name": "symfony/translation", - "version": "v7.4.17", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" + "reference": "a892cd1807bfaf0a10690359c47fdf95b162ef22" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", - "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "url": "https://api.github.com/repos/symfony/translation/zipball/a892cd1807bfaf0a10690359c47fdf95b162ef22", + "reference": "a892cd1807bfaf0a10690359c47fdf95b162ef22", "shasum": "" }, "require": { @@ -7886,7 +7886,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.17" + "source": "https://github.com/symfony/translation/tree/v7.4.22" }, "funding": [ { @@ -7906,7 +7906,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-10-25T14:40:16+00:00" }, { "name": "symfony/translation-contracts", @@ -7992,16 +7992,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.21", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6" + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/55d06b02f49eca9bff61828548988e6f8763e8a6", - "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", "shasum": "" }, "require": { @@ -8055,7 +8055,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.21" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.22" }, "funding": [ { @@ -8075,26 +8075,26 @@ "type": "tidelift" } ], - "time": "2026-10-13T05:48:33+00:00" + "time": "2026-10-25T21:04:04+00:00" }, { "name": "tracy/tracy", - "version": "v2.12.1", + "version": "v2.12.2", "source": { "type": "git", "url": "https://github.com/nette/tracy.git", - "reference": "90d24872cd97202f79281aac7eb09c27ec48bf69" + "reference": "2664bd9456cb745ff1fb0b31efa79fe2f411bef3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/tracy/zipball/90d24872cd97202f79281aac7eb09c27ec48bf69", - "reference": "90d24872cd97202f79281aac7eb09c27ec48bf69", + "url": "https://api.github.com/repos/nette/tracy/zipball/2664bd9456cb745ff1fb0b31efa79fe2f411bef3", + "reference": "2664bd9456cb745ff1fb0b31efa79fe2f411bef3", "shasum": "" }, "require": { "ext-json": "*", "ext-session": "*", - "php": "8.2 - 8.5" + "php": "8.2 - 8.6" }, "conflict": { "nette/di": "<3.0" @@ -8108,7 +8108,7 @@ "nette/tester": "^2.6", "nette/utils": "^3.0 || ^4.0", "phpstan/extension-installer": "^1.4@stable", - "phpstan/phpstan": "^2.1@stable", + "phpstan/phpstan": "^2.3@stable", "psr/log": "^1.0 || ^2.0 || ^3.0" }, "type": "library", @@ -8136,39 +8136,40 @@ { "name": "David Grudl", "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" } ], - "description": "😎 Tracy: the addictive tool to ease debugging PHP code for cool developers. Friendly design, logging, profiler, advanced features like debugging AJAX calls or CLI support. You will love it.", + "description": "😎 Tracy: the addictive PHP debugger. Clear error pages with full context, a debug bar, readable dumps, AJAX and CLI support, and logging with e-mail alerts in production.", "homepage": "https://tracy.nette.org", "keywords": [ - "Xdebug", - "debug", + "debug bar", "debugger", + "debugging", + "dump", + "error handler", + "logging", "nette", - "profiler" + "tracy", + "var_dump", + "whoops" ], "support": { "issues": "https://github.com/nette/tracy/issues", - "source": "https://github.com/nette/tracy/tree/v2.12.1" + "source": "https://github.com/nette/tracy/tree/v2.12.2" }, - "time": "2026-08-18T10:56:11+00:00" + "time": "2026-10-21T05:14:18+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/phpdotenv/phpdotenv.git", - "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + "reference": "96149103e62ac67e915f6d2ef59b72dee2e011e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", - "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/96149103e62ac67e915f6d2ef59b72dee2e011e0", + "reference": "96149103e62ac67e915f6d2ef59b72dee2e011e0", "shasum": "" }, "require": { @@ -8195,7 +8196,7 @@ "forward-command": false }, "branch-alias": { - "dev-master": "5.6-dev" + "dev-master": "5.8-dev" } }, "autoload": { @@ -8226,8 +8227,8 @@ "environment" ], "support": { - "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + "issues": "https://github.com/phpdotenv/phpdotenv/issues", + "source": "https://github.com/phpdotenv/phpdotenv/tree/v5.8.0" }, "funding": [ { @@ -8239,7 +8240,7 @@ "type": "tidelift" } ], - "time": "2026-08-24T18:07:49+00:00" + "time": "2026-10-25T16:26:19+00:00" }, { "name": "voku/portable-ascii", @@ -9700,16 +9701,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.6", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" + "reference": "e3647094ed8f0f1f67393d812b793a5143e25af3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", - "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/e3647094ed8f0f1f67393d812b793a5143e25af3", + "reference": "e3647094ed8f0f1f67393d812b793a5143e25af3", "shasum": "" }, "require": { @@ -9741,9 +9742,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.4.0" }, - "time": "2026-09-27T20:07:45+00:00" + "time": "2026-10-28T20:28:58+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10198,12 +10199,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "1d3860eceb9c8a3c76ade46a8ef7896a63802048" + "reference": "eeb812c0368526077282bb18ec790cb222121246" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/1d3860eceb9c8a3c76ade46a8ef7896a63802048", - "reference": "1d3860eceb9c8a3c76ade46a8ef7896a63802048", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/eeb812c0368526077282bb18ec790cb222121246", + "reference": "eeb812c0368526077282bb18ec790cb222121246", "shasum": "" }, "conflict": { @@ -10238,7 +10239,7 @@ "amphp/artax": "<1.0.6|>=2,<2.0.6", "amphp/http": "<=1.7.2|>=2,<=2.1", "amphp/http-client": ">=4,<4.4", - "amphp/http-server": ">=2.0.0.0-RC1-dev,<2.1.10|>=3.0.0.0-beta1,<3.4.4", + "amphp/http-server": ">=2.0.0.0-RC1,<2.1.10|>=3.0.0.0-beta1,<3.4.4", "anchorcms/anchor-cms": "<=0.12.7", "andreapollastri/cipi": "<=3.1.15", "andrewhaine/silverstripe-form-capture": ">=0.2,<=0.2.3|>=1,<1.0.2|>=2,<2.2.5", @@ -10371,8 +10372,8 @@ "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", "craftcms/cms": "<4.18.3|>=5,<5.10.8", "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4", - "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", - "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", + "craftcms/composer": ">=4.0.0.0-RC1,<=4.10|>=5.0.0.0-RC1,<=5.5.1", + "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1,<=5.8.21", "craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2", "craftcms/webhooks": ">=3,<3.2", "croogo/croogo": "<=4.0.7", @@ -10480,7 +10481,7 @@ "ezsystems/ezplatform": "<=1.13.6|>=2,<=2.5.24", "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6|>=1.5,<1.5.29|>=2.3,<2.3.39|>=3.3,<3.3.39", "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1|>=5.3.0.0-beta1,<5.3.5", - "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12", + "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1,<1.0.13|>=2.0.0.0-beta1,<2.3.12", "ezsystems/ezplatform-http-cache": "<2.3.16", "ezsystems/ezplatform-kernel": "<=1.2.5|>=1.3,<1.3.35", "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", @@ -10702,7 +10703,7 @@ "litesaml/lightsaml": ">=6,<6.0.1", "livehelperchat/livehelperchat": "<=3.91", "livewire-filemanager/filemanager": "<=1.0.4", - "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<=3.8.2|>=4.0.0.0-beta1,<=4.3.3", + "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<=3.8.2|>=4.0.0.0-beta1,<4.4.8", "livewire/volt": "<1.7", "lms/routes": "<2.1.1", "localizationteam/l10nmgr": "<7.4|>=8,<8.7|>=9,<9.2", @@ -10867,6 +10868,7 @@ "phanan/koel": "<=9.7", "pheditor/pheditor": "<2.0.8", "phenx/php-svg-lib": "<0.5.2", + "php": "<8.2.35|>=8.3,<8.3.36|>=8.4,<8.4.27|>=8.5,<8.5.12", "php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5", "php-mod/curl": "<2.3.2", "php-standard-library/h2": ">=6.1,<6.1.2|>=6.2,<6.2.1", @@ -10882,18 +10884,18 @@ "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", - "phpoffice/phpspreadsheet": "<=1.30.5|>=2,<=2.1.17|>=2.2,<=2.4.6|>=3,<=3.10.6|>=4,<=5.8", + "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", - "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6", + "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", "phpwhois/phpwhois": "<=4.2.5", "phpxmlrpc/extras": "<0.6.1", "phpxmlrpc/phpxmlrpc": "<4.9.2", "phraseanet/phraseanet": "==4.0.3", "pi/pi": "<=2.5", - "pimcore/admin-ui-classic-bundle": "<1.7.18|>=2.0.0.0-RC1-dev,<=2.3.5", + "pimcore/admin-ui-classic-bundle": "<1.7.18|>=2.0.0.0-RC1,<=2.3.5", "pimcore/customer-management-framework-bundle": "<4.2.1", "pimcore/data-hub": "<1.2.4", "pimcore/data-importer": "<1.8.9|>=1.9,<1.9.3", @@ -10902,7 +10904,7 @@ "pimcore/perspective-editor": "<1.5.1", "pimcore/pimcore": "<=12.3.9|>=2026.1,<=2026.1.5", "pimcore/studio-backend-bundle": "<2025.4.6|>=2026.1,<2026.1.6", - "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", + "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1,<=6.1", "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", "plank/laravel-mediable": "<7", @@ -10912,7 +10914,7 @@ "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", "pontedilana/php-weasyprint": "<=2.5.1", "poweradmin/poweradmin": "<4.2.5|>=4.3,<4.3.4", - "predis/predis": ">=3.0.0.0-RC1-dev,<3.3", + "predis/predis": ">=3.0.0.0-RC1,<3.3", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", "prestashop/blockreassurance": "<=5.1.3", @@ -10932,7 +10934,7 @@ "propel/propel1": ">=1,<1.7.2", "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", "pterodactyl/panel": "<=1.12.4", - "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", + "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1,<=2", "ptrofimov/beanstalk_console": "<1.7.14", "pubnub/pubnub": "<6.1", "punktde/pt_extbase": "<1.5.1", @@ -10961,7 +10963,7 @@ "roadiz/openid": "<2.3.43|>=2.5,<2.5.45|>=2.6,<2.6.31|>=2.7,<2.7.18", "robrichards/xmlseclibs": "<3.1.5", "roots/soil": "<4.1", - "roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11|>=1.7.0.0-beta,<1.7.0.0-RC5-dev", + "roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11|>=1.7.0.0-beta,<1.7.0.0-RC5", "rudloff/alltube": "<3.0.3", "rudloff/rtmpdump-bin": "<=2.3.1", "s-cart/core": "<=9.0.5", @@ -11070,7 +11072,7 @@ "sylius/mollie-plugin": "<2.2.8|>=3,<3.2.4|>=3.3,<3.3.1", "sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2", "sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", - "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<2.0.18|>=2.1,<2.1.15|>=2.2,<2.2.6", + "sylius/sylius": "<1.14.21|>=2,<2.1.17|>=2.2,<2.2.12|>=2.3,<2.3.2", "symbiote/silverstripe-advancedworkflow": "<6.4.5|>=7,<7.1.3|>=7.2,<7.2.1", "symbiote/silverstripe-multivaluefield": ">=3,<3.1", "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", @@ -11096,7 +11098,7 @@ "symfony/mailomat-mailer": ">=7.2,<7.4.13|>=8,<8.0.13", "symfony/mailtrap-mailer": ">=7.2,<7.4.12|>=8,<8.0.12", "symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1", - "symfony/mime": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/mime": "<5.4.55|>=6,<6.4.49|>=7,<7.4.22|>=8,<8.1.10", "symfony/monolog-bridge": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", "symfony/polyfill": ">=1,<1.10|>=1.17.1,<1.38.1", @@ -11113,7 +11115,7 @@ "symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", "symfony/security-http": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", "symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12", - "symfony/symfony": "<5.4.54|>=6,<6.4.48|>=7,<7.4.21|>=8,<8.1.9", + "symfony/symfony": "<5.4.55|>=6,<6.4.49|>=7,<7.4.22|>=8,<8.1.10", "symfony/translation": ">=2,<2.0.17", "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40", "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", @@ -11145,7 +11147,7 @@ "thorsten/phpmyfaq": "<4.2.0.0-alpha", "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", - "tinymce/tinymce": "<7.9.3|>=8,<8.5.1", + "tinymce/tinymce": "<7.9.3|==7.9.3|>=8,<8.9.3", "tinymighty/wiki-seo": "<1.2.2", "titon/framework": "<9.9.99", "tltneon/lgsl": "<7", @@ -11356,7 +11358,7 @@ "type": "tidelift" } ], - "time": "2026-10-20T14:42:48+00:00" + "time": "2026-10-28T22:14:24+00:00" }, { "name": "sebastian/cli-parser", diff --git a/core/functions/preload.php b/core/functions/preload.php index d89093322f..2aee8fdb94 100644 --- a/core/functions/preload.php +++ b/core/functions/preload.php @@ -26,38 +26,57 @@ function evo() if (IN_MANAGER_MODE == true && IN_INSTALL_MODE == false && EVO_API_MODE != true) { // attempt to foil some simple types of CSRF attacks - if ((int)$evo->getConfig('validate_referer') !== 0) { - if (isset($_SERVER['HTTP_REFERER'])) { + if ((int)$evo->getConfig('validate_referer') !== 0 + && !\EvolutionCMS\Support\ManagerReferer::allows( + $_SERVER, + (string)EVO_SITE_URL, + (string)$evo->getConfig('server_protocol', 'http') + )) { + rejectManagerReferer($evo); + } + } - $referer = $_SERVER['HTTP_REFERER']; + return $evo; + } +} - if (!empty($referer)) { - if (!preg_match('/^' . preg_quote(EVO_SITE_URL, '/') . '/i', $referer)) { - $evo->webAlertAndQuit( - "A possible CSRF attempt was detected from referer: {$referer}.", - "/" . MGR_DIR . "/index.php" - ); - } - } else { - $evo->webAlertAndQuit( - "A possible CSRF attempt was detected. No referer was provided by the client.", - "/" . MGR_DIR . "/index.php" - ); - } - } else { +if (!function_exists('rejectManagerReferer')) { + /** + * Refuse a manager request whose referer is missing or foreign (`validate_referer`). + * A script caller shows the body itself, so it gets the localized message as JSON, not the alert page. + * + * @param \EvolutionCMS\Interfaces\CoreInterface $evo + */ + function rejectManagerReferer($evo): never + { + // every evo() call repeats the check, and shutdown callbacks call evo(): answer once + static $refused = false; + if ($refused) { + exit; + } + $refused = true; - if (mb_strtoupper($_SERVER['REQUEST_METHOD']) !== 'GET') { - $evo->webAlertAndQuit( - "A possible CSRF attempt was detected. No referer was provided by the server.", - "/" . MGR_DIR . "/index.php" - ); - } + // evo() is still being built: the language comes from its configuration, never from evo() + $language = (string)$evo->getConfig('manager_language', 'en'); - } + if (\EvolutionCMS\Support\CsrfMessage::wantsScriptResponse($_SERVER, $_POST)) { + if (!headers_sent()) { + http_response_code(403); + header('Content-Type: application/json; charset=UTF-8'); + header('Cache-Control: no-store'); } + echo json_encode( + \EvolutionCMS\Support\CsrfMessage::payload(\EvolutionCMS\Support\CsrfMessage::REFERER, $language), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + exit; } - return $evo; + $evo->webAlertAndQuit( + \EvolutionCMS\Support\CsrfMessage::text(\EvolutionCMS\Support\CsrfMessage::REFERER, $language), + '/' . MGR_DIR . '/index.php' + ); + exit; } } diff --git a/core/lang/az/global.php b/core/lang/az/global.php index f09b4c69e2..ef9799fb7b 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -1318,6 +1318,8 @@ $_lang["upload_limits_hint"] = 'Hər fayl üçün %1$s, hər yükləmə üçün %2$s-a qədər.'; $_lang["upload_maxsize_message"] = 'Fayl meneceri vasitəsilə yüklənə bilən maksimum fayl ölçüsünü daxil edin. Yüklənəcək fayl ölçüsü baytlarda göstərilməlidir. QEYD: Böyük faylların yüklənməsi çox uzun çəkə bilər!'; $_lang["upload_maxsize_title"] = 'Maksimum yükləmə ölçüsü'; +$_lang['csrf_token_mismatch'] = 'Təhlükəsizlik nişanının müddəti bitib. Səhifəni yeniləyin və yenidən cəhd edin.'; +$_lang['csrf_referer_mismatch'] = 'Sorğu bloklandı: brauzer onun bu saytdan göndərildiyini təsdiqləmədi.'; $_lang["upload_request_back"] = 'Geri'; $_lang["upload_request_too_large"] = 'Sorğu qəbul edilmədi: onun ölçüsü %1$s-dır, lakin bu server bir sorğuda ən çox %2$s qəbul edir (PHP post_max_size). Heç nə saxlanılmadı.'; $_lang["upload_request_too_large_hint"] = 'Daha az və ya daha kiçik fayllar yükləyin, yaxud administratordan post_max_size və upload_max_filesize dəyərlərini artırmağı xahiş edin.'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index 32de3d21e8..22b8f43629 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -1308,6 +1308,8 @@ $_lang["upload_limits_hint"] = 'Да %1$s на файл, %2$s на загрузку.'; $_lang["upload_maxsize_message"] = 'Калі ласка, увядзіце максімальны памер файла, які можна загрузіць праз файлавы менеджэр. Памер загружанага файла павінен быць уведзены ў байтах. УВАГА: загрузка вялікіх файлаў можа заняць вельмі шмат часу!'; $_lang["upload_maxsize_title"] = 'Максімальны памер загрузкі'; +$_lang['csrf_token_mismatch'] = 'Тэрмін дзеяння ахоўнага токена скончыўся. Абнавіце старонку і паўтарыце спробу.'; +$_lang['csrf_referer_mismatch'] = 'Запыт заблакаваны: браўзер не пацвердзіў, што ён адпраўлены з гэтага сайта.'; $_lang["upload_request_back"] = 'Назад'; $_lang["upload_request_too_large"] = 'Запыт не прыняты: яго памер %1$s, а гэты сервер прымае не больш за %2$s на запыт (PHP post_max_size). Нічога не захавана.'; $_lang["upload_request_too_large_hint"] = 'Загрузіце менш файлаў або файлы меншага памеру, або папрасіце адміністратара павялічыць post_max_size і upload_max_filesize.'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 37d3780b98..4d526f5427 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -1059,6 +1059,8 @@ $_lang["upload_limits_hint"] = 'До %1$s на файл, %2$s на качване.'; $_lang["upload_maxsize_message"] = 'Въведете максималния размер на файла, който може да бъде ъплоуднат с файловия мениджър. Размера на файла за ъплоудване трябва да бъде написан в байтове. ЗАБЕЛЕЖКА: Големите файлове ще се ъплоудват по-дълго време!'; $_lang["upload_maxsize_title"] = 'Максимален размер на файла за ъплоудване'; +$_lang['csrf_token_mismatch'] = 'Срокът на защитния токен изтече. Презаредете страницата и опитайте отново.'; +$_lang['csrf_referer_mismatch'] = 'Заявката е блокирана: браузърът не потвърди, че идва от този сайт.'; $_lang["upload_request_back"] = 'Назад'; $_lang["upload_request_too_large"] = 'Заявката не беше приета: тя е %1$s, но този сървър приема най-много %2$s на заявка (PHP post_max_size). Нищо не беше записано.'; $_lang["upload_request_too_large_hint"] = 'Качете по-малко или по-малки файлове или помолете администратора да увеличи post_max_size и upload_max_filesize.'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 86b61026e5..976b7055b5 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -1063,6 +1063,8 @@ $_lang["upload_limits_hint"] = 'Až %1$s na soubor, %2$s na jedno nahrání.'; $_lang["upload_maxsize_message"] = 'Zadejte maximální velikost souboru, který může být nahrán prostřednictvím souborového správce. Velikost nahraného souboru musí být zadána v bajtech. POZNÁMKA: Velké soubory se můžou nahrávat velmi dlohou dobu!'; $_lang["upload_maxsize_title"] = 'Maximální velikost nahrávaného souboru'; +$_lang['csrf_token_mismatch'] = 'Platnost bezpečnostního tokenu vypršela. Obnovte stránku a zkuste to znovu.'; +$_lang['csrf_referer_mismatch'] = 'Požadavek zablokován: prohlížeč nepotvrdil, že pochází z tohoto webu.'; $_lang["upload_request_back"] = 'Zpět'; $_lang["upload_request_too_large"] = 'Požadavek nebyl přijat: má %1$s, ale tento server přijímá nejvýše %2$s na požadavek (PHP post_max_size). Nic nebylo uloženo.'; $_lang["upload_request_too_large_hint"] = 'Nahrajte méně nebo menší soubory, nebo požádejte správce o zvýšení post_max_size a upload_max_filesize.'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index 8cc4c8d120..0ebf91ba09 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -1062,6 +1062,8 @@ $_lang["upload_limits_hint"] = 'Op til %1$s pr. fil, %2$s pr. upload.'; $_lang["upload_maxsize_message"] = 'Indtast den maksimale filstørrelse som der må uploades med filbrowseren. Størrelsen skal angives i bytes. Bemærk: Det kan tage lang tid at uploade store filer!'; $_lang["upload_maxsize_title"] = 'Maksimal filstørrelse ved upload'; +$_lang['csrf_token_mismatch'] = 'Sikkerhedstokenet er udløbet. Genindlæs siden, og prøv igen.'; +$_lang['csrf_referer_mismatch'] = 'Anmodningen blev blokeret: browseren bekræftede ikke, at den kom fra dette websted.'; $_lang["upload_request_back"] = 'Tilbage'; $_lang["upload_request_too_large"] = 'Forespørgslen blev ikke accepteret: den fylder %1$s, men denne server modtager højst %2$s pr. forespørgsel (PHP post_max_size). Intet blev gemt.'; $_lang["upload_request_too_large_hint"] = 'Upload færre eller mindre filer, eller bed administratoren om at hæve post_max_size og upload_max_filesize.'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index bae4f9f586..186b9ddec6 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -1143,6 +1143,8 @@ $_lang["upload_limits_hint"] = 'Bis zu %1$s pro Datei, %2$s pro Upload.'; $_lang["upload_maxsize_message"] = 'Geben Sie hier die maximale Dateigröße für den Datei-Upload mit dem Datei-Manager an. Die Größenangabe ist in Bytes. Achtung: je größer die Datei desto länger dauert der Upload!'; $_lang["upload_maxsize_title"] = 'Maximale Upload-Dateigröße'; +$_lang['csrf_token_mismatch'] = 'Das Sicherheitstoken ist abgelaufen. Laden Sie die Seite neu und versuchen Sie es erneut.'; +$_lang['csrf_referer_mismatch'] = 'Anfrage blockiert: Der Browser hat nicht bestätigt, dass sie von dieser Website stammt.'; $_lang["upload_request_back"] = 'Zurück'; $_lang["upload_request_too_large"] = 'Die Anfrage wurde nicht angenommen: Sie ist %1$s groß, dieser Server akzeptiert aber höchstens %2$s pro Anfrage (PHP post_max_size). Es wurde nichts gespeichert.'; $_lang["upload_request_too_large_hint"] = 'Laden Sie weniger oder kleinere Dateien hoch oder bitten Sie den Administrator, post_max_size und upload_max_filesize zu erhöhen.'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index 198827af70..0f8fb195c6 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -1190,6 +1190,8 @@ $_lang["upload_limits_hint"] = 'Up to %1$s per file, %2$s per upload.'; $_lang["upload_maxsize_message"] = 'Enter the maximum file size that can be uploaded via the file manager. Upload file size must be entered in bytes. NOTE: Large files can take a very long time to upload!'; $_lang["upload_maxsize_title"] = 'Maximum upload size'; +$_lang['csrf_token_mismatch'] = 'Security token expired. Reload the page and try again.'; +$_lang['csrf_referer_mismatch'] = 'Request blocked: the browser did not confirm it came from this site.'; $_lang["upload_request_back"] = 'Back'; $_lang["upload_request_too_large"] = 'The request was not accepted: it is %1$s, but this server takes at most %2$s per request (PHP post_max_size). Nothing was saved.'; $_lang["upload_request_too_large_hint"] = 'Upload fewer or smaller files, or ask the administrator to raise post_max_size and upload_max_filesize.'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index c3eee72ca7..38ededb5b9 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -1145,6 +1145,8 @@ $_lang["upload_limits_hint"] = 'Hasta %1$s por archivo, %2$s por subida.'; $_lang["upload_maxsize_message"] = 'Ingresa el tamaño máximo de archivo a ser subido por el Administrador de archivos. El tamaño de archivo a subir debe de ser ingresado en bytes. NOTA: ¡Archivos grandes pueden demorar mucho tiempo para subir!'; $_lang["upload_maxsize_title"] = 'Tamaño máximo de subida'; +$_lang['csrf_token_mismatch'] = 'El token de seguridad ha caducado. Recargue la página e inténtelo de nuevo.'; +$_lang['csrf_referer_mismatch'] = 'Solicitud bloqueada: el navegador no confirmó que procede de este sitio.'; $_lang["upload_request_back"] = 'Volver'; $_lang["upload_request_too_large"] = 'La solicitud no fue aceptada: ocupa %1$s, pero este servidor admite como máximo %2$s por solicitud (PHP post_max_size). No se ha guardado nada.'; $_lang["upload_request_too_large_hint"] = 'Sube menos archivos o archivos más pequeños, o pide al administrador que aumente post_max_size y upload_max_filesize.'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index 089ed2a9f0..dadca76c12 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -1060,6 +1060,8 @@ $_lang["upload_limits_hint"] = 'حداکثر %1$s برای هر فایل، %2$s برای هر آپلود.'; $_lang["upload_maxsize_message"] = 'نهایت اندازه مجاز فایل برای آپلود بوسیله ی بخش فایلها را به بایت در اینجا وارد کنید. تذکر : فایلهای بزرگ زمان بیشتری را برای آپلود میطلبد!'; $_lang["upload_maxsize_title"] = 'نهایت اندازه برای آپلود :'; +$_lang['csrf_token_mismatch'] = 'توکن امنیتی منقضی شده است. صفحه را دوباره بارگذاری و مجدداً تلاش کنید.'; +$_lang['csrf_referer_mismatch'] = 'درخواست مسدود شد: مرورگر تأیید نکرد که از این سایت ارسال شده است.'; $_lang["upload_request_back"] = 'بازگشت'; $_lang["upload_request_too_large"] = 'درخواست پذیرفته نشد: حجم آن %1$s است، اما این سرور حداکثر %2$s در هر درخواست می‌پذیرد (PHP post_max_size). چیزی ذخیره نشد.'; $_lang["upload_request_too_large_hint"] = 'فایل‌های کمتر یا کوچک‌تری آپلود کنید، یا از مدیر سیستم بخواهید post_max_size و upload_max_filesize را افزایش دهد.'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index 5cc0b72618..3e22f21b43 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -1058,6 +1058,8 @@ $_lang["upload_limits_hint"] = 'Enintään %1$s tiedostoa kohden, %2$s siirtoa kohden.'; $_lang["upload_maxsize_message"] = 'Enimmäiskoko tiedostoselaimen kautta siirrettäville tiedostoille. Tiedostokoko tulee ilmoittaa tavuina. HUOMAA: Suurien tiedostojen siirrossa voi kestää erittäin kauan!'; $_lang["upload_maxsize_title"] = 'Siirrettävän tiedoston enimmäiskoko'; +$_lang['csrf_token_mismatch'] = 'Suojaustunnus on vanhentunut. Lataa sivu uudelleen ja yritä uudelleen.'; +$_lang['csrf_referer_mismatch'] = 'Pyyntö estettiin: selain ei vahvistanut, että se tuli tältä sivustolta.'; $_lang["upload_request_back"] = 'Takaisin'; $_lang["upload_request_too_large"] = 'Pyyntöä ei hyväksytty: sen koko on %1$s, mutta tämä palvelin ottaa vastaan enintään %2$s pyyntöä kohden (PHP post_max_size). Mitään ei tallennettu.'; $_lang["upload_request_too_large_hint"] = 'Siirrä vähemmän tai pienempiä tiedostoja tai pyydä ylläpitäjää kasvattamaan arvoja post_max_size ja upload_max_filesize.'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index 889cb31147..20e6b30996 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -1029,6 +1029,8 @@ $_lang["upload_limits_hint"] = 'Jusqu\'à %1$s par fichier, %2$s par téléchargement.'; $_lang["upload_maxsize_message"] = 'Entrez ici la taille maximale des fichiers pouvant être téléchargés à l\'aide du Gestionnaire de Fichiers. La taille doit être spécifiée en octets. NOTE: Les fichiers volumineux peuvent être longs à télécharger!'; $_lang["upload_maxsize_title"] = 'Taille maximale de téléchargement'; +$_lang['csrf_token_mismatch'] = 'Le jeton de sécurité a expiré. Rechargez la page et réessayez.'; +$_lang['csrf_referer_mismatch'] = 'Requête bloquée : le navigateur n\'a pas confirmé qu\'elle provient de ce site.'; $_lang["upload_request_back"] = 'Retour'; $_lang["upload_request_too_large"] = 'La requête n\'a pas été acceptée : elle fait %1$s, mais ce serveur accepte au maximum %2$s par requête (PHP post_max_size). Rien n\'a été enregistré.'; $_lang["upload_request_too_large_hint"] = 'Téléchargez moins de fichiers ou des fichiers plus petits, ou demandez à l\'administrateur d\'augmenter post_max_size et upload_max_filesize.'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index a1c82f235d..ddfc0f3d2e 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -1060,6 +1060,8 @@ $_lang["upload_limits_hint"] = 'עד %1$s לקובץ, %2$s להעלאה.'; $_lang["upload_maxsize_message"] = 'Enter the maximum file size that can be uploaded via the file manager. Upload file size must be entered in bytes. NOTE: Large files can take a very long time to upload!'; $_lang["upload_maxsize_title"] = 'Maximum upload size'; +$_lang['csrf_token_mismatch'] = 'פג תוקף אסימון האבטחה. יש לטעון מחדש את הדף ולנסות שוב.'; +$_lang['csrf_referer_mismatch'] = 'הבקשה נחסמה: הדפדפן לא אישר שהיא נשלחה מאתר זה.'; $_lang["upload_request_back"] = 'חזרה'; $_lang["upload_request_too_large"] = 'הבקשה לא התקבלה: גודלה %1$s, אך שרת זה מקבל לכל היותר %2$s לבקשה (PHP post_max_size). דבר לא נשמר.'; $_lang["upload_request_too_large_hint"] = 'העלו פחות קבצים או קבצים קטנים יותר, או בקשו ממנהל המערכת להגדיל את post_max_size ואת upload_max_filesize.'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index 5b7456b4e2..c8149e28c9 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -1154,6 +1154,8 @@ $_lang["upload_limits_hint"] = 'Fino a %1$s per file, %2$s per caricamento.'; $_lang["upload_maxsize_message"] = 'Qui potete inserire le dimensioni massime dei file che si possono caricare tramite il sistema di gestione file. Le dimensioni devo essere impostate in bytes. NOTA: File troppo grandi impiegano molto tempo a caricare!'; $_lang["upload_maxsize_title"] = 'Massima dimensione caricamento:'; +$_lang['csrf_token_mismatch'] = 'Il token di sicurezza è scaduto. Ricarica la pagina e riprova.'; +$_lang['csrf_referer_mismatch'] = 'Richiesta bloccata: il browser non ha confermato che proviene da questo sito.'; $_lang["upload_request_back"] = 'Indietro'; $_lang["upload_request_too_large"] = 'La richiesta non è stata accettata: è di %1$s, ma questo server accetta al massimo %2$s per richiesta (PHP post_max_size). Non è stato salvato nulla.'; $_lang["upload_request_too_large_hint"] = 'Caricate meno file o file più piccoli, oppure chiedete all\'amministratore di aumentare post_max_size e upload_max_filesize.'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index 73d6ef81a3..a8630dda2e 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -1177,6 +1177,8 @@ $_lang["upload_limits_hint"] = '1 ファイルあたり最大 %1$s、1 回のアップロードあたり最大 %2$s。'; $_lang["upload_maxsize_message"] = '管理画面を通じてアップロード可能なファイルの最大サイズ。このサーバでは上限%sまで設定できます。
※ブラウザ側で制限をかけるための設定です。サーバ側の調整が必要な場合は.htaccessやphp.iniでupload_max_filesizeの設定を変更してください。'; $_lang["upload_maxsize_title"] = 'アップロードサイズの上限'; +$_lang['csrf_token_mismatch'] = 'セキュリティトークンの有効期限が切れました。ページを再読み込みして、もう一度お試しください。'; +$_lang['csrf_referer_mismatch'] = 'リクエストはブロックされました:ブラウザはこのサイトから送信されたことを確認しませんでした。'; $_lang["upload_request_back"] = '戻る'; $_lang["upload_request_too_large"] = 'リクエストは受け付けられませんでした: サイズは %1$s ですが、このサーバーが 1 リクエストで受け付けるのは最大 %2$s です (PHP post_max_size)。何も保存されませんでした。'; $_lang["upload_request_too_large_hint"] = 'ファイルの数を減らすか、より小さいファイルをアップロードしてください。または、post_max_size と upload_max_filesize を引き上げるよう管理者に依頼してください。'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index 468e5e3a1b..e18363725b 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -1133,6 +1133,8 @@ $_lang["upload_limits_hint"] = 'Maximaal %1$s per bestand, %2$s per upload.'; $_lang["upload_maxsize_message"] = 'Voer de maximale bestandsgrootte in voor bestanden die via Bestandsbeheer geupload kunnen worden. De bestandsgrootte wordt opgegeven in bytes.
NB: Het kan enige tijd duren om grote bestanden te uploaden!'; $_lang["upload_maxsize_title"] = 'Maximale upload bestandsgrootte'; +$_lang['csrf_token_mismatch'] = 'Het beveiligingstoken is verlopen. Laad de pagina opnieuw en probeer het nogmaals.'; +$_lang['csrf_referer_mismatch'] = 'Verzoek geblokkeerd: de browser heeft niet bevestigd dat het van deze site komt.'; $_lang["upload_request_back"] = 'Terug'; $_lang["upload_request_too_large"] = 'Het verzoek is niet geaccepteerd: het is %1$s, maar deze server accepteert maximaal %2$s per verzoek (PHP post_max_size). Er is niets opgeslagen.'; $_lang["upload_request_too_large_hint"] = 'Upload minder of kleinere bestanden, of vraag de beheerder om post_max_size en upload_max_filesize te verhogen.'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 4c0bb53750..9053e916b7 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -1026,6 +1026,8 @@ $_lang["upload_limits_hint"] = 'Opptil %1$s per fil, %2$s per opplasting.'; $_lang["upload_maxsize_message"] = 'Skriv den største filstørrelsen som kan lastes opp via filhåndtereren. Størelsen må angis i bytes.
OBS: Store filer kan ta veldig lang tid å laste opp!'; $_lang["upload_maxsize_title"] = 'Max størrelse for opplastinger'; +$_lang['csrf_token_mismatch'] = 'Tryggingstokenet er utgått. Last sida på nytt og prøv igjen.'; +$_lang['csrf_referer_mismatch'] = 'Førespurnaden vart blokkert: nettlesaren stadfesta ikkje at han kom frå denne nettstaden.'; $_lang["upload_request_back"] = 'Tilbake'; $_lang["upload_request_too_large"] = 'Førespurnaden vart ikkje godteken: han er %1$s, men denne tenaren tek imot høgst %2$s per førespurnad (PHP post_max_size). Ingenting vart lagra.'; $_lang["upload_request_too_large_hint"] = 'Last opp færre eller mindre filer, eller be administratoren auke post_max_size og upload_max_filesize.'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index dfd326b666..ca554bb906 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -1147,6 +1147,8 @@ $_lang["upload_limits_hint"] = 'Do %1$s na plik, %2$s na jedno wgrywanie.'; $_lang["upload_maxsize_message"] = 'Wprowadź maksymalny rozmiar pliku, który może zostać załadowany przez menedżera plików. Wartość musi być podana w bajtach. Uwaga: Ładowanie dużych plików może zająć bardzo dużo czasu!'; $_lang["upload_maxsize_title"] = 'Maksymalny rozmiar pliku'; +$_lang['csrf_token_mismatch'] = 'Token bezpieczeństwa wygasł. Odśwież stronę i spróbuj ponownie.'; +$_lang['csrf_referer_mismatch'] = 'Żądanie zablokowane: przeglądarka nie potwierdziła, że pochodzi z tej witryny.'; $_lang["upload_request_back"] = 'Wstecz'; $_lang["upload_request_too_large"] = 'Żądanie nie zostało przyjęte: ma %1$s, a ten serwer przyjmuje najwyżej %2$s na żądanie (PHP post_max_size). Nic nie zostało zapisane.'; $_lang["upload_request_too_large_hint"] = 'Wgraj mniej plików lub mniejsze pliki albo poproś administratora o zwiększenie post_max_size i upload_max_filesize.'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index 496d11c817..d9f1c4d908 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -1059,6 +1059,8 @@ $_lang["upload_limits_hint"] = 'Até %1$s por ficheiro, %2$s por envio.'; $_lang["upload_maxsize_message"] = 'Digite o tamanho máximo que um ficheiro pode ter para ser enviado via Gestor de Ficheiros. Este valor deve ser digitado em bytes. Importante: Ficheiros muito grandes podem demorar muito tempo a ser transferidos!'; $_lang["upload_maxsize_title"] = 'Tamanho máximo de envio (upload)'; +$_lang['csrf_token_mismatch'] = 'O token de segurança expirou. Recarregue a página e tente novamente.'; +$_lang['csrf_referer_mismatch'] = 'Pedido bloqueado: o navegador não confirmou que veio deste site.'; $_lang["upload_request_back"] = 'Voltar'; $_lang["upload_request_too_large"] = 'O pedido não foi aceite: tem %1$s, mas este servidor aceita no máximo %2$s por pedido (PHP post_max_size). Nada foi guardado.'; $_lang["upload_request_too_large_hint"] = 'Envie menos ficheiros ou ficheiros mais pequenos, ou peça ao administrador para aumentar post_max_size e upload_max_filesize.'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index 024f815b55..9655125587 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -1178,6 +1178,8 @@ $_lang["upload_limits_hint"] = 'Do %1$s na súbor, %2$s na jedno nahrávanie.'; $_lang["upload_maxsize_message"] = 'Nastavte maximálnu veľkosť nahrávaných súborov. Hodnota musí byť v bajtoch.
Poznámka: veľké súbory sa spravidla nahrávajú VEĽMI dlho'; $_lang["upload_maxsize_title"] = 'Maximálna veľkosť nahrávania:'; +$_lang['csrf_token_mismatch'] = 'Platnosť bezpečnostného tokenu vypršala. Obnovte stránku a skúste to znova.'; +$_lang['csrf_referer_mismatch'] = 'Požiadavka zablokovaná: prehliadač nepotvrdil, že pochádza z tohto webu.'; $_lang["upload_request_back"] = 'Späť'; $_lang["upload_request_too_large"] = 'Požiadavka nebola prijatá: má %1$s, ale tento server prijíma najviac %2$s na požiadavku (PHP post_max_size). Nič nebolo uložené.'; $_lang["upload_request_too_large_hint"] = 'Nahrajte menej súborov alebo menšie súbory, prípadne požiadajte správcu o zvýšenie post_max_size a upload_max_filesize.'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index 2a7dad2791..731639f975 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -1060,6 +1060,8 @@ $_lang["upload_limits_hint"] = 'Upp till %1$s per fil, %2$s per uppladdning.'; $_lang["upload_maxsize_message"] = 'Skriv den största filstorleken som kan laddas upp via filhanteraren. Storleken måste anges i bytes.
OBS: Stora filer kan ta väldigt lång tid att ladda upp!'; $_lang["upload_maxsize_title"] = 'Största storlek för uppladdningar'; +$_lang['csrf_token_mismatch'] = 'Säkerhetstoken har upphört att gälla. Läs in sidan igen och försök på nytt.'; +$_lang['csrf_referer_mismatch'] = 'Begäran blockerades: webbläsaren bekräftade inte att den kom från den här webbplatsen.'; $_lang["upload_request_back"] = 'Tillbaka'; $_lang["upload_request_too_large"] = 'Begäran accepterades inte: den är %1$s, men den här servern tar emot högst %2$s per begäran (PHP post_max_size). Inget sparades.'; $_lang["upload_request_too_large_hint"] = 'Ladda upp färre eller mindre filer, eller be administratören att höja post_max_size och upload_max_filesize.'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index 08a8e9531a..700f147f6c 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -1185,6 +1185,8 @@ $_lang["upload_limits_hint"] = 'До %1$s на файл, %2$s на завантаження.'; $_lang["upload_maxsize_message"] = 'Задайте максимальний розмір файлів. Значення має бути в байтах.
Примітка: великі файли завантажуються, як правило, ДУЖЕ довго'; $_lang["upload_maxsize_title"] = 'Максимальний розмір завантаження:'; +$_lang['csrf_token_mismatch'] = 'Термін дії захисного токена минув. Оновіть сторінку та повторіть спробу.'; +$_lang['csrf_referer_mismatch'] = 'Запит заблоковано: браузер не підтвердив, що його надіслано з цього сайту.'; $_lang["upload_request_back"] = 'Назад'; $_lang["upload_request_too_large"] = 'Запит не прийнято: його розмір %1$s, а цей сервер приймає не більше %2$s на запит (PHP post_max_size). Нічого не збережено.'; $_lang["upload_request_too_large_hint"] = 'Завантажте менше файлів або менші файли, або попросіть адміністратора збільшити post_max_size і upload_max_filesize.'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 3be861d63a..14e9d97c3f 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -1060,6 +1060,8 @@ $_lang["upload_limits_hint"] = '每个文件最大 %1$s,每次上传最大 %2$s。'; $_lang["upload_maxsize_message"] = '输入通过文件管理上传文件的大小的最大值. 大小按 byte计算. 注意: 大文件会占用太多的传输时间!'; $_lang["upload_maxsize_title"] = '最大上传大小'; +$_lang['csrf_token_mismatch'] = '安全令牌已过期。请重新加载页面后再试。'; +$_lang['csrf_referer_mismatch'] = '请求已被阻止:浏览器未确认其来自本站。'; $_lang["upload_request_back"] = '返回'; $_lang["upload_request_too_large"] = '请求未被接受:其大小为 %1$s,但此服务器每个请求最多接受 %2$s (PHP post_max_size)。未保存任何内容。'; $_lang["upload_request_too_large_hint"] = '请上传更少或更小的文件,或请管理员调大 post_max_size 和 upload_max_filesize。'; diff --git a/core/src/Middleware/VerifyCsrfToken.php b/core/src/Middleware/VerifyCsrfToken.php index 5bbd8e4b3e..1d64b7fc16 100644 --- a/core/src/Middleware/VerifyCsrfToken.php +++ b/core/src/Middleware/VerifyCsrfToken.php @@ -1,6 +1,7 @@ ajax() || $request->wantsJson()) { - return Response::json(['error' => 'CSRF token mismatch', 'code' => 'csrf_token_mismatch'], 403); + return Response::json(CsrfMessage::payload(), 403, ['Cache-Control' => 'no-store']); } // Rejected operations, including mutating GET actions, must never be replayed. return Response::make( - 'The session token has changed or is missing. Reopen the manager, then review and retry your action. The action was not performed.', + CsrfMessage::text(), 403, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store'] ); diff --git a/core/src/Support/CsrfMessage.php b/core/src/Support/CsrfMessage.php new file mode 100644 index 0000000000..5d8bd2caab --- /dev/null +++ b/core/src/Support/CsrfMessage.php @@ -0,0 +1,129 @@ + 'Security token expired. Reload the page and try again.', + self::REFERER => 'Request blocked: the browser did not confirm it came from this site.', + ]; + + private const ERRORS = [ + self::TOKEN => 'CSRF token mismatch', + self::REFERER => 'CSRF referer mismatch', + ]; + + /** @var array> */ + private static array $lexicons = []; + + /** + * The localized message. `$language` is a manager language code (`en`, `de`...); null asks the + * current configuration, which already holds the user's own manager_language. + */ + public static function text(string $key = self::TOKEN, ?string $language = null): string + { + $key = isset(self::FALLBACK[$key]) ? $key : self::TOKEN; + $text = self::lexicon($language ?? self::managerLanguage())[$key] ?? null; + if (!is_string($text) || trim($text) === '') { + $text = self::FALLBACK[$key]; + } + + return $text; + } + + /** + * The JSON body of a refusal: `error` and `code` stay machine-readable for existing callers, + * `message` is what to show. + * + * @return array{error: string, code: string, message: string} + */ + public static function payload(string $key = self::TOKEN, ?string $language = null): array + { + $key = isset(self::FALLBACK[$key]) ? $key : self::TOKEN; + + return [ + 'error' => self::ERRORS[$key], + 'code' => $key, + 'message' => self::text($key, $language), + ]; + } + + /** + * Whether the caller is a script that shows the response body itself: an XHR, a JSON client, + * or a legacy form that posts `ajax=1` (the login form) and alerts whatever comes back. + * + * @param array $server A `$_SERVER`-shaped array. + * @param array $post + */ + public static function wantsScriptResponse(array $server, array $post = []): bool + { + return strcasecmp((string)($server['HTTP_X_REQUESTED_WITH'] ?? ''), 'XMLHttpRequest') === 0 + || stripos((string)($server['HTTP_ACCEPT'] ?? ''), 'json') !== false + || (is_scalar($post['ajax'] ?? null) && (string)$post['ajax'] === '1'); + } + + /** @return array */ + private static function lexicon(string $language): array + { + // a language code only: the value names a file to include + if (!preg_match('/^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})?$/i', $language)) { + $language = 'en'; + } + if (isset(self::$lexicons[$language])) { + return self::$lexicons[$language]; + } + + $dir = dirname(__DIR__, 2) . '/lang/'; + $lexicon = self::read($dir . 'en/global.php'); + if ($language !== 'en') { + $lexicon = array_merge($lexicon, self::read($dir . $language . '/global.php')); + } + + return self::$lexicons[$language] = $lexicon; + } + + /** @return array */ + private static function read(string $file): array + { + if (!is_file($file)) { + return []; + } + + return (static function (string $file): array { + $_lang = []; + include $file; + + return $_lang; + })($file); + } + + private static function managerLanguage(): string + { + // the instance evo() keeps, if it was built: calling evo() here could build it, or re-enter its referer check + $evo = $GLOBALS['evo'] ?? null; + + return is_object($evo) && method_exists($evo, 'getConfig') ? (string)$evo->getConfig('manager_language', 'en') : 'en'; + } +} diff --git a/core/src/Support/ManagerReferer.php b/core/src/Support/ManagerReferer.php new file mode 100644 index 0000000000..152790bba7 --- /dev/null +++ b/core/src/Support/ManagerReferer.php @@ -0,0 +1,60 @@ + $server A `$_SERVER`-shaped array. + */ + public static function allows(array $server, string $siteUrl, string $serverProtocol = 'http'): bool + { + $referer = $server['HTTP_REFERER'] ?? null; + + if (is_string($referer) && $referer !== '') { + foreach (self::siteUrls($siteUrl, $serverProtocol) as $url) { + if (stripos($referer, $url) === 0) { + return true; + } + } + + return false; + } + + if (strtolower((string)($server['HTTP_SEC_FETCH_SITE'] ?? '')) === 'same-origin') { + return true; + } + + // an empty Referer header was refused even on GET; only an absent one lets a GET through + return $referer === null && strtoupper((string)($server['REQUEST_METHOD'] ?? 'GET')) === 'GET'; + } + + /** + * @return list + */ + public static function siteUrls(string $siteUrl, string $serverProtocol = 'http'): array + { + $urls = [$siteUrl]; + if (strtolower(trim($serverProtocol)) === 'https' && stripos($siteUrl, 'http://') === 0) { + $urls[] = 'https://' . substr($siteUrl, 7); + } + + return $urls; + } +} diff --git a/core/tests/Unit/Security/CsrfMessageTest.php b/core/tests/Unit/Security/CsrfMessageTest.php new file mode 100644 index 0000000000..cd69a38b62 --- /dev/null +++ b/core/tests/Unit/Security/CsrfMessageTest.php @@ -0,0 +1,136 @@ +toBe($en['csrf_token_mismatch']) + ->and(CsrfMessage::text(CsrfMessage::REFERER, 'en'))->toBe($en['csrf_referer_mismatch']); +}); + +it('answers in every shipped manager language, falling back to English per entry', function (string $language) { + $en = csrfMessageLexicon('en'); + $own = csrfMessageLexicon($language); + + foreach ([CsrfMessage::TOKEN, CsrfMessage::REFERER] as $key) { + $expected = isset($own[$key]) && trim((string)$own[$key]) !== '' ? $own[$key] : $en[$key]; + $text = CsrfMessage::text($key, $language); + + expect($text)->toBe($expected) + ->and($text)->not->toMatch('/<[a-z!\/]/i') + // a renamed manager folder keeps it from bots: a refusal never says where it is + ->and($text)->not->toMatch('~https?://|/manager|%s|\[\+|MGR_DIR~i'); + } +})->with(fn () => array_map('basename', glob(dirname(__DIR__, 3) . '/lang/*', GLOB_ONLYDIR))); + +it('never includes a file named by an unexpected language value', function (string $language) { + expect(CsrfMessage::text(CsrfMessage::TOKEN, $language))->toBe(csrfMessageLexicon('en')['csrf_token_mismatch']); +})->with(['../../../etc/passwd', 'en/../de', 'english', '', 'zz']); + +it('takes the language of the configuration evo() already holds', function () { + $GLOBALS['evo'] = new class { + public function getConfig($key, $default = null) + { + return $key === 'manager_language' ? 'de' : $default; + } + }; + + expect(CsrfMessage::text())->toBe(CsrfMessage::text(CsrfMessage::TOKEN, 'de')); +}); + +it('falls back to English without a built evo()', function () { + expect(CsrfMessage::text())->toBe(CsrfMessage::text(CsrfMessage::TOKEN, 'en')); +}); + +it('keeps the machine-readable fields of the JSON refusal and adds the message', function () { + expect(CsrfMessage::payload())->toBe([ + 'error' => 'CSRF token mismatch', + 'code' => 'csrf_token_mismatch', + 'message' => CsrfMessage::text(), + ])->and(CsrfMessage::payload(CsrfMessage::REFERER, 'en'))->toBe([ + 'error' => 'CSRF referer mismatch', + 'code' => 'csrf_referer_mismatch', + 'message' => CsrfMessage::text(CsrfMessage::REFERER, 'en'), + ]); +}); + +it('treats an unknown key as the token refusal', function () { + expect(CsrfMessage::text('nope', 'en'))->toBe(CsrfMessage::text(CsrfMessage::TOKEN, 'en')) + ->and(CsrfMessage::payload('nope')['code'])->toBe('csrf_token_mismatch'); +}); + +it('recognises the callers that show the response body themselves', function (array $server, array $post, bool $script) { + expect(CsrfMessage::wantsScriptResponse($server, $post))->toBe($script); +})->with([ + 'XHR' => [['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'], [], true], + 'JSON client' => [['HTTP_ACCEPT' => 'application/json'], [], true], + 'AJAX login form' => [[], ['ajax' => '1'], true], + 'page load' => [['HTTP_ACCEPT' => 'text/html,*/*'], [], false], + 'ajax array' => [[], ['ajax' => ['1']], false], + 'ajax=0' => [[], ['ajax' => '0'], false], +]); + +it('is used by every CSRF check instead of a hard-coded message', function (string $file, string $removed) { + $source = file_get_contents(dirname(__DIR__, 4) . '/' . $file); + + expect($source)->toContain('CsrfMessage') + ->and($source)->not->toContain($removed); +})->with([ + ['core/src/Middleware/VerifyCsrfToken.php', 'The session token has changed or is missing'], + ['core/functions/preload.php', 'A possible CSRF attempt was detected'], + ['manager/actions/files.dynamic.php', 'Invalid token'], + ['manager/media/browser/mcpuk/browse.php', 'Invalid CSRF token.'], +]); + +it('answers a script caller of the referer check with JSON, not the alert page', function () { + $source = file_get_contents(dirname(__DIR__, 3) . '/functions/preload.php'); + $function = substr($source, strpos($source, 'function rejectManagerReferer')); + + expect(strpos($function, 'wantsScriptResponse'))->toBeLessThan(strpos($function, 'webAlertAndQuit')) + ->and($function)->toContain('http_response_code(403)') + // shutdown callbacks call evo() again after the exit; the refusal must not be printed twice + ->and(strpos($function, 'static $refused'))->toBeLessThan(strpos($function, 'wantsScriptResponse')) + // the language comes from the instance under construction: evo() would run the check again + ->and($function)->toContain("\$evo->getConfig('manager_language'"); +}); + +it('lets the login form alert only the text of a refusal', function (string $template) { + $source = file_get_contents(dirname(__DIR__, 4) . '/manager/media/style/' . $template . '/login.tpl'); + + expect($source)->not->toContain('alert(this.response)') + ->and($source)->toContain('alert(loginResponseMessage(this))') + ->and($source)->toContain('body.message') + ->and($source)->toContain("DOMParser().parseFromString(text, 'text/html')"); +})->with(['default', 'common']); diff --git a/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php b/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php index aec557222b..28bc350561 100644 --- a/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php +++ b/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php @@ -273,6 +273,20 @@ function csrfPassed(Request $request): bool ->toMatchArray(['kind' => 'text', 'status' => 403]); })->with(['text/html', '*/*', 'application/json']); +it('explains the rejection with the localized lexicon text, not markup', function () { + $middleware = new VerifyCsrfToken(); + $noop = fn () => 'next'; + + $json = $middleware->handle(csrfRequest('POST', ['a' => 109], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']), $noop); + $text = $middleware->handle(csrfRequest('GET', ['a' => 6, 'id' => 3]), $noop); + + expect($json['data'])->toBe(\EvolutionCMS\Support\CsrfMessage::payload()) + ->and($json['data']['code'])->toBe('csrf_token_mismatch') + ->and($text['data'])->toBe(\EvolutionCMS\Support\CsrfMessage::text()) + ->and($text['data'])->not->toContain('<') + ->and($text['headers']['Content-Type'])->toBe('text/plain; charset=UTF-8'); +}); + it('answers non-XHR JSON clients with a JSON rejection', function () { $request = csrfRequest('POST', ['a' => 109], ['HTTP_ACCEPT' => 'application/json']); diff --git a/core/tests/Unit/Security/ManagerRefererTest.php b/core/tests/Unit/Security/ManagerRefererTest.php new file mode 100644 index 0000000000..b7a40fac36 --- /dev/null +++ b/core/tests/Unit/Security/ManagerRefererTest.php @@ -0,0 +1,100 @@ + 'POST'], MANAGER_REFERER_SITE, $protocol); +} + +it('accepts a referer from the site itself, in any letter case', function (string $referer) { + expect(managerRefererAllows(['HTTP_REFERER' => $referer]))->toBeTrue(); +})->with(['http://example.test/sub/manager/', 'HTTP://EXAMPLE.TEST/sub/manager/index.php?a=2']); + +it('refuses a foreign referer, whatever else the request claims', function (string $referer) { + expect(managerRefererAllows(['HTTP_REFERER' => $referer, 'HTTP_SEC_FETCH_SITE' => 'same-origin']))->toBeFalse() + ->and(managerRefererAllows(['HTTP_REFERER' => $referer, 'REQUEST_METHOD' => 'GET']))->toBeFalse(); +})->with([ + 'other site' => 'http://evil.test/', + 'look-alike host' => 'http://example.test.evil.test/sub/', + 'site as a path' => 'http://evil.test/http://example.test/sub/', + 'outside the base path' => 'http://example.test/other/', + 'other port' => 'http://example.test:8080/sub/', +]); + +it('accepts a POST without a referer that the browser marks as same-origin', function () { + expect(managerRefererAllows(['HTTP_SEC_FETCH_SITE' => 'same-origin']))->toBeTrue() + ->and(managerRefererAllows(['HTTP_REFERER' => '', 'HTTP_SEC_FETCH_SITE' => 'Same-Origin']))->toBeTrue(); +}); + +it('refuses a POST without a referer from anywhere but the same origin', function (?string $site) { + $server = $site === null ? [] : ['HTTP_SEC_FETCH_SITE' => $site]; + + expect(managerRefererAllows($server))->toBeFalse() + ->and(managerRefererAllows($server + ['HTTP_REFERER' => '']))->toBeFalse(); +})->with([null, 'same-site', 'cross-site', 'none', '']); + +it('keeps letting a GET without a referer through, but not one with an empty referer', function () { + expect(managerRefererAllows(['REQUEST_METHOD' => 'GET']))->toBeTrue() + ->and(managerRefererAllows(['REQUEST_METHOD' => 'GET', 'HTTP_REFERER' => '']))->toBeFalse(); +}); + +it('accepts the https referer of an http site URL once the site is declared https', function () { + $referer = ['HTTP_REFERER' => 'https://example.test/sub/manager/']; + + expect(managerRefererAllows($referer, 'http'))->toBeFalse() + ->and(managerRefererAllows($referer, 'https'))->toBeTrue() + ->and(ManagerReferer::siteUrls(MANAGER_REFERER_SITE, 'https')) + ->toBe([MANAGER_REFERER_SITE, 'https://example.test/sub/']); +}); + +it('never lets http count for an https site', function () { + expect(ManagerReferer::allows( + ['REQUEST_METHOD' => 'POST', 'HTTP_REFERER' => 'http://example.test/sub/manager/'], + 'https://example.test/sub/', + 'https' + ))->toBeFalse(); +}); + +it('is what evo() asks, with the Server type setting', function () { + $source = file_get_contents(dirname(__DIR__, 3) . '/functions/preload.php'); + + expect($source)->toContain('ManagerReferer::allows(') + ->and($source)->toContain("getConfig('server_protocol'") + ->and($source)->not->toContain("preg_quote(EVO_SITE_URL"); +}); + +it('asks the browser for a same-origin referrer on every manager page that posts', function (string $file) { + expect(file_get_contents(dirname(__DIR__, 4) . '/' . $file)) + ->toContain(''); +})->with([ + 'manager/media/style/default/login.tpl', + 'manager/media/style/common/login.tpl', + 'manager/media/style/default/manager.lockout.tpl', + 'manager/media/style/common/manager.lockout.tpl', + 'manager/views/frame/1.blade.php', + 'manager/views/partials/header.blade.php', + 'manager/media/browser/mcpuk/tpl/tpl_browser.php', +]); + +it('sends the same policy as a header before the referer check runs', function () { + $index = file_get_contents(dirname(__DIR__, 4) . '/manager/index.php'); + + expect($index)->toContain("header('Referrer-Policy: same-origin');") + ->and(strpos($index, "header('Referrer-Policy: same-origin');"))->toBeLessThan(strpos($index, '= evo();')); +}); diff --git a/core/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index 105b3d84d9..895400df33 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -139,6 +139,7 @@ 'Carbon\\WrapperClock' => $vendorDir . '/nesbot/carbon/src/Carbon/WrapperClock.php', 'Composer\\Advisory\\AuditConfig' => $vendorDir . '/composer/composer/src/Composer/Advisory/AuditConfig.php', 'Composer\\Advisory\\Auditor' => $vendorDir . '/composer/composer/src/Composer/Advisory/Auditor.php', + 'Composer\\Advisory\\CooldownAuditor' => $vendorDir . '/composer/composer/src/Composer/Advisory/CooldownAuditor.php', 'Composer\\Advisory\\IgnoredSecurityAdvisory' => $vendorDir . '/composer/composer/src/Composer/Advisory/IgnoredSecurityAdvisory.php', 'Composer\\Advisory\\PartialSecurityAdvisory' => $vendorDir . '/composer/composer/src/Composer/Advisory/PartialSecurityAdvisory.php', 'Composer\\Advisory\\SecurityAdvisory' => $vendorDir . '/composer/composer/src/Composer/Advisory/SecurityAdvisory.php', @@ -196,11 +197,18 @@ 'Composer\\Config' => $vendorDir . '/composer/composer/src/Composer/Config.php', 'Composer\\Config\\ConfigSourceInterface' => $vendorDir . '/composer/composer/src/Composer/Config/ConfigSourceInterface.php', 'Composer\\Config\\JsonConfigSource' => $vendorDir . '/composer/composer/src/Composer/Config/JsonConfigSource.php', + 'Composer\\Console\\AbstractOscProgramStatusEncoder' => $vendorDir . '/composer/composer/src/Composer/Console/AbstractOscProgramStatusEncoder.php', 'Composer\\Console\\Application' => $vendorDir . '/composer/composer/src/Composer/Console/Application.php', 'Composer\\Console\\GithubActionError' => $vendorDir . '/composer/composer/src/Composer/Console/GithubActionError.php', 'Composer\\Console\\HtmlOutputFormatter' => $vendorDir . '/composer/composer/src/Composer/Console/HtmlOutputFormatter.php', 'Composer\\Console\\Input\\InputArgument' => $vendorDir . '/composer/composer/src/Composer/Console/Input/InputArgument.php', 'Composer\\Console\\Input\\InputOption' => $vendorDir . '/composer/composer/src/Composer/Console/Input/InputOption.php', + 'Composer\\Console\\Osc7501ProgramStatusEncoder' => $vendorDir . '/composer/composer/src/Composer/Console/Osc7501ProgramStatusEncoder.php', + 'Composer\\Console\\Osc9ProgressEncoder' => $vendorDir . '/composer/composer/src/Composer/Console/Osc9ProgressEncoder.php', + 'Composer\\Console\\OscHelper' => $vendorDir . '/composer/composer/src/Composer/Console/OscHelper.php', + 'Composer\\Console\\ProgramStatus' => $vendorDir . '/composer/composer/src/Composer/Console/ProgramStatus.php', + 'Composer\\Console\\TerminalStatusPolicy' => $vendorDir . '/composer/composer/src/Composer/Console/TerminalStatusPolicy.php', + 'Composer\\DependencyResolver\\CooldownPoolFilter' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/CooldownPoolFilter.php', 'Composer\\DependencyResolver\\Decisions' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/Decisions.php', 'Composer\\DependencyResolver\\DefaultPolicy' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/DefaultPolicy.php', 'Composer\\DependencyResolver\\FilterListPoolFilter' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/FilterListPoolFilter.php', @@ -216,6 +224,7 @@ 'Composer\\DependencyResolver\\Operation\\UninstallOperation' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/Operation/UninstallOperation.php', 'Composer\\DependencyResolver\\Operation\\UpdateOperation' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/Operation/UpdateOperation.php', 'Composer\\DependencyResolver\\PolicyInterface' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/PolicyInterface.php', + 'Composer\\DependencyResolver\\PolicyRemovalReason' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/PolicyRemovalReason.php', 'Composer\\DependencyResolver\\Pool' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/Pool.php', 'Composer\\DependencyResolver\\PoolBuilder' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/PoolBuilder.php', 'Composer\\DependencyResolver\\PoolOptimizer' => $vendorDir . '/composer/composer/src/Composer/DependencyResolver/PoolOptimizer.php', @@ -376,6 +385,7 @@ 'Composer\\Plugin\\PrePoolCreateEvent' => $vendorDir . '/composer/composer/src/Composer/Plugin/PrePoolCreateEvent.php', 'Composer\\Policy\\AbandonedPolicyConfig' => $vendorDir . '/composer/composer/src/Composer/Policy/AbandonedPolicyConfig.php', 'Composer\\Policy\\AdvisoriesPolicyConfig' => $vendorDir . '/composer/composer/src/Composer/Policy/AdvisoriesPolicyConfig.php', + 'Composer\\Policy\\CooldownPolicyConfig' => $vendorDir . '/composer/composer/src/Composer/Policy/CooldownPolicyConfig.php', 'Composer\\Policy\\CustomListPolicyConfig' => $vendorDir . '/composer/composer/src/Composer/Policy/CustomListPolicyConfig.php', 'Composer\\Policy\\IgnoreIdRule' => $vendorDir . '/composer/composer/src/Composer/Policy/IgnoreIdRule.php', 'Composer\\Policy\\IgnorePackageRule' => $vendorDir . '/composer/composer/src/Composer/Policy/IgnorePackageRule.php', @@ -464,6 +474,8 @@ 'Composer\\Util\\Http\\ProxyManager' => $vendorDir . '/composer/composer/src/Composer/Util/Http/ProxyManager.php', 'Composer\\Util\\Http\\RequestProxy' => $vendorDir . '/composer/composer/src/Composer/Util/Http/RequestProxy.php', 'Composer\\Util\\Http\\Response' => $vendorDir . '/composer/composer/src/Composer/Util/Http/Response.php', + 'Composer\\Util\\Http\\ResponseWarnings' => $vendorDir . '/composer/composer/src/Composer/Util/Http/ResponseWarnings.php', + 'Composer\\Util\\Http\\TlsChainDenylist' => $vendorDir . '/composer/composer/src/Composer/Util/Http/TlsChainDenylist.php', 'Composer\\Util\\IniHelper' => $vendorDir . '/composer/composer/src/Composer/Util/IniHelper.php', 'Composer\\Util\\Loop' => $vendorDir . '/composer/composer/src/Composer/Util/Loop.php', 'Composer\\Util\\MetadataMinifier' => $vendorDir . '/composer/composer/src/Composer/Util/MetadataMinifier.php', @@ -1425,6 +1437,7 @@ 'EvolutionCMS\\Support\\ChunkFileStore' => $baseDir . '/src/Support/ChunkFileStore.php', 'EvolutionCMS\\Support\\ClientIp' => $baseDir . '/src/Support/ClientIp.php', 'EvolutionCMS\\Support\\ContextMenu' => $baseDir . '/src/Support/ContextMenu.php', + 'EvolutionCMS\\Support\\CsrfMessage' => $baseDir . '/src/Support/CsrfMessage.php', 'EvolutionCMS\\Support\\DataGrid' => $baseDir . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => $baseDir . '/src/Support/DataSetPager.php', 'EvolutionCMS\\Support\\DocBlock' => $baseDir . '/src/Support/DocBlock.php', @@ -1443,6 +1456,7 @@ 'EvolutionCMS\\Support\\MailTestMailer' => $baseDir . '/src/Support/MailTestMailer.php', 'EvolutionCMS\\Support\\MailTestSmtp' => $baseDir . '/src/Support/MailTestSmtp.php', 'EvolutionCMS\\Support\\MakeTable' => $baseDir . '/src/Support/MakeTable.php', + 'EvolutionCMS\\Support\\ManagerReferer' => $baseDir . '/src/Support/ManagerReferer.php', 'EvolutionCMS\\Support\\Menu' => $baseDir . '/src/Support/Menu.php', 'EvolutionCMS\\Support\\ModuleAccess' => $baseDir . '/src/Support/ModuleAccess.php', 'EvolutionCMS\\Support\\MoveDocumentTargetGuard' => $baseDir . '/src/Support/MoveDocumentTargetGuard.php', @@ -2816,6 +2830,7 @@ 'JsonSchema\\Constraints\\Drafts\\Draft2019\\RefConstraint' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/RefConstraint.php', 'JsonSchema\\Constraints\\Drafts\\Draft2019\\RequiredConstraint' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/RequiredConstraint.php', 'JsonSchema\\Constraints\\Drafts\\Draft2019\\TypeConstraint' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/TypeConstraint.php', + 'JsonSchema\\Constraints\\Drafts\\Draft2019\\UnevaluatedPropertiesConstraint' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php', 'JsonSchema\\Constraints\\Drafts\\Draft2019\\UniqueItemsConstraint' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/UniqueItemsConstraint.php', 'JsonSchema\\Constraints\\EnumConstraint' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Constraints/EnumConstraint.php', 'JsonSchema\\Constraints\\Factory' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Factory.php', @@ -2852,6 +2867,7 @@ 'JsonSchema\\SchemaStorageInterface' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/SchemaStorageInterface.php', 'JsonSchema\\Tool\\DeepComparer' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Tool/DeepComparer.php', 'JsonSchema\\Tool\\DeepCopy' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Tool/DeepCopy.php', + 'JsonSchema\\Tool\\EcmaPatternConverter' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Tool/EcmaPatternConverter.php', 'JsonSchema\\Tool\\Validator\\RelativeReferenceValidator' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php', 'JsonSchema\\Tool\\Validator\\UriValidator' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/Tool/Validator/UriValidator.php', 'JsonSchema\\UriResolverInterface' => $vendorDir . '/justinrainbow/json-schema/src/JsonSchema/UriResolverInterface.php', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index 0cb2fd5c1c..8ea51cccca 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -806,6 +806,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'Carbon\\WrapperClock' => __DIR__ . '/..' . '/nesbot/carbon/src/Carbon/WrapperClock.php', 'Composer\\Advisory\\AuditConfig' => __DIR__ . '/..' . '/composer/composer/src/Composer/Advisory/AuditConfig.php', 'Composer\\Advisory\\Auditor' => __DIR__ . '/..' . '/composer/composer/src/Composer/Advisory/Auditor.php', + 'Composer\\Advisory\\CooldownAuditor' => __DIR__ . '/..' . '/composer/composer/src/Composer/Advisory/CooldownAuditor.php', 'Composer\\Advisory\\IgnoredSecurityAdvisory' => __DIR__ . '/..' . '/composer/composer/src/Composer/Advisory/IgnoredSecurityAdvisory.php', 'Composer\\Advisory\\PartialSecurityAdvisory' => __DIR__ . '/..' . '/composer/composer/src/Composer/Advisory/PartialSecurityAdvisory.php', 'Composer\\Advisory\\SecurityAdvisory' => __DIR__ . '/..' . '/composer/composer/src/Composer/Advisory/SecurityAdvisory.php', @@ -863,11 +864,18 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'Composer\\Config' => __DIR__ . '/..' . '/composer/composer/src/Composer/Config.php', 'Composer\\Config\\ConfigSourceInterface' => __DIR__ . '/..' . '/composer/composer/src/Composer/Config/ConfigSourceInterface.php', 'Composer\\Config\\JsonConfigSource' => __DIR__ . '/..' . '/composer/composer/src/Composer/Config/JsonConfigSource.php', + 'Composer\\Console\\AbstractOscProgramStatusEncoder' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/AbstractOscProgramStatusEncoder.php', 'Composer\\Console\\Application' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/Application.php', 'Composer\\Console\\GithubActionError' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/GithubActionError.php', 'Composer\\Console\\HtmlOutputFormatter' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/HtmlOutputFormatter.php', 'Composer\\Console\\Input\\InputArgument' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/Input/InputArgument.php', 'Composer\\Console\\Input\\InputOption' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/Input/InputOption.php', + 'Composer\\Console\\Osc7501ProgramStatusEncoder' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/Osc7501ProgramStatusEncoder.php', + 'Composer\\Console\\Osc9ProgressEncoder' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/Osc9ProgressEncoder.php', + 'Composer\\Console\\OscHelper' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/OscHelper.php', + 'Composer\\Console\\ProgramStatus' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/ProgramStatus.php', + 'Composer\\Console\\TerminalStatusPolicy' => __DIR__ . '/..' . '/composer/composer/src/Composer/Console/TerminalStatusPolicy.php', + 'Composer\\DependencyResolver\\CooldownPoolFilter' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/CooldownPoolFilter.php', 'Composer\\DependencyResolver\\Decisions' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/Decisions.php', 'Composer\\DependencyResolver\\DefaultPolicy' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/DefaultPolicy.php', 'Composer\\DependencyResolver\\FilterListPoolFilter' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/FilterListPoolFilter.php', @@ -883,6 +891,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'Composer\\DependencyResolver\\Operation\\UninstallOperation' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/Operation/UninstallOperation.php', 'Composer\\DependencyResolver\\Operation\\UpdateOperation' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/Operation/UpdateOperation.php', 'Composer\\DependencyResolver\\PolicyInterface' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/PolicyInterface.php', + 'Composer\\DependencyResolver\\PolicyRemovalReason' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/PolicyRemovalReason.php', 'Composer\\DependencyResolver\\Pool' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/Pool.php', 'Composer\\DependencyResolver\\PoolBuilder' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/PoolBuilder.php', 'Composer\\DependencyResolver\\PoolOptimizer' => __DIR__ . '/..' . '/composer/composer/src/Composer/DependencyResolver/PoolOptimizer.php', @@ -1043,6 +1052,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'Composer\\Plugin\\PrePoolCreateEvent' => __DIR__ . '/..' . '/composer/composer/src/Composer/Plugin/PrePoolCreateEvent.php', 'Composer\\Policy\\AbandonedPolicyConfig' => __DIR__ . '/..' . '/composer/composer/src/Composer/Policy/AbandonedPolicyConfig.php', 'Composer\\Policy\\AdvisoriesPolicyConfig' => __DIR__ . '/..' . '/composer/composer/src/Composer/Policy/AdvisoriesPolicyConfig.php', + 'Composer\\Policy\\CooldownPolicyConfig' => __DIR__ . '/..' . '/composer/composer/src/Composer/Policy/CooldownPolicyConfig.php', 'Composer\\Policy\\CustomListPolicyConfig' => __DIR__ . '/..' . '/composer/composer/src/Composer/Policy/CustomListPolicyConfig.php', 'Composer\\Policy\\IgnoreIdRule' => __DIR__ . '/..' . '/composer/composer/src/Composer/Policy/IgnoreIdRule.php', 'Composer\\Policy\\IgnorePackageRule' => __DIR__ . '/..' . '/composer/composer/src/Composer/Policy/IgnorePackageRule.php', @@ -1131,6 +1141,8 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'Composer\\Util\\Http\\ProxyManager' => __DIR__ . '/..' . '/composer/composer/src/Composer/Util/Http/ProxyManager.php', 'Composer\\Util\\Http\\RequestProxy' => __DIR__ . '/..' . '/composer/composer/src/Composer/Util/Http/RequestProxy.php', 'Composer\\Util\\Http\\Response' => __DIR__ . '/..' . '/composer/composer/src/Composer/Util/Http/Response.php', + 'Composer\\Util\\Http\\ResponseWarnings' => __DIR__ . '/..' . '/composer/composer/src/Composer/Util/Http/ResponseWarnings.php', + 'Composer\\Util\\Http\\TlsChainDenylist' => __DIR__ . '/..' . '/composer/composer/src/Composer/Util/Http/TlsChainDenylist.php', 'Composer\\Util\\IniHelper' => __DIR__ . '/..' . '/composer/composer/src/Composer/Util/IniHelper.php', 'Composer\\Util\\Loop' => __DIR__ . '/..' . '/composer/composer/src/Composer/Util/Loop.php', 'Composer\\Util\\MetadataMinifier' => __DIR__ . '/..' . '/composer/composer/src/Composer/Util/MetadataMinifier.php', @@ -2092,6 +2104,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\ChunkFileStore' => __DIR__ . '/../..' . '/src/Support/ChunkFileStore.php', 'EvolutionCMS\\Support\\ClientIp' => __DIR__ . '/../..' . '/src/Support/ClientIp.php', 'EvolutionCMS\\Support\\ContextMenu' => __DIR__ . '/../..' . '/src/Support/ContextMenu.php', + 'EvolutionCMS\\Support\\CsrfMessage' => __DIR__ . '/../..' . '/src/Support/CsrfMessage.php', 'EvolutionCMS\\Support\\DataGrid' => __DIR__ . '/../..' . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => __DIR__ . '/../..' . '/src/Support/DataSetPager.php', 'EvolutionCMS\\Support\\DocBlock' => __DIR__ . '/../..' . '/src/Support/DocBlock.php', @@ -2110,6 +2123,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\MailTestMailer' => __DIR__ . '/../..' . '/src/Support/MailTestMailer.php', 'EvolutionCMS\\Support\\MailTestSmtp' => __DIR__ . '/../..' . '/src/Support/MailTestSmtp.php', 'EvolutionCMS\\Support\\MakeTable' => __DIR__ . '/../..' . '/src/Support/MakeTable.php', + 'EvolutionCMS\\Support\\ManagerReferer' => __DIR__ . '/../..' . '/src/Support/ManagerReferer.php', 'EvolutionCMS\\Support\\Menu' => __DIR__ . '/../..' . '/src/Support/Menu.php', 'EvolutionCMS\\Support\\ModuleAccess' => __DIR__ . '/../..' . '/src/Support/ModuleAccess.php', 'EvolutionCMS\\Support\\MoveDocumentTargetGuard' => __DIR__ . '/../..' . '/src/Support/MoveDocumentTargetGuard.php', @@ -3483,6 +3497,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'JsonSchema\\Constraints\\Drafts\\Draft2019\\RefConstraint' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/RefConstraint.php', 'JsonSchema\\Constraints\\Drafts\\Draft2019\\RequiredConstraint' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/RequiredConstraint.php', 'JsonSchema\\Constraints\\Drafts\\Draft2019\\TypeConstraint' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/TypeConstraint.php', + 'JsonSchema\\Constraints\\Drafts\\Draft2019\\UnevaluatedPropertiesConstraint' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php', 'JsonSchema\\Constraints\\Drafts\\Draft2019\\UniqueItemsConstraint' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/UniqueItemsConstraint.php', 'JsonSchema\\Constraints\\EnumConstraint' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Constraints/EnumConstraint.php', 'JsonSchema\\Constraints\\Factory' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Constraints/Factory.php', @@ -3519,6 +3534,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'JsonSchema\\SchemaStorageInterface' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/SchemaStorageInterface.php', 'JsonSchema\\Tool\\DeepComparer' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Tool/DeepComparer.php', 'JsonSchema\\Tool\\DeepCopy' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Tool/DeepCopy.php', + 'JsonSchema\\Tool\\EcmaPatternConverter' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Tool/EcmaPatternConverter.php', 'JsonSchema\\Tool\\Validator\\RelativeReferenceValidator' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php', 'JsonSchema\\Tool\\Validator\\UriValidator' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/Tool/Validator/UriValidator.php', 'JsonSchema\\UriResolverInterface' => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema/UriResolverInterface.php', diff --git a/core/vendor/composer/ca-bundle/src/CaBundle.php b/core/vendor/composer/ca-bundle/src/CaBundle.php index 95311c05b7..e670439104 100644 --- a/core/vendor/composer/ca-bundle/src/CaBundle.php +++ b/core/vendor/composer/ca-bundle/src/CaBundle.php @@ -254,7 +254,7 @@ public static function findDistrustedCertificate(array $chain) */ private static function isX509Certificate($cert) { - return is_a($cert, 'OpenSSLCertificate') || (is_resource($cert) && get_resource_type($cert) === 'OpenSSL X.509'); + return $cert instanceof \OpenSSLCertificate || (is_resource($cert) && get_resource_type($cert) === 'OpenSSL X.509'); } /** diff --git a/core/vendor/composer/composer/composer.json b/core/vendor/composer/composer/composer.json index ad5ca04b56..9aede43555 100644 --- a/core/vendor/composer/composer/composer.json +++ b/core/vendor/composer/composer/composer.json @@ -26,13 +26,13 @@ "ext-filter": "*", "ext-hash": "*", "ext-json": "*", - "composer/ca-bundle": "^1.5", - "composer/class-map-generator": "^1.4.0", + "composer/ca-bundle": "^1.6", + "composer/class-map-generator": "^1.8.0", "composer/metadata-minifier": "^1.0", "composer/semver": "^3.3", "composer/spdx-licenses": "^1.5.7", "composer/xdebug-handler": "^2.0.2 || ^3.0.3", - "justinrainbow/json-schema": "^6.5.1", + "justinrainbow/json-schema": "^6.6.3", "psr/log": "^1.0 || ^2.0 || ^3.0", "seld/jsonlint": "^1.4", "seld/phar-utils": "^1.2", @@ -50,7 +50,7 @@ }, "require-dev": { "symfony/phpunit-bridge": "^6.4.25 || ^7.3.3 || ^8.0", - "phpstan/phpstan": "^1.11.8", + "phpstan/phpstan": "1.12.33", "phpstan/phpstan-phpunit": "^1.4.0", "phpstan/phpstan-deprecation-rules": "^1.2.0", "phpstan/phpstan-strict-rules": "^1.6.0", @@ -66,11 +66,19 @@ "platform": { "php": "7.2.5" }, - "platform-check": false + "platform-check": false, + "policy": { + "cooldown": { + "period": "7days", + "ignore": { + "composer/ca-bundle": {"constraint": "1.6.0", "reason": "Required and approved"} + } + } + } }, "extra": { "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" }, "phpstan": { "includes": [ diff --git a/core/vendor/composer/composer/composer.lock b/core/vendor/composer/composer/composer.lock index d04221e977..57897b91c3 100644 --- a/core/vendor/composer/composer/composer.lock +++ b/core/vendor/composer/composer/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c476757337544dcb83275f61d8692ed0", + "content-hash": "cbaf09bc6db95f62472e350138eaedcb", "packages": [ { "name": "composer/ca-bundle", - "version": "1.5.12", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" + "reference": "2c99cf2e92516985dea4914812b93d9daf01cdd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/2c99cf2e92516985dea4914812b93d9daf01cdd6", + "reference": "2c99cf2e92516985dea4914812b93d9daf01cdd6", "shasum": "" }, "require": { @@ -64,7 +64,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.12" + "source": "https://github.com/composer/ca-bundle/tree/1.6.0" }, "funding": [ { @@ -76,20 +76,21 @@ "type": "github" } ], - "time": "2026-05-19T11:26:22+00:00" + "time": "2026-10-20T20:14:05+00:00", + "published-time": "2026-10-20T20:17:23+00:00" }, { "name": "composer/class-map-generator", - "version": "1.7.3", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/composer/class-map-generator.git", - "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f" + "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f", - "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", + "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", "shasum": "" }, "require": { @@ -133,7 +134,7 @@ ], "support": { "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.7.3" + "source": "https://github.com/composer/class-map-generator/tree/1.8.0" }, "funding": [ { @@ -145,7 +146,8 @@ "type": "github" } ], - "time": "2026-05-05T09:17:07+00:00" + "time": "2026-09-25T15:24:19+00:00", + "published-time": "2026-09-25T15:27:30+00:00" }, { "name": "composer/metadata-minifier", @@ -210,7 +212,8 @@ "type": "github" } ], - "time": "2026-06-30T11:34:59+00:00" + "time": "2026-06-30T11:34:59+00:00", + "published-time": "2026-06-30T11:37:18+00:00" }, { "name": "composer/pcre", @@ -289,20 +292,21 @@ "type": "tidelift" } ], - "time": "2024-11-12T16:24:47+00:00" + "time": "2024-11-12T16:24:47+00:00", + "published-time": "2024-11-12T16:38:23+00:00" }, { "name": "composer/semver", - "version": "3.4.4", + "version": "3.6.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/composer/semver/zipball/d92543b7ede9c4ec6b59853cefb061e39866d2cf", + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf", "shasum": "" }, "require": { @@ -344,7 +348,7 @@ "homepage": "http://robbast.nl" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", "keywords": [ "semantic", "semver", @@ -354,7 +358,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "source": "https://github.com/composer/semver/tree/3.6.0" }, "funding": [ { @@ -366,7 +370,8 @@ "type": "github" } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2026-10-29T09:13:34+00:00", + "published-time": "2026-10-29T09:14:11+00:00" }, { "name": "composer/spdx-licenses", @@ -442,7 +447,8 @@ "type": "github" } ], - "time": "2026-04-08T20:18:39+00:00" + "time": "2026-04-08T20:18:39+00:00", + "published-time": "2026-04-08T20:19:56+00:00" }, { "name": "composer/xdebug-handler", @@ -508,23 +514,25 @@ "type": "tidelift" } ], - "time": "2024-05-06T16:37:16+00:00" + "time": "2024-05-06T16:37:16+00:00", + "published-time": "2024-05-06T17:01:42+00:00" }, { "name": "justinrainbow/json-schema", - "version": "6.10.0", + "version": "6.13.1", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" + "reference": "e54aacc4c0657b3a4af4f628428d2702146828c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", - "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/e54aacc4c0657b3a4af4f628428d2702146828c6", + "reference": "e54aacc4c0657b3a4af4f628428d2702146828c6", "shasum": "" }, "require": { + "ext-filter": "*", "ext-json": "*", "marc-mabe/php-enum": "^4.4", "php": "^7.2 || ^8.0" @@ -557,20 +565,9 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "name": "Danny van der Sluijs", + "email": "danny.vandersluijs@icloud.com", + "role": "Maintainer" } ], "description": "A library to validate a json schema.", @@ -581,9 +578,10 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.13.1" }, - "time": "2026-06-16T20:50:26+00:00" + "time": "2026-09-30T07:42:28+00:00", + "published-time": "2026-09-30T07:42:33+00:00" }, { "name": "marc-mabe/php-enum", @@ -656,7 +654,8 @@ "issues": "https://github.com/marc-mabe/php-enum/issues", "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" }, - "time": "2025-09-14T11:18:39+00:00" + "time": "2025-09-14T11:18:39+00:00", + "published-time": "2025-09-14T11:32:17+00:00" }, { "name": "psr/container", @@ -704,7 +703,8 @@ "issues": "https://github.com/php-fig/container/issues", "source": "https://github.com/php-fig/container/tree/1.1.1" }, - "time": "2021-03-05T17:36:06+00:00" + "time": "2021-03-05T17:36:06+00:00", + "published-time": "2021-03-05T17:38:32+00:00" }, { "name": "psr/log", @@ -754,7 +754,8 @@ "support": { "source": "https://github.com/php-fig/log/tree/1.1.4" }, - "time": "2021-05-03T11:20:27+00:00" + "time": "2021-05-03T11:20:27+00:00", + "published-time": "2021-05-03T11:22:56+00:00" }, { "name": "react/promise", @@ -827,7 +828,8 @@ "type": "open_collective" } ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2025-08-19T18:57:03+00:00", + "published-time": "2025-08-19T19:13:36+00:00" }, { "name": "seld/jsonlint", @@ -891,20 +893,21 @@ "type": "tidelift" } ], - "time": "2026-06-12T11:32:29+00:00" + "time": "2026-06-12T11:32:29+00:00", + "published-time": "2026-06-12T12:30:14+00:00" }, { "name": "seld/phar-utils", - "version": "1.2.1", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/Seldaek/phar-utils.git", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + "reference": "65fb381613fd31745d39a740a112a233f978d2d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/65fb381613fd31745d39a740a112a233f978d2d9", + "reference": "65fb381613fd31745d39a740a112a233f978d2d9", "shasum": "" }, "require": { @@ -937,9 +940,10 @@ ], "support": { "issues": "https://github.com/Seldaek/phar-utils/issues", - "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.3" }, - "time": "2022-08-31T10:31:18+00:00" + "time": "2026-10-01T08:16:23+00:00", + "published-time": "2026-10-01T08:17:41+00:00" }, { "name": "seld/signal-handler", @@ -1000,20 +1004,21 @@ "issues": "https://github.com/Seldaek/signal-handler/issues", "source": "https://github.com/Seldaek/signal-handler/tree/2.0.2" }, - "time": "2023-09-03T09:24:00+00:00" + "time": "2023-09-03T09:24:00+00:00", + "published-time": "2023-09-03T09:24:36+00:00" }, { "name": "symfony/console", - "version": "v5.4.47", + "version": "v5.4.54", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed" + "reference": "68a4a45af9fcb8c9bee567a88ae29e721fb22986" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed", - "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed", + "url": "https://api.github.com/repos/symfony/console/zipball/68a4a45af9fcb8c9bee567a88ae29e721fb22986", + "reference": "68a4a45af9fcb8c9bee567a88ae29e721fb22986", "shasum": "" }, "require": { @@ -1083,7 +1088,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.47" + "source": "https://github.com/symfony/console/tree/v5.4.54" }, "funding": [ { @@ -1094,12 +1099,17 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-06T11:30:55+00:00" + "time": "2026-10-07T15:50:15+00:00", + "published-time": "2026-10-15T06:51:54+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1166,20 +1176,21 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:11:13+00:00" + "time": "2024-09-25T14:11:13+00:00", + "published-time": "2024-11-27T12:25:26+00:00" }, { "name": "symfony/filesystem", - "version": "v5.4.45", + "version": "v5.4.54", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "57c8294ed37d4a055b77057827c67f9558c95c54" + "reference": "c4a6cc5733386414f6d17d744ee5ad8b445e349d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/57c8294ed37d4a055b77057827c67f9558c95c54", - "reference": "57c8294ed37d4a055b77057827c67f9558c95c54", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c4a6cc5733386414f6d17d744ee5ad8b445e349d", + "reference": "c4a6cc5733386414f6d17d744ee5ad8b445e349d", "shasum": "" }, "require": { @@ -1217,7 +1228,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v5.4.45" + "source": "https://github.com/symfony/filesystem/tree/v5.4.54" }, "funding": [ { @@ -1228,12 +1239,17 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-22T13:05:35+00:00" + "time": "2026-08-30T06:54:06+00:00", + "published-time": "2026-10-15T06:51:49+00:00" }, { "name": "symfony/finder", @@ -1296,7 +1312,8 @@ "type": "tidelift" } ], - "time": "2024-09-28T13:32:08+00:00" + "time": "2024-09-28T13:32:08+00:00", + "published-time": "2024-10-27T12:57:19+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1379,20 +1396,21 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-04-10T16:19:22+00:00", + "published-time": "2026-04-26T14:44:50+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", "shasum": "" }, "require": { @@ -1441,7 +1459,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" }, "funding": [ { @@ -1461,20 +1479,21 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-09-25T15:46:47+00:00", + "published-time": "2026-09-30T10:23:37+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", "shasum": "" }, "require": { @@ -1526,7 +1545,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" }, "funding": [ { @@ -1546,20 +1565,21 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-09-23T22:02:09+00:00", + "published-time": "2026-09-30T10:23:47+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", "shasum": "" }, "require": { @@ -1611,7 +1631,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" }, "funding": [ { @@ -1631,7 +1651,8 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-09-24T15:19:04+00:00", + "published-time": "2026-09-30T10:23:35+00:00" }, { "name": "symfony/polyfill-php73", @@ -1711,20 +1732,21 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-09-09T11:45:10+00:00", + "published-time": "2026-04-26T14:44:49+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", "shasum": "" }, "require": { @@ -1775,7 +1797,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" }, "funding": [ { @@ -1795,7 +1817,8 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-09-25T15:50:05+00:00", + "published-time": "2026-09-30T10:23:35+00:00" }, { "name": "symfony/polyfill-php81", @@ -1875,20 +1898,21 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:45:58+00:00" + "time": "2026-05-26T12:45:58+00:00", + "published-time": "2026-05-26T13:40:34+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.38.1", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", "shasum": "" }, "require": { @@ -1935,7 +1959,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" }, "funding": [ { @@ -1955,20 +1979,21 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-09-23T22:01:09+00:00", + "published-time": "2026-09-30T10:23:33+00:00" }, { "name": "symfony/process", - "version": "v5.4.51", + "version": "v5.4.54", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f" + "reference": "485d7b3f15ae329058d2407c9772c57ed4f564de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f", - "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f", + "url": "https://api.github.com/repos/symfony/process/zipball/485d7b3f15ae329058d2407c9772c57ed4f564de", + "reference": "485d7b3f15ae329058d2407c9772c57ed4f564de", "shasum": "" }, "require": { @@ -2001,7 +2026,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.4.51" + "source": "https://github.com/symfony/process/tree/v5.4.54" }, "funding": [ { @@ -2021,7 +2046,8 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:53:37+00:00" + "time": "2026-09-01T06:19:14+00:00", + "published-time": "2026-10-15T06:52:18+00:00" }, { "name": "symfony/service-contracts", @@ -2104,7 +2130,8 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:11:13+00:00" + "time": "2024-09-25T14:11:13+00:00", + "published-time": "2024-11-27T12:25:27+00:00" }, { "name": "symfony/string", @@ -2190,7 +2217,8 @@ "type": "tidelift" } ], - "time": "2024-11-10T20:33:58+00:00" + "time": "2024-11-10T20:33:58+00:00", + "published-time": "2024-11-13T13:51:58+00:00" } ], "packages-dev": [ @@ -2245,7 +2273,8 @@ "type": "github" } ], - "time": "2026-02-28T20:30:03+00:00" + "time": "2026-02-28T20:30:03+00:00", + "published-time": "2026-02-28T20:30:15+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -2292,7 +2321,8 @@ "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/1.2.1" }, - "time": "2024-09-11T15:52:35+00:00" + "time": "2024-09-11T15:52:35+00:00", + "published-time": "2024-09-11T15:53:17+00:00" }, { "name": "phpstan/phpstan-phpunit", @@ -2344,7 +2374,8 @@ "issues": "https://github.com/phpstan/phpstan-phpunit/issues", "source": "https://github.com/phpstan/phpstan-phpunit/tree/1.4.2" }, - "time": "2024-12-17T17:20:49+00:00" + "time": "2024-12-17T17:20:49+00:00", + "published-time": "2024-12-17T17:23:46+00:00" }, { "name": "phpstan/phpstan-strict-rules", @@ -2393,7 +2424,8 @@ "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.6.2" }, - "time": "2025-01-19T13:02:24+00:00" + "time": "2025-01-19T13:02:24+00:00", + "published-time": "2025-01-19T13:04:58+00:00" }, { "name": "phpstan/phpstan-symfony", @@ -2465,7 +2497,8 @@ "issues": "https://github.com/phpstan/phpstan-symfony/issues", "source": "https://github.com/phpstan/phpstan-symfony/tree/1.4.16" }, - "time": "2025-09-07T06:55:28+00:00" + "time": "2025-09-07T06:55:28+00:00", + "published-time": "2025-09-07T06:56:13+00:00" }, { "name": "symfony/phpunit-bridge", @@ -2554,7 +2587,8 @@ "type": "tidelift" } ], - "time": "2025-09-12T12:18:52+00:00" + "time": "2025-09-12T12:18:52+00:00", + "published-time": "2025-09-27T12:35:42+00:00" } ], "aliases": [], @@ -2572,5 +2606,5 @@ "platform-overrides": { "php": "7.2.5" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.11.0" } diff --git a/core/vendor/composer/composer/res/composer-repository-schema.json b/core/vendor/composer/composer/res/composer-repository-schema.json index 870495ce31..899e724edc 100644 --- a/core/vendor/composer/composer/res/composer-repository-schema.json +++ b/core/vendor/composer/composer/res/composer-repository-schema.json @@ -194,7 +194,11 @@ "replace": { "type": "object" }, "conflict": { "type": "object" }, "provide": { "type": "object" }, - "time": { "type": "string" } + "time": { "type": "string" }, + "published-time": { + "type": "string", + "description": "Repository-set publication timestamp (UTC, ISO 8601 / RFC 3339). Unlike 'time' this is owned by the repository and not influenced by the package author; it is preferred by the cooldown policy when present." + } }, "additionalProperties": true }, diff --git a/core/vendor/composer/composer/res/composer-schema.json b/core/vendor/composer/composer/res/composer-schema.json index 20cd485244..3a7f9f6337 100644 --- a/core/vendor/composer/composer/res/composer-schema.json +++ b/core/vendor/composer/composer/res/composer-schema.json @@ -692,6 +692,39 @@ } ] }, + "cooldown": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "description": "Configuration for the cooldown policy, which withholds package versions published more recently than the configured period during update/require.", + "properties": { + "period": { + "description": "Cooldown duration as a string in the form \" \" where unit is second, minute, hour, day or week (e.g. \"7 days\", \"24 hours\", \"30 minutes\", \"1 week\"), an integer number of seconds, or null to disable. Relative phrases like \"tomorrow\" are not accepted. Defaults to null (no cooldown).", + "anyOf": [ + { "type": "string", "pattern": "^(\\d+\\s*(second|minute|hour|day|week)s?|\\d+)?$" }, + { "type": "integer", "minimum": 0 }, + { "type": "null" } + ] + }, + "block": { + "type": "boolean", + "description": "When true, versions still in the cooldown period cannot be installed during update/require. Defaults to true (only takes effect once a period is set).", + "default": true + }, + "audit": { + "enum": ["ignore", "report", "fail"], + "description": "How composer audit treats versions still in the cooldown period. Defaults to ignore.", + "default": "ignore" + }, + "ignore": { "$ref": "#/definitions/policy-ignore" } + }, + "additionalProperties": false + } + ] + }, "ignore-unreachable": { "description": "Controls for which operations unreachable repositories and policy sources are silently ignored. Defaults to [\"update\", \"install\"]. Set to true to ignore for all operations, false for none.", "oneOf": [ @@ -729,9 +762,14 @@ "properties": { "block": { "type": "boolean", - "description": "When true (default), matched packages are blocked during update/require/remove.", + "description": "When true (default), matched packages are blocked. The 'block-scope' key controls which commands the blocking applies to.", "default": true }, + "block-scope": { + "enum": ["all", "update", "install"], + "description": "Controls which commands trigger blocking: all (both update/require/remove and install), update (default, only update/require/remove), or install (only install).", + "default": "update" + }, "audit": { "enum": ["ignore", "report", "fail"], "description": "How composer audit treats packages matched by this dependency policy. Defaults to fail.", @@ -994,6 +1032,10 @@ "type": "boolean", "description": "Always optimize when dumping the autoloader." }, + "strict-psr-autoloader": { + "type": "boolean", + "description": "Return a failed exit code (6) if PSR-4 or PSR-0 mapping errors are present in the current project (dependencies excluded). Requires \"optimize-autoloader\" or \"classmap-authoritative\" to be enabled to work." + }, "prepend-autoloader": { "type": "boolean", "description": "If false, the composer autoloader will not be prepended to existing autoloaders, defaults to true." @@ -1301,6 +1343,7 @@ "additionalProperties": true }, "allow_ssl_downgrade": { "type": "boolean" }, + "require-published-time": { "type": "boolean", "description": "When the cooldown policy is active, refuse versions from this repository that carry no published-time. Defaults to true; set to false to fall back to the package-supplied time field." }, "force-lazy-providers": { "type": "boolean" }, "filter": { "type": ["boolean", "object"], @@ -1333,6 +1376,13 @@ } }, "no-api": { "type": "boolean" }, + "only-branches": { + "type": "array", + "description": "List of branch names to load, wildcards (*) are supported. The default branch is always loaded, so an empty list loads only the default branch. Defaults to all branches.", + "items": { + "type": "string" + } + }, "secure-http": { "type": "boolean" }, "svn-cache-credentials": { "type": "boolean" }, "trunk-path": { "type": ["string", "boolean"] }, diff --git a/core/vendor/composer/composer/src/Composer/Advisory/Auditor.php b/core/vendor/composer/composer/src/Composer/Advisory/Auditor.php index 74fcf26172..2fb4df1738 100644 --- a/core/vendor/composer/composer/src/Composer/Advisory/Auditor.php +++ b/core/vendor/composer/composer/src/Composer/Advisory/Auditor.php @@ -18,6 +18,7 @@ use Composer\IO\ConsoleIO; use Composer\IO\IOInterface; use Composer\Json\JsonFile; +use Composer\Package\AliasPackage; use Composer\Package\BasePackage; use Composer\Package\CompletePackageInterface; use Composer\Package\PackageInterface; @@ -60,6 +61,14 @@ class Auditor public const STATUS_OK = 0; public const STATUS_FAILED = 1; + /** @var CooldownAuditor */ + private $cooldownAuditor; + + public function __construct(?CooldownAuditor $cooldownAuditor = null) + { + $this->cooldownAuditor = $cooldownAuditor ?? new CooldownAuditor(); + } + /** * @param PolicyConfig $policyConfig Source of truth for ignore lists, severity filters, and per-list audit settings. * @param PackageInterface[] $packages @@ -92,7 +101,11 @@ public function audit(IOInterface $io, RepositorySet $repoSet, PolicyConfig $pol if ($policyConfig->abandoned->audit === ListPolicyConfig::AUDIT_IGNORE) { $abandonedPackages = []; } else { - $abandonedPackages = $this->filterAbandonedPackages($packages, $policyConfig->abandoned->getFlatIgnoreForOperation('audit')); + // abandonment is per package, so aliases would only report it twice + $canonicalPackages = array_filter($packages, static function (PackageInterface $package): bool { + return !$package instanceof AliasPackage; + }); + $abandonedPackages = $this->filterAbandonedPackages($canonicalPackages, $policyConfig->abandoned->getFlatIgnoreForOperation('audit')); if ($policyConfig->abandoned->audit === ListPolicyConfig::AUDIT_FAIL) { $abandonedCount = count($abandonedPackages); } @@ -115,6 +128,10 @@ public function audit(IOInterface $io, RepositorySet $repoSet, PolicyConfig $pol foreach ($packages as $package) { $matchingEntries = $filterAuditor->getMatchingAuditEntries($package, $filterResult['filter'], $policyConfig); foreach ($matchingEntries as $entry) { + // an alias can match the same entry as the package it aliases + if (in_array($entry, $filteredPackages[$package->getName()] ?? [], true)) { + continue; + } $filteredPackages[$package->getName()][] = $entry; if (isset($failingListNames[$entry->listName])) { @@ -124,7 +141,16 @@ public function audit(IOInterface $io, RepositorySet $repoSet, PolicyConfig $pol } } - $auditResult = (0 < $affectedPackagesCount || 0 < $abandonedCount || 0 < $filteredCount) ? self::STATUS_FAILED : self::STATUS_OK; + $cooldownPackages = []; + $cooldownCount = 0; + if ($policyConfig->cooldown->audit !== ListPolicyConfig::AUDIT_IGNORE) { + $cooldownPackages = $this->cooldownAuditor->collect($packages, $policyConfig->cooldown); + if ($policyConfig->cooldown->audit === ListPolicyConfig::AUDIT_FAIL) { + $cooldownCount = count($cooldownPackages); + } + } + + $auditResult = (0 < $affectedPackagesCount || 0 < $abandonedCount || 0 < $filteredCount || 0 < $cooldownCount) ? self::STATUS_FAILED : self::STATUS_OK; if (self::FORMAT_JSON === $format) { $json = ['advisories' => $advisories]; @@ -147,6 +173,7 @@ public function audit(IOInterface $io, RepositorySet $repoSet, PolicyConfig $pol return $data; }, $entries); }, $filteredPackages); + $json['cooldown'] = $cooldownPackages; $io->write(JsonFile::encode($json)); @@ -199,9 +226,39 @@ public function audit(IOInterface $io, RepositorySet $repoSet, PolicyConfig $pol } } + if (count($cooldownPackages) > 0) { + $plurality = count($cooldownPackages) === 1 ? '' : 's'; + $punctuation = $format === self::FORMAT_SUMMARY ? '.' : ':'; + $style = $cooldownCount > 0 ? 'error' : 'warning'; + + $io->write(sprintf('<%s>Found %d package%s still in the cooldown period configured in "policy.cooldown"%s', $style, count($cooldownPackages), $plurality, $punctuation, $style)); + if ($format !== self::FORMAT_SUMMARY) { + $this->outputCooldownPackages($io, $cooldownPackages); + } + } + return $auditResult; } + /** + * @param array $cooldownPackages + */ + private function outputCooldownPackages(IOInterface $io, array $cooldownPackages): void + { + foreach ($cooldownPackages as $name => $info) { + $io->write(sprintf( + ' - %s (%s) published %s, available in %s', + $name, + $info['prettyVersion'], + $info['releaseDate'], + $info['availableIn'] + )); + if ($info['source'] === 'time') { + $io->write(' The cooldown policy relied on the package-supplied time field because the repository provides no published-time.'); + } + } + } + /** * @param array> $advisories * @param array $ignoreList @@ -326,7 +383,7 @@ private function countAdvisories(array $advisories): array } /** - * @param array> $advisories + * @param array> $advisories * @param self::FORMAT_* $format The format that will be used to output audit results. */ private function outputAdvisories(IOInterface $io, array $advisories, string $format): void @@ -352,40 +409,17 @@ private function outputAdvisories(IOInterface $io, array $advisories, string $fo } /** - * @param array> $advisories + * @param array> $advisories */ private function outputAdvisoriesTable(ConsoleIO $io, array $advisories): void { foreach ($advisories as $packageAdvisories) { foreach ($packageAdvisories as $advisory) { - $headers = [ - 'Package', - 'Severity', - 'Advisory ID', - 'CVE', - 'Title', - 'URL', - 'Affected versions', - 'Reported at', - ]; - $row = [ - $advisory->packageName, - $this->getSeverity($advisory), - $this->getAdvisoryId($advisory), - $this->getCVE($advisory), - $advisory->title, - $this->getURL($advisory), - $advisory->affectedVersions->getPrettyString(), - $advisory->reportedAt->format(DATE_ATOM), - ]; - if ($advisory instanceof IgnoredSecurityAdvisory) { - $headers[] = 'Ignore reason'; - $row[] = $advisory->ignoreReason ?? 'None specified'; - } + $fields = $this->getAdvisoryFields($advisory); $io->getTable() ->setHorizontal() - ->setHeaders($headers) - ->addRow(ConsoleIO::sanitize($row)) + ->setHeaders(array_keys($fields)) + ->addRow(ConsoleIO::sanitize(array_values($fields))) ->setColumnWidth(1, 80) ->setColumnMaxWidth(1, 80) ->render(); @@ -394,7 +428,7 @@ private function outputAdvisoriesTable(ConsoleIO $io, array $advisories): void } /** - * @param array> $advisories + * @param array> $advisories */ private function outputAdvisoriesPlain(IOInterface $io, array $advisories): void { @@ -405,16 +439,8 @@ private function outputAdvisoriesPlain(IOInterface $io, array $advisories): void if (!$firstAdvisory) { $error[] = '--------'; } - $error[] = "Package: ".$advisory->packageName; - $error[] = "Severity: ".$this->getSeverity($advisory); - $error[] = "Advisory ID: ".$this->getAdvisoryId($advisory); - $error[] = "CVE: ".$this->getCVE($advisory); - $error[] = "Title: ".OutputFormatter::escape($advisory->title); - $error[] = "URL: ".$this->getURL($advisory); - $error[] = "Affected versions: ".OutputFormatter::escape($advisory->affectedVersions->getPrettyString()); - $error[] = "Reported at: ".$advisory->reportedAt->format(DATE_ATOM); - if ($advisory instanceof IgnoredSecurityAdvisory) { - $error[] = "Ignore reason: ".($advisory->ignoreReason ?? 'None specified'); + foreach ($this->getAdvisoryFields($advisory) as $label => $value) { + $error[] = $label.': '.(in_array($label, ['Title', 'Affected versions'], true) ? OutputFormatter::escape($value) : $value); } $firstAdvisory = false; } @@ -422,6 +448,38 @@ private function outputAdvisoriesPlain(IOInterface $io, array $advisories): void $io->write($error); } + /** + * A partial advisory only has the fields its repository provided, so the others are left out + * + * @return array + */ + private function getAdvisoryFields(PartialSecurityAdvisory $advisory): array + { + if (!$advisory instanceof SecurityAdvisory) { + return [ + 'Package' => $advisory->packageName, + 'Advisory ID' => $this->getAdvisoryId($advisory), + 'Affected versions' => $advisory->affectedVersions->getPrettyString(), + ]; + } + + $fields = [ + 'Package' => $advisory->packageName, + 'Severity' => $this->getSeverity($advisory), + 'Advisory ID' => $this->getAdvisoryId($advisory), + 'CVE' => $this->getCVE($advisory), + 'Title' => $advisory->title, + 'URL' => $this->getURL($advisory), + 'Affected versions' => $advisory->affectedVersions->getPrettyString(), + 'Reported at' => $advisory->reportedAt->format(DATE_ATOM), + ]; + if ($advisory instanceof IgnoredSecurityAdvisory) { + $fields['Ignore reason'] = $advisory->ignoreReason ?? 'None specified'; + } + + return $fields; + } + /** * @param array $packages * @param self::FORMAT_PLAIN|self::FORMAT_TABLE $format @@ -478,7 +536,7 @@ private function getSeverity(SecurityAdvisory $advisory): string return $advisory->severity; } - private function getAdvisoryId(SecurityAdvisory $advisory): string + private function getAdvisoryId(PartialSecurityAdvisory $advisory): string { if (str_starts_with($advisory->advisoryId, 'PKSA-')) { return 'advisoryId.'>'.$advisory->advisoryId.''; diff --git a/core/vendor/composer/composer/src/Composer/Advisory/CooldownAuditor.php b/core/vendor/composer/composer/src/Composer/Advisory/CooldownAuditor.php new file mode 100644 index 0000000000..c8626be2bc --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Advisory/CooldownAuditor.php @@ -0,0 +1,86 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Advisory; + +use Composer\Package\AliasPackage; +use Composer\Package\PackageInterface; +use Composer\Package\RootPackageInterface; +use Composer\Policy\CooldownPolicyConfig; +use Composer\Repository\PlatformRepository; +use DateTimeImmutable; +use DateTimeInterface; + +/** + * Reports installed/locked package versions whose publication is still within the + * configured cooldown period, for `composer audit`. + * + * This is the audit-time counterpart to {@see \Composer\DependencyResolver\CooldownPoolFilter}. + * It operates on a flat package list with no resolver request. + * + * @internal + * @final + */ +class CooldownAuditor +{ + /** @var DateTimeImmutable */ + private $now; + + public function __construct(?DateTimeImmutable $now = null) + { + $this->now = $now ?? new DateTimeImmutable(); + } + + /** + * @param PackageInterface[] $packages + * @return array + * Map of package name => cooldown info, for versions still within the cooldown + */ + public function collect(array $packages, CooldownPolicyConfig $cooldown): array + { + if (!$cooldown->hasCooldown()) { + return []; + } + + $result = []; + foreach ($packages as $package) { + // Decide on the aliased package, as the block path does + if ($package instanceof AliasPackage) { + $package = $package->getAliasOf(); + } + + // Mirror the block path's skips: root, platform, dev, ignored, and + // versions without a verifiable publication date. + if ($package instanceof RootPackageInterface + || PlatformRepository::isPlatformPackage($package->getName()) + || $package->isDev() + || $cooldown->isIgnored($package, 'audit') + ) { + continue; + } + + $releaseDate = $cooldown->getEffectiveDate($package); + if ($releaseDate === null || !$cooldown->isWithinCooldown($releaseDate, $this->now)) { + continue; + } + + $result[$package->getName()] = [ + 'prettyVersion' => $package->getPrettyVersion(), + 'releaseDate' => $releaseDate->format(DateTimeInterface::ATOM), + 'availableIn' => $cooldown->formatTimeUntilAvailable($releaseDate, $this->now), + 'source' => $package->getPublishedDate() !== null ? 'published-time' : 'time', + ]; + } + + return $result; + } +} diff --git a/core/vendor/composer/composer/src/Composer/Autoload/AutoloadGenerator.php b/core/vendor/composer/composer/src/Composer/Autoload/AutoloadGenerator.php index 1653dd325c..b43f3e6788 100644 --- a/core/vendor/composer/composer/src/Composer/Autoload/AutoloadGenerator.php +++ b/core/vendor/composer/composer/src/Composer/Autoload/AutoloadGenerator.php @@ -326,7 +326,7 @@ public static function autoload(\$class) } foreach ($autoloads['classmap'] as $dir) { - $classMapGenerator->scanPaths($dir, $this->buildExclusionRegex($dir, $excluded)); + $classMapGenerator->scanPaths($dir, $this->buildExclusionRegexWithVendorDir($dir, $excluded, $vendorPath)); } if ($scanPsrPackages) { @@ -349,24 +349,21 @@ public static function autoload(\$class) continue; } - // if the vendor dir is contained within a psr-0/psr-4 dir being scanned we exclude it - if (str_contains($vendorPath, $dir.'/')) { - $exclusionRegex = $this->buildExclusionRegex($dir, array_merge($excluded, [$vendorPath.'/'])); - } else { - $exclusionRegex = $this->buildExclusionRegex($dir, $excluded); - } - - $classMapGenerator->scanPaths($dir, $exclusionRegex, $group['type'], $namespace); + $classMapGenerator->scanPaths($dir, $this->buildExclusionRegexWithVendorDir($dir, $excluded, $vendorPath), $group['type'], $namespace); } } } } $classMap = $classMapGenerator->getClassMap(); + // added before the ambiguity checks so that the cached result is reused by dump-autoload --strict-ambiguous + $classMap->addClass('Composer\InstalledVersions', $vendorPath . '/composer/InstalledVersions.php'); if ($strictAmbiguous) { $ambiguousClasses = $classMap->getAmbiguousClasses(false); + $ambiguousFolders = $classMap->getAmbiguousFolders(false); } else { $ambiguousClasses = $classMap->getAmbiguousClasses(); + $ambiguousFolders = $classMap->getAmbiguousFolders(); } foreach ($ambiguousClasses as $className => $ambiguousPaths) { if (count($ambiguousPaths) > 1) { @@ -381,7 +378,13 @@ public static function autoload(\$class) ); } } - if (\count($ambiguousClasses) > 0) { + foreach ($ambiguousFolders as $ambiguousPaths) { + $this->io->writeError( + 'Warning: Ambiguous path casing, "'. implode('", "', $ambiguousPaths) .'" only differ in casing'. + ' and merge into one on case-insensitive filesystems (e.g. Windows and macOS), which breaks autoloading there.' + ); + } + if (\count($ambiguousClasses) > 0 || \count($ambiguousFolders) > 0) { $this->io->writeError('To resolve ambiguity in classes not under your control you can ignore them by path using exclude-from-classmap'); } @@ -391,7 +394,6 @@ public static function autoload(\$class) $this->io->writeError("$msg"); } - $classMap->addClass('Composer\InstalledVersions', $vendorPath . '/composer/InstalledVersions.php'); $classMap->sort(); $classmapFile = << $excluded + * @return non-empty-string|null + */ + private function buildExclusionRegexWithVendorDir(string $dir, array $excluded, ?string $vendorPath): ?string + { + if (null !== $vendorPath) { + $fs = new Filesystem(); + $resolvedDir = $fs->normalizePath($fs->isAbsolutePath($dir) ? $dir : realpath(Platform::getCwd()).'/'.$dir); + if (str_starts_with($vendorPath, $resolvedDir.'/')) { + $excluded[] = preg_quote($vendorPath.'/'); + } + } + + return $this->buildExclusionRegex($dir, $excluded); + } + /** * @param array $excluded * @return non-empty-string|null @@ -653,12 +674,17 @@ public function createLoader(array $autoloads, ?string $vendorDir = null) $excluded = $autoloads['exclude-from-classmap']; } + $vendorPath = null; + if (null !== $vendorDir && false !== ($realVendorDir = realpath($vendorDir))) { + $vendorPath = (new Filesystem())->normalizePath($realVendorDir); + } + $classMapGenerator = new ClassMapGenerator(['php', 'inc', 'hh']); $classMapGenerator->avoidDuplicateScans(); foreach ($autoloads['classmap'] as $dir) { try { - $classMapGenerator->scanPaths($dir, $this->buildExclusionRegex($dir, $excluded)); + $classMapGenerator->scanPaths($dir, $this->buildExclusionRegexWithVendorDir($dir, $excluded, $vendorPath)); } catch (\RuntimeException $e) { $this->io->writeError(''.$e->getMessage().''); } diff --git a/core/vendor/composer/composer/src/Composer/Autoload/ClassMapGenerator.php b/core/vendor/composer/composer/src/Composer/Autoload/ClassMapGenerator.php index 316757fe93..177c7167e0 100644 --- a/core/vendor/composer/composer/src/Composer/Autoload/ClassMapGenerator.php +++ b/core/vendor/composer/composer/src/Composer/Autoload/ClassMapGenerator.php @@ -20,6 +20,7 @@ use Composer\ClassMapGenerator\FileList; use Composer\IO\IOInterface; +use Composer\Util\Filesystem; /** * ClassMapGenerator @@ -45,7 +46,7 @@ public static function dump(iterable $dirs, string $file): void $maps = array_merge($maps, static::createMap($dir)); } - file_put_contents($file, sprintf('setHelp( <<getOption('direct')) { + $packages = $this->filterDirectPackages($composer, $input, $packages); + if (count($packages) === 0) { + $this->getIO()->writeError('No direct dependencies - skipping audit.'); + + return Auditor::STATUS_OK; + } + } + $auditor = new Auditor(); $repoSet = new RepositorySet(); foreach ($composer->getRepositoryManager()->getRepositories() as $repo) { @@ -133,4 +146,22 @@ private function getPackages(Composer $composer, InputInterface $input): array return $installedRepo->getPackages(); } + + /** + * @param PackageInterface[] $packages + * @return list + */ + private function filterDirectPackages(Composer $composer, InputInterface $input, array $packages): array + { + $rootPkg = $composer->getPackage(); + $requires = $rootPkg->getRequires(); + if (!$input->getOption('no-dev')) { + $requires = array_merge($requires, $rootPkg->getDevRequires()); + } + + // getNames() includes replaced/provided names so e.g. symfony/symfony counts when symfony/console is required + return array_values(array_filter($packages, static function (PackageInterface $package) use ($requires): bool { + return count(array_intersect_key($requires, array_flip($package->getNames()))) > 0; + })); + } } diff --git a/core/vendor/composer/composer/src/Composer/Command/BaseCommand.php b/core/vendor/composer/composer/src/Composer/Command/BaseCommand.php index 81f53b7d2e..c452b9a04b 100644 --- a/core/vendor/composer/composer/src/Composer/Command/BaseCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/BaseCommand.php @@ -27,6 +27,8 @@ use Composer\Plugin\PluginEvents; use Composer\Advisory\Auditor; use Composer\Advisory\AuditConfig; +use Composer\Policy\CooldownPolicyConfig; +use Composer\Policy\ListPolicyConfig; use Composer\Policy\PolicyConfig; use Composer\Util\Platform; use Symfony\Component\Console\Completion\CompletionInput; @@ -471,7 +473,7 @@ protected function getAuditFormat(InputInterface $input, string $optName = 'audi } /** - * Creates a PolicyConfig from the Config object, applying --no-blocking / --no-security-blocking overrides. + * Creates a PolicyConfig from the Config object, applying --no-blocking / --no-security-blocking / --no-cooldown overrides. * * @internal */ @@ -479,7 +481,7 @@ protected function createPolicyConfig(Config $config, ?InputInterface $input): P { $policyConfig = PolicyConfig::fromConfig($config); - // --no-blocking / --no-security-blocking: disable ALL blocking (advisories + malware + abandoned + custom) + // --no-blocking / --no-security-blocking: disable ALL blocking (advisories + malware + abandoned + cooldown + custom) $noBlocking = Platform::getBoolEnv('COMPOSER_NO_BLOCKING', false) || Platform::getBoolEnv('COMPOSER_NO_SECURITY_BLOCKING', false) || ($input !== null && $input->hasOption('no-security-blocking') && $input->getOption('no-security-blocking')) @@ -487,11 +489,28 @@ protected function createPolicyConfig(Config $config, ?InputInterface $input): P if ($noBlocking) { $policyConfig = $policyConfig->withBlockingDisabled(); + } elseif ($input !== null && $input->hasOption('no-cooldown') && $input->getOption('no-cooldown')) { + $policyConfig = $policyConfig->withCooldownBlockingDisabled(); } return $policyConfig; } + /** + * The cooldown policy that would block during an update, to apply when picking versions, or null when none applies. + * + * @internal + */ + protected function getBlockingCooldownPolicy(Config $config, ?InputInterface $input): ?CooldownPolicyConfig + { + $policyConfig = $this->createPolicyConfig($config, $input); + if ($policyConfig->enabled && $policyConfig->cooldown->shouldBlock(ListPolicyConfig::BLOCK_SCOPE_UPDATE)) { + return $policyConfig->cooldown; + } + + return null; + } + /** * Creates an AuditConfig from the input options. * diff --git a/core/vendor/composer/composer/src/Composer/Command/BumpCommand.php b/core/vendor/composer/composer/src/Composer/Command/BumpCommand.php index 3579a8db02..c5396d55f0 100644 --- a/core/vendor/composer/composer/src/Composer/Command/BumpCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/BumpCommand.php @@ -255,7 +255,7 @@ private function updateFileCleanly(JsonFile $json, array $updates): bool } } - if (false === file_put_contents($json->getPath(), $manipulator->getContents())) { + if (false === Filesystem::safeFilePutContents($json->getPath(), $manipulator->getContents())) { throw new \RuntimeException('Unable to write new '.$json->getPath().' contents.'); } diff --git a/core/vendor/composer/composer/src/Composer/Command/ClearCacheCommand.php b/core/vendor/composer/composer/src/Composer/Command/ClearCacheCommand.php index 77ed517a80..b0e74cbbf0 100644 --- a/core/vendor/composer/composer/src/Composer/Command/ClearCacheCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/ClearCacheCommand.php @@ -37,7 +37,7 @@ protected function configure(): void The clear-cache deletes all cached packages from composer's cache directory. -Read more at https://getcomposer.org/doc/03-cli.md#clear-cache-clearcache-cc +Read more at https://getcomposer.org/doc/03-cli.md#clear-cache--clearcache--cc EOT ) ; @@ -67,6 +67,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } + // check before realpath() as e.g. "nul" does not resolve on Windows + if (!Cache::isUsable($cachePath)) { + $io->writeError("Cache is not enabled ($key): $cachePath"); + + continue; + } + $cachePath = realpath($cachePath); if (!$cachePath) { $io->writeError("Cache directory does not exist ($key): $cachePath"); diff --git a/core/vendor/composer/composer/src/Composer/Command/ConfigCommand.php b/core/vendor/composer/composer/src/Composer/Command/ConfigCommand.php index 4433dca6af..40dd1ce4e1 100644 --- a/core/vendor/composer/composer/src/Composer/Command/ConfigCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/ConfigCommand.php @@ -13,6 +13,7 @@ namespace Composer\Command; use Composer\Pcre\Preg; +use Composer\Policy\CooldownPolicyConfig; use Composer\Policy\IgnoreUnreachable; use Composer\Policy\ListPolicyConfig; use Composer\Policy\PolicyConfig; @@ -81,8 +82,8 @@ protected function configure(): void new InputOption('list', 'l', InputOption::VALUE_NONE, 'List configuration settings'), new InputOption('file', 'f', InputOption::VALUE_REQUIRED, 'If you want to choose a different composer.json or config.json'), new InputOption('absolute', null, InputOption::VALUE_NONE, 'Returns absolute paths when fetching *-dir config values instead of relative'), - new InputOption('json', 'j', InputOption::VALUE_NONE, 'JSON decode the setting value, to be used with extra.* keys'), - new InputOption('merge', 'm', InputOption::VALUE_NONE, 'Merge the setting value with the current value, to be used with extra.* or audit.ignore[-abandoned] keys in combination with --json'), + new InputOption('json', 'j', InputOption::VALUE_NONE, 'JSON decode the setting value, only supported for extra.*, policy.*.ignore, policy.advisories.ignore-id, policy.ignore-unreachable and audit.ignore[-abandoned] keys'), + new InputOption('merge', 'm', InputOption::VALUE_NONE, 'Merge the setting value with the current value, only supported for extra.* keys in combination with --json, and for policy.*.ignore, policy.advisories.ignore-id and audit.ignore[-abandoned] keys'), new InputOption('append', null, InputOption::VALUE_NONE, 'When adding a repository, append it (lowest priority) to the existing ones instead of prepending it (highest priority)'), new InputOption('source', null, InputOption::VALUE_NONE, 'Display where the config value is loaded from'), new InputArgument('setting-key', null, 'Setting key', null, $this->suggestSettingKeys()), @@ -327,6 +328,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $auditValidator = static function ($val): bool { return in_array($val, [ListPolicyConfig::AUDIT_IGNORE, ListPolicyConfig::AUDIT_REPORT, ListPolicyConfig::AUDIT_FAIL], true); }; + $blockScopeValidator = static function ($val): bool { + return in_array($val, ListPolicyConfig::AVAILABLE_BLOCK_SCOPES, true); + }; $keepAsIsNormalizer = static function ($val) { return $val; }; @@ -427,6 +431,7 @@ static function ($val) { }], 'sort-packages' => [$booleanValidator, $booleanNormalizer], 'optimize-autoloader' => [$booleanValidator, $booleanNormalizer], + 'strict-psr-autoloader' => [$booleanValidator, $booleanNormalizer], 'classmap-authoritative' => [$booleanValidator, $booleanNormalizer], 'apcu-autoloader' => [$booleanValidator, $booleanNormalizer], 'prepend-autoloader' => [$booleanValidator, $booleanNormalizer], @@ -497,15 +502,33 @@ static function ($val) { 'policy.advisories.block' => [$booleanValidator, $booleanNormalizer], 'policy.advisories.audit' => [$auditValidator, $keepAsIsNormalizer], 'policy.malware.block' => [$booleanValidator, $booleanNormalizer], - 'policy.malware.block-scope' => [ - static function ($val): bool { - return in_array($val, ['all', 'update', 'install'], true); - }, - $keepAsIsNormalizer, - ], + 'policy.malware.block-scope' => [$blockScopeValidator, $keepAsIsNormalizer], 'policy.malware.audit' => [$auditValidator, $keepAsIsNormalizer], 'policy.abandoned.block' => [$booleanValidator, $booleanNormalizer], 'policy.abandoned.audit' => [$auditValidator, $keepAsIsNormalizer], + 'policy.cooldown.block' => [$booleanValidator, $booleanNormalizer], + 'policy.cooldown.audit' => [$auditValidator, $keepAsIsNormalizer], + 'policy.cooldown.period' => [ + static function ($val): bool { + if ($val === 'null' || $val === '') { + return true; + } + try { + CooldownPolicyConfig::parseDuration($val); + + return true; + } catch (\RuntimeException $e) { + return false; + } + }, + static function ($val) { + if ($val === 'null' || $val === '') { + return null; + } + + return is_numeric($val) ? (int) $val : $val; + }, + ], 'policy.ignore-unreachable' => [$booleanValidator, $booleanNormalizer], ]; $multiConfigValues = [ @@ -704,8 +727,8 @@ static function ($vals) { return 0; } - // handle custom policy lists: policy..block / policy..audit - if (Preg::isMatch('/^policy\.([^.]+)\.(block|audit)$/', $settingKey, $matches) + // handle custom policy lists: policy..block / policy..block-scope / policy..audit + if (Preg::isMatch('/^policy\.([^.]+)\.(block|block-scope|audit)$/', $settingKey, $matches) && !in_array($matches[1], $nonCustomPolicyKeys, true) ) { $reservedError = PolicyConfig::getFutureReservedListNameError($matches[1]); @@ -717,6 +740,11 @@ static function ($vals) { throw new \RuntimeException(sprintf('"%s" is an invalid value for %s, expected a boolean', $values[0], $settingKey)); } $this->configSource->addConfigSetting($settingKey, $booleanNormalizer($values[0])); + } elseif ($matches[2] === 'block-scope') { + if (!$blockScopeValidator($values[0])) { + throw new \RuntimeException(sprintf('"%s" is an invalid value for %s, must be one of: all, update, install', $values[0], $settingKey)); + } + $this->configSource->addConfigSetting($settingKey, $values[0]); } else { if (!$auditValidator($values[0])) { throw new \RuntimeException(sprintf('"%s" is an invalid value for %s, must be one of: ignore, report, fail', $values[0], $settingKey)); diff --git a/core/vendor/composer/composer/src/Composer/Command/CreateProjectCommand.php b/core/vendor/composer/composer/src/Composer/Command/CreateProjectCommand.php index 26184bd9e5..26015a8735 100644 --- a/core/vendor/composer/composer/src/Composer/Command/CreateProjectCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/CreateProjectCommand.php @@ -20,9 +20,12 @@ use Composer\Installer; use Composer\Installer\ProjectInstaller; use Composer\Installer\SuggestedPackagesReporter; +use Composer\Console\ProgramStatus; +use Composer\IO\ConsoleIO; use Composer\IO\IOInterface; use Composer\Package\BasePackage; use Composer\DependencyResolver\Operation\InstallOperation; +use Composer\DependencyResolver\PolicyRemovalReason; use Composer\Package\Version\VersionSelector; use Composer\Package\AliasPackage; use Composer\Pcre\Preg; @@ -92,8 +95,9 @@ protected function configure(): void new InputOption('no-install', null, InputOption::VALUE_NONE, 'Whether to skip installation of the package dependencies.'), new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Whether to skip auditing of the installed package dependencies (can also be set via the COMPOSER_NO_AUDIT=1 env var).'), new InputOption('audit-format', null, InputOption::VALUE_REQUIRED, 'Audit output format. Must be "table", "plain", "json" or "summary".', Auditor::FORMAT_SUMMARY, Auditor::FORMATS), - new InputOption('no-security-blocking', null, InputOption::VALUE_NONE, 'DEPRECATED: use --no-blocking instead. Allows installing packages with security advisories or that are abandoned (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).'), - new InputOption('no-blocking', null, InputOption::VALUE_NONE, 'Disables all policy blocking during this command (can also be set via the COMPOSER_NO_BLOCKING=1 env var).'), + new InputOption('no-security-blocking', null, InputOption::VALUE_NONE, 'DEPRECATED: use --no-blocking instead. Allows installing packages with security advisories, that are abandoned or that are still in the cooldown period (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).'), + new InputOption('no-blocking', null, InputOption::VALUE_NONE, 'Disables all policy blocking during this command, including the cooldown period (can also be set via the COMPOSER_NO_BLOCKING=1 env var).'), + new InputOption('no-cooldown', null, InputOption::VALUE_NONE, 'Disables the cooldown policy blocking during this command, other policies still apply.'), new InputOption('ignore-platform-req', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore a specific platform requirement (php & ext- packages).'), new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages).'), new InputOption('ask', null, InputOption::VALUE_NONE, 'Whether to ask for project directory.'), @@ -442,8 +446,10 @@ protected function installRootPackage(InputInterface $input, IOInterface $io, Co $platformOverrides = $config->get('platform'); $platformRepo = new PlatformRepository([], $platformOverrides); + $cooldown = $this->getBlockingCooldownPolicy($config, $input); + // find the latest version if there are multiple - $versionSelector = new VersionSelector($repositorySet, $platformRepo); + $versionSelector = new VersionSelector($repositorySet, $platformRepo, $cooldown); $package = $versionSelector->findBestCandidate($name, $packageVersion, $stability, $platformRequirementFilter, 0, $io); if (!$package) { @@ -451,6 +457,9 @@ protected function installRootPackage(InputInterface $input, IOInterface $io, Co if (!($platformRequirementFilter instanceof IgnoreAllPlatformRequirementFilter) && $versionSelector->findBestCandidate($name, $packageVersion, $stability, PlatformRequirementFilterFactory::ignoreAll())) { throw new \InvalidArgumentException($errorMessage .' in a version installable using your PHP version, PHP extensions and Composer version.'); } + if ($cooldown !== null && false !== (new VersionSelector($repositorySet, $platformRepo))->findBestCandidate($name, $packageVersion, $stability, $platformRequirementFilter)) { + throw new \InvalidArgumentException($errorMessage .' that is past the cooldown period configured in "policy.cooldown". '.PolicyRemovalReason::COOLDOWN_REMEDY); + } throw new \InvalidArgumentException($errorMessage .'.'); } @@ -462,6 +471,10 @@ protected function installRootPackage(InputInterface $input, IOInterface $io, Co $this->getIO()->writeError('Received '.$signal.', aborting', true, IOInterface::DEBUG); $fs = new Filesystem(); $fs->removeDirectory($realDir); + $io = $this->getIO(); + if ($io instanceof ConsoleIO) { + $io->writeProgramStatus(ProgramStatus::idle()); + } $handler->exitWithLastSignal(); }); } diff --git a/core/vendor/composer/composer/src/Composer/Command/DependsCommand.php b/core/vendor/composer/composer/src/Composer/Command/DependsCommand.php index 07e58c1df1..e31422d4a5 100644 --- a/core/vendor/composer/composer/src/Composer/Command/DependsCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/DependsCommand.php @@ -45,7 +45,7 @@ protected function configure(): void php composer.phar depends composer/composer -Read more at https://getcomposer.org/doc/03-cli.md#depends-why +Read more at https://getcomposer.org/doc/03-cli.md#depends--why EOT ) ; diff --git a/core/vendor/composer/composer/src/Composer/Command/DumpAutoloadCommand.php b/core/vendor/composer/composer/src/Composer/Command/DumpAutoloadCommand.php index 6c07539479..d368a28b77 100644 --- a/core/vendor/composer/composer/src/Composer/Command/DumpAutoloadCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/DumpAutoloadCommand.php @@ -46,7 +46,7 @@ protected function configure(): void <<php composer.phar dump-autoload -Read more at https://getcomposer.org/doc/03-cli.md#dump-autoload-dumpautoload +Read more at https://getcomposer.org/doc/03-cli.md#dump-autoload--dumpautoload EOT ) ; @@ -77,10 +77,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $optimize = $input->getOption('optimize') || $config->get('optimize-autoloader'); $authoritative = $input->getOption('classmap-authoritative') || $config->get('classmap-authoritative'); + $strictPsr = $input->getOption('strict-psr') || $config->get('strict-psr-autoloader'); $apcuPrefix = $input->getOption('apcu-prefix'); $apcu = $apcuPrefix !== null || $input->getOption('apcu') || $config->get('apcu-autoloader'); - if ($input->getOption('strict-psr') && !$optimize && !$authoritative) { + if ($strictPsr && !$optimize && !$authoritative) { throw new \InvalidArgumentException('--strict-psr mode only works with optimized autoloader, use --optimize or --classmap-authoritative if you want a strict return value.'); } if ($input->getOption('strict-ambiguous') && !$optimize && !$authoritative) { @@ -133,11 +134,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->getIO()->write('Generated autoload files'); } - if ($missingDependencies || ($input->getOption('strict-psr') && count($classMap->getPsrViolations()) > 0)) { + if ($missingDependencies || ($strictPsr && count($classMap->getPsrViolations()) > 0)) { return 1; } - if ($input->getOption('strict-ambiguous') && count($classMap->getAmbiguousClasses(false)) > 0) { + if ($input->getOption('strict-ambiguous') && (count($classMap->getAmbiguousClasses(false)) > 0 || count($classMap->getAmbiguousFolders(false)) > 0)) { return 2; } diff --git a/core/vendor/composer/composer/src/Composer/Command/HomeCommand.php b/core/vendor/composer/composer/src/Composer/Command/HomeCommand.php index 3547faec77..726a818998 100644 --- a/core/vendor/composer/composer/src/Composer/Command/HomeCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/HomeCommand.php @@ -52,7 +52,7 @@ protected function configure(): void To open the homepage by default, use -H or --homepage. To show instead of open the repository or homepage URL, use -s or --show. -Read more at https://getcomposer.org/doc/03-cli.md#browse-home +Read more at https://getcomposer.org/doc/03-cli.md#browse--home EOT ); } diff --git a/core/vendor/composer/composer/src/Composer/Command/InitCommand.php b/core/vendor/composer/composer/src/Composer/Command/InitCommand.php index 2a54da313f..4559054cc0 100644 --- a/core/vendor/composer/composer/src/Composer/Command/InitCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/InitCommand.php @@ -187,6 +187,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } + if ($input->isInteractive() && is_dir('.git') && in_array($input->getOption('type'), [null, 'library'], true)) { + $attributesFile = realpath('.gitattributes'); + + if (false === $attributesFile) { + $attributesFile = realpath('.') . '/.gitattributes'; + } + + if (!$this->hasExportIgnoreForLockfile($attributesFile)) { + $question = 'Would you like composer.lock marked as export-ignore in your .gitattributes [yes]? '; + + if ($io->askConfirmation($question)) { + $this->addExportIgnoreForLockfile($attributesFile); + } + } + } + $question = 'Would you like to install dependencies now [yes]? '; if ($input->isInteractive() && $this->hasDependencies($options) && $io->askConfirmation($question)) { $this->updateDependencies($output); @@ -550,13 +566,55 @@ protected function addVendorIgnore(string $ignoreFile, string $vendor = '/vendor $contents = ""; if (file_exists($ignoreFile)) { $contents = file_get_contents($ignoreFile); + if (false === $contents) { + throw new \RuntimeException('Could not read '.$ignoreFile); + } + + if ($contents !== '' && substr($contents, -1) !== "\n") { + $contents .= "\n"; + } + } + + Filesystem::safeFilePutContents($ignoreFile, $contents . $vendor. "\n"); + } + + protected function hasExportIgnoreForLockfile(string $gitAttributesFile, string $lockfile = 'composer.lock'): bool + { + if (!file_exists($gitAttributesFile)) { + return false; + } + + $pattern = sprintf('{^/?%s\s.*(?php composer.phar install -Read more at https://getcomposer.org/doc/03-cli.md#install-i +Read more at https://getcomposer.org/doc/03-cli.md#install--i EOT ) ; @@ -118,10 +118,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $optimize = $input->getOption('optimize-autoloader') || $config->get('optimize-autoloader'); $authoritative = $input->getOption('classmap-authoritative') || $config->get('classmap-authoritative'); + $strictPsrAutoloader = $input->getOption('strict-psr-autoloader') || $config->get('strict-psr-autoloader'); $apcuPrefix = $input->getOption('apcu-autoloader-prefix'); $apcu = $apcuPrefix !== null || $input->getOption('apcu-autoloader') || $config->get('apcu-autoloader'); - if ($input->getOption('strict-psr-autoloader') && !$optimize && !$authoritative) { + if ($strictPsrAutoloader && !$optimize && !$authoritative) { throw new \InvalidArgumentException('--strict-psr-autoloader mode only works with optimized autoloader, use --optimize-autoloader or --classmap-authoritative if you want a strict return value.'); } @@ -137,7 +138,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->setDumpAutoloader(!$input->getOption('no-autoloader')) ->setOptimizeAutoloader($optimize) ->setClassMapAuthoritative($authoritative) - ->setStrictPsrAutoloader($input->getOption('strict-psr-autoloader')) + ->setStrictPsrAutoloader($strictPsrAutoloader) ->setApcuAutoloader($apcu, $apcuPrefix) ->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input)) ->setPolicyConfig($this->createPolicyConfig($composer->getConfig(), $input)) diff --git a/core/vendor/composer/composer/src/Composer/Command/OutdatedCommand.php b/core/vendor/composer/composer/src/Composer/Command/OutdatedCommand.php index 0fea6dc092..9afa0fae35 100644 --- a/core/vendor/composer/composer/src/Composer/Command/OutdatedCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/OutdatedCommand.php @@ -41,6 +41,7 @@ protected function configure(): void new InputOption('minor-only', 'm', InputOption::VALUE_NONE, 'Show only packages that have minor SemVer-compatible updates.'), new InputOption('patch-only', 'p', InputOption::VALUE_NONE, 'Show only packages that have patch SemVer-compatible updates.'), new InputOption('sort-by-age', 'A', InputOption::VALUE_NONE, 'Displays the installed version\'s age, and sorts packages oldest first.'), + new InputOption('cooldown-period', null, InputOption::VALUE_REQUIRED, 'Overrides the configured policy.cooldown.period while looking up latest versions, even when the policy is disabled or not blocking, 0 disables it for this run.'), new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text', ['json', 'text']), new InputOption('ignore', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore specified package(s). Can contain wildcards (*). Use it if you don\'t want to be informed about new versions of some packages.', null, $this->suggestInstalledPackage(false)), new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables search in require-dev packages.'), @@ -58,6 +59,7 @@ protected function configure(): void compatibility breaks according to semver, so upgrade when you can but it may involve work. - red (!): Dependency has a new version that is semver-compatible and you should upgrade it. +- uncolored (c): Dependency has a newer version that is still in the cooldown period and cannot be installed yet. Read more at https://getcomposer.org/doc/03-cli.md#outdated EOT @@ -113,6 +115,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($input->getOption('sort-by-age')) { $args['--sort-by-age'] = true; } + if (null !== $input->getOption('cooldown-period')) { + $args['--cooldown-period'] = $input->getOption('cooldown-period'); + } $args['--ignore-platform-req'] = $input->getOption('ignore-platform-req'); if ($input->getOption('ignore-platform-reqs')) { $args['--ignore-platform-reqs'] = true; diff --git a/core/vendor/composer/composer/src/Composer/Command/PackageDiscoveryTrait.php b/core/vendor/composer/composer/src/Composer/Command/PackageDiscoveryTrait.php index 9e21e163a2..2019c5fc46 100644 --- a/core/vendor/composer/composer/src/Composer/Command/PackageDiscoveryTrait.php +++ b/core/vendor/composer/composer/src/Composer/Command/PackageDiscoveryTrait.php @@ -12,6 +12,7 @@ namespace Composer\Command; +use Composer\DependencyResolver\PolicyRemovalReason; use Composer\Factory; use Composer\Filter\PlatformRequirementFilter\IgnoreAllPlatformRequirementFilter; use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterFactory; @@ -22,6 +23,7 @@ use Composer\Package\Version\VersionParser; use Composer\Package\Version\VersionSelector; use Composer\Pcre\Preg; +use Composer\Policy\CooldownPolicyConfig; use Composer\Repository\CompositeRepository; use Composer\Repository\PlatformRepository; use Composer\Repository\RepositoryFactory; @@ -282,6 +284,17 @@ final protected function determineRequirements(InputInterface $input, OutputInte return $requires; } + /** + * The cooldown to apply while picking a version, or null when it would not block the resulting update + */ + private function getCooldownPolicyConfig(InputInterface $input): ?CooldownPolicyConfig + { + $composer = $this->tryComposer(); + $config = $composer !== null ? $composer->getConfig() : Factory::createConfig($this->getIO()); + + return $this->getBlockingCooldownPolicy($config, $input); + } + /** * Given a package name, this determines the best version to use in the require key. * @@ -301,7 +314,8 @@ private function findBestVersionAndNameForPackage(IOInterface $io, InputInterfac // find the latest version allowed in this repo set $repoSet = $this->getRepositorySet($input); - $versionSelector = new VersionSelector($repoSet, $platformRepo); + $cooldown = $this->getCooldownPolicyConfig($input); + $versionSelector = new VersionSelector($repoSet, $platformRepo, $cooldown); $effectiveMinimumStability = $this->getMinimumStability($input); $package = $versionSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter, 0, $this->getIO()); @@ -329,17 +343,32 @@ private function findBestVersionAndNameForPackage(IOInterface $io, InputInterfac return [$name, $constraint]; } + // The diagnostics below ask what would match with a constraint relaxed, which the cooldown must not hide + $unfilteredSelector = new VersionSelector($repoSet, $platformRepo); + + // Check whether the cooldown withheld every version + if ($cooldown !== null && false !== $unfilteredSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter)) { + $withheld = $versionSelector->getFirstClearingWithheldCandidate(); + $wait = $withheld !== null ? ' (version '.$withheld['package']->getPrettyVersion().' becomes available in '.$cooldown->formatTimeUntilAvailable($withheld['releaseDate'], new \DateTimeImmutable()).')' : ''; + + throw new \InvalidArgumentException(sprintf( + 'All versions of package %s are still in the cooldown period configured in "policy.cooldown"%s. '.PolicyRemovalReason::COOLDOWN_REMEDY, + $name, + $wait + )); + } + // Check whether the package requirements were the problem - if (!($platformRequirementFilter instanceof IgnoreAllPlatformRequirementFilter) && false !== ($candidate = $versionSelector->findBestCandidate($name, null, $preferredStability, PlatformRequirementFilterFactory::ignoreAll()))) { + if (!($platformRequirementFilter instanceof IgnoreAllPlatformRequirementFilter) && false !== ($candidate = $unfilteredSelector->findBestCandidate($name, null, $preferredStability, PlatformRequirementFilterFactory::ignoreAll()))) { throw new \InvalidArgumentException(sprintf( 'Package %s has requirements incompatible with your PHP version, PHP extensions and Composer version' . $this->getPlatformExceptionDetails($candidate, $platformRepo), $name )); } // Check whether the minimum stability was the problem but the package exists - if (false !== ($package = $versionSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter, RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES))) { + if (false !== ($package = $unfilteredSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter, RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES))) { // we must first verify if a valid package would be found in a lower priority repository - if (false !== ($allReposPackage = $versionSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter, RepositorySet::ALLOW_SHADOWED_REPOSITORIES))) { + if (false !== ($allReposPackage = $unfilteredSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter, RepositorySet::ALLOW_SHADOWED_REPOSITORIES))) { throw new \InvalidArgumentException( 'Package '.$name.' exists in '.$allReposPackage->getRepository()->getRepoName().' and '.$package->getRepository()->getRepoName().' which has a higher repository priority. The packages from the higher priority repository do not match your minimum-stability and are therefore not installable. That repository is canonical so the lower priority repo\'s packages are not installable. See https://getcomposer.org/repoprio for details and assistance.' ); @@ -352,9 +381,9 @@ private function findBestVersionAndNameForPackage(IOInterface $io, InputInterfac )); } // Check whether the PHP version was the problem for all versions - if (!$platformRequirementFilter instanceof IgnoreAllPlatformRequirementFilter && false !== ($candidate = $versionSelector->findBestCandidate($name, null, $preferredStability, PlatformRequirementFilterFactory::ignoreAll(), RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES))) { + if (!$platformRequirementFilter instanceof IgnoreAllPlatformRequirementFilter && false !== ($candidate = $unfilteredSelector->findBestCandidate($name, null, $preferredStability, PlatformRequirementFilterFactory::ignoreAll(), RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES))) { $additional = ''; - if (false === $versionSelector->findBestCandidate($name, null, $preferredStability, PlatformRequirementFilterFactory::ignoreAll())) { + if (false === $unfilteredSelector->findBestCandidate($name, null, $preferredStability, PlatformRequirementFilterFactory::ignoreAll())) { $additional = PHP_EOL.PHP_EOL.'Additionally, the package was only found with a stability of "'.$candidate->getStability().'" while your minimum stability is "'.$effectiveMinimumStability.'".'; } diff --git a/core/vendor/composer/composer/src/Composer/Command/ProhibitsCommand.php b/core/vendor/composer/composer/src/Composer/Command/ProhibitsCommand.php index 49e06694e9..1728e10878 100644 --- a/core/vendor/composer/composer/src/Composer/Command/ProhibitsCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/ProhibitsCommand.php @@ -46,7 +46,7 @@ protected function configure(): void php composer.phar prohibits composer/composer -Read more at https://getcomposer.org/doc/03-cli.md#prohibits-why-not +Read more at https://getcomposer.org/doc/03-cli.md#prohibits--why-not EOT ) ; diff --git a/core/vendor/composer/composer/src/Composer/Command/ReinstallCommand.php b/core/vendor/composer/composer/src/Composer/Command/ReinstallCommand.php index cb7882a9cf..f90eb2bf67 100644 --- a/core/vendor/composer/composer/src/Composer/Command/ReinstallCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/ReinstallCommand.php @@ -51,17 +51,18 @@ protected function configure(): void new InputOption('apcu-autoloader-prefix', null, InputOption::VALUE_REQUIRED, 'Use a custom prefix for the APCu autoloader cache. Implicitly enables --apcu-autoloader'), new InputOption('ignore-platform-req', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore a specific platform requirement (php & ext- packages).'), new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages).'), - new InputOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter packages to reinstall by type(s)', null, $this->suggestInstalledPackageTypes(false)), + new InputOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter packages to reinstall by exact type(s)', null, $this->suggestInstalledPackageTypes(false)), new InputArgument('packages', InputArgument::IS_ARRAY, 'List of package names to reinstall, can include a wildcard (*) to match any substring.', null, $this->suggestInstalledPackage(false)), ]) ->setHelp( <<reinstall command looks up installed packages by name, -uninstalls them and reinstalls them. This lets you do a clean install +The reinstall command looks up installed packages by name +(or by type with --type), uninstalls them and reinstalls them. This lets you do a clean install of a package if you messed with its files, or if you wish to change the installation type using --prefer-install. php composer.phar reinstall acme/foo "acme/bar-*" +php composer.phar reinstall --type=composer-plugin Read more at https://getcomposer.org/doc/03-cli.md#reinstall EOT diff --git a/core/vendor/composer/composer/src/Composer/Command/RemoveCommand.php b/core/vendor/composer/composer/src/Composer/Command/RemoveCommand.php index baf6410f4e..09600b1297 100644 --- a/core/vendor/composer/composer/src/Composer/Command/RemoveCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/RemoveCommand.php @@ -27,6 +27,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Composer\Package\BasePackage; use Composer\Advisory\Auditor; +use Composer\Util\Filesystem; /** * @author Pierre du Plessis @@ -51,8 +52,9 @@ protected function configure(): void new InputOption('no-install', null, InputOption::VALUE_NONE, 'Skip the install step after updating the composer.lock file.'), new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after updating the composer.lock file (can also be set via the COMPOSER_NO_AUDIT=1 env var).'), new InputOption('audit-format', null, InputOption::VALUE_REQUIRED, 'Audit output format. Must be "table", "plain", "json", or "summary".', Auditor::FORMAT_SUMMARY, Auditor::FORMATS), - new InputOption('no-security-blocking', null, InputOption::VALUE_NONE, 'DEPRECATED: use --no-blocking instead. Allows installing packages with security advisories or that are abandoned (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).'), - new InputOption('no-blocking', null, InputOption::VALUE_NONE, 'Disables all policy blocking during this command (can also be set via the COMPOSER_NO_BLOCKING=1 env var).'), + new InputOption('no-security-blocking', null, InputOption::VALUE_NONE, 'DEPRECATED: use --no-blocking instead. Allows installing packages with security advisories, that are abandoned or that are still in the cooldown period (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).'), + new InputOption('no-blocking', null, InputOption::VALUE_NONE, 'Disables all policy blocking during this command, including the cooldown period (can also be set via the COMPOSER_NO_BLOCKING=1 env var).'), + new InputOption('no-cooldown', null, InputOption::VALUE_NONE, 'Disables the cooldown policy blocking during this command, other policies still apply.'), new InputOption('update-no-dev', null, InputOption::VALUE_NONE, 'Run the dependency update with the --no-dev option.'), new InputOption('update-with-dependencies', 'w', InputOption::VALUE_NONE, 'Allows inherited dependencies to be updated with explicit dependencies (can also be set via the COMPOSER_WITH_DEPENDENCIES=1 env var). (Deprecated, is now default behavior)'), new InputOption('update-with-all-dependencies', 'W', InputOption::VALUE_NONE, 'Allows all inherited dependencies to be updated, including those that are root requirements (can also be set via the COMPOSER_WITH_ALL_DEPENDENCIES=1 env var).'), @@ -74,7 +76,7 @@ protected function configure(): void php composer.phar remove -Read more at https://getcomposer.org/doc/03-cli.md#remove-rm +Read more at https://getcomposer.org/doc/03-cli.md#remove--rm--uninstall EOT ) ; @@ -140,7 +142,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $jsonFile = new JsonFile($file); /** @var array{require?: array, require-dev?: array} $composer */ $composer = $jsonFile->read(); - $composerBackup = file_get_contents($jsonFile->getPath()); + $composerBackup = (string) file_get_contents($jsonFile->getPath()); $json = new JsonConfigSource($jsonFile); @@ -299,7 +301,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $status = $install->run(); if ($status !== 0) { $io->writeError("\n".'Removal failed, reverting '.$file.' to its original content.'); - file_put_contents($jsonFile->getPath(), $composerBackup); + Filesystem::safeFilePutContents($jsonFile->getPath(), $composerBackup); } if (!$dryRun) { diff --git a/core/vendor/composer/composer/src/Composer/Command/RequireCommand.php b/core/vendor/composer/composer/src/Composer/Command/RequireCommand.php index c274ea3a31..d5167553cb 100644 --- a/core/vendor/composer/composer/src/Composer/Command/RequireCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/RequireCommand.php @@ -39,6 +39,8 @@ use Composer\Plugin\PluginEvents; use Composer\Repository\CompositeRepository; use Composer\Repository\PlatformRepository; +use Composer\Console\ProgramStatus; +use Composer\IO\ConsoleIO; use Composer\IO\IOInterface; use Composer\Advisory\Auditor; use Composer\Util\Silencer; @@ -82,15 +84,16 @@ protected function configure(): void new InputOption('prefer-source', null, InputOption::VALUE_NONE, 'Forces installation from package sources when possible, including VCS information.'), new InputOption('prefer-dist', null, InputOption::VALUE_NONE, 'Forces installation from package dist (default behavior).'), new InputOption('prefer-install', null, InputOption::VALUE_REQUIRED, 'Forces installation from package dist|source|auto (auto chooses source for dev versions, dist for the rest).', null, $this->suggestPreferInstall()), - new InputOption('fixed', null, InputOption::VALUE_NONE, 'Write fixed version to the composer.json.'), + new InputOption('fixed', null, InputOption::VALUE_NONE, 'Write the exact resolved version to the composer.json instead of the recommended constraint (only allowed when the root package type is "project", or with --dev).'), new InputOption('no-suggest', null, InputOption::VALUE_NONE, 'DEPRECATED: This flag does not exist anymore.'), new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'), new InputOption('no-update', null, InputOption::VALUE_NONE, 'Disables the automatic update of the dependencies (implies --no-install).'), new InputOption('no-install', null, InputOption::VALUE_NONE, 'Skip the install step after updating the composer.lock file.'), new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after updating the composer.lock file (can also be set via the COMPOSER_NO_AUDIT=1 env var).'), new InputOption('audit-format', null, InputOption::VALUE_REQUIRED, 'Audit output format. Must be "table", "plain", "json", or "summary".', Auditor::FORMAT_SUMMARY, Auditor::FORMATS), - new InputOption('no-security-blocking', null, InputOption::VALUE_NONE, 'DEPRECATED: use --no-blocking instead. Allows installing packages with security advisories or that are abandoned (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).'), - new InputOption('no-blocking', null, InputOption::VALUE_NONE, 'Disables all policy blocking during this command (can also be set via the COMPOSER_NO_BLOCKING=1 env var).'), + new InputOption('no-security-blocking', null, InputOption::VALUE_NONE, 'DEPRECATED: use --no-blocking instead. Allows installing packages with security advisories, that are abandoned or that are still in the cooldown period (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).'), + new InputOption('no-blocking', null, InputOption::VALUE_NONE, 'Disables all policy blocking during this command, including the cooldown period (can also be set via the COMPOSER_NO_BLOCKING=1 env var).'), + new InputOption('no-cooldown', null, InputOption::VALUE_NONE, 'Disables the cooldown policy blocking during this command, other policies still apply.'), new InputOption('update-no-dev', null, InputOption::VALUE_NONE, 'Run the dependency update with the --no-dev option.'), new InputOption('update-with-dependencies', 'w', InputOption::VALUE_NONE, 'Allows inherited dependencies to be updated, except those that are root requirements (can also be set via the COMPOSER_WITH_DEPENDENCIES=1 env var).'), new InputOption('update-with-all-dependencies', 'W', InputOption::VALUE_NONE, 'Allows all inherited dependencies to be updated, including those that are root requirements (can also be set via the COMPOSER_WITH_ALL_DEPENDENCIES=1 env var).'), @@ -118,7 +121,7 @@ protected function configure(): void If you do not want to install the new dependencies immediately you can call it with --no-update -Read more at https://getcomposer.org/doc/03-cli.md#require-r +Read more at https://getcomposer.org/doc/03-cli.md#require--r EOT ) ; @@ -149,7 +152,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if (filesize($this->file) === 0) { - file_put_contents($this->file, "{\n}\n"); + Filesystem::safeFilePutContents($this->file, "{\n}\n"); } $this->json = new JsonFile($this->file); @@ -160,6 +163,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $signalHandler = SignalHandler::create([SignalHandler::SIGINT, SignalHandler::SIGTERM, SignalHandler::SIGHUP], function (string $signal, SignalHandler $handler) { $this->getIO()->writeError('Received '.$signal.', aborting', true, IOInterface::DEBUG); $this->revertComposerFile(); + $io = $this->getIO(); + if ($io instanceof ConsoleIO) { + $io->writeProgramStatus(ProgramStatus::idle()); + } $handler->exitWithLastSignal(); }); @@ -180,7 +187,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int * @see https://github.com/composer/composer/pull/8313#issuecomment-532637955 */ if ($packageType !== 'project' && !$input->getOption('dev')) { - $io->writeError('The "--fixed" option is only allowed for packages with a "project" type or for dev dependencies to prevent possible misuses.'); + $io->writeError('The "--fixed" option is only allowed when the root package has a "project" type or for dev dependencies to prevent possible misuses.'); if (!isset($config['type'])) { $io->writeError('If your package is not a library, you can explicitly specify the "type" by using "composer config type project".'); @@ -607,7 +614,7 @@ private function updateFileCleanly(JsonFile $json, array $new, string $requireKe $manipulator->removeMainKeyIfEmpty($removeKey); - file_put_contents($json->getPath(), $manipulator->getContents()); + Filesystem::safeFilePutContents($json->getPath(), $manipulator->getContents()); return true; } @@ -632,9 +639,9 @@ private function revertComposerFile(): void $msg = ' and '.$this->lock.' to their '; } $io->writeError("\n".'Installation failed, reverting '.$this->file.$msg.'original content.'); - file_put_contents($this->json->getPath(), $this->composerBackup); + Filesystem::safeFilePutContents($this->json->getPath(), $this->composerBackup); if ($this->lockBackup) { - file_put_contents($this->lock, $this->lockBackup); + Filesystem::safeFilePutContents($this->lock, $this->lockBackup); } } } diff --git a/core/vendor/composer/composer/src/Composer/Command/RunScriptCommand.php b/core/vendor/composer/composer/src/Composer/Command/RunScriptCommand.php index e7b6d820a0..a68a143cd7 100644 --- a/core/vendor/composer/composer/src/Composer/Command/RunScriptCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/RunScriptCommand.php @@ -67,7 +67,7 @@ protected function configure(): void php composer.phar run-script post-update-cmd -Read more at https://getcomposer.org/doc/03-cli.md#run-script-run +Read more at https://getcomposer.org/doc/03-cli.md#run-script--run EOT ) ; diff --git a/core/vendor/composer/composer/src/Composer/Command/ScriptAliasCommand.php b/core/vendor/composer/composer/src/Composer/Command/ScriptAliasCommand.php index d178210c2b..1a9c7cdac9 100644 --- a/core/vendor/composer/composer/src/Composer/Command/ScriptAliasCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/ScriptAliasCommand.php @@ -68,7 +68,7 @@ protected function configure(): void php composer.phar run-script post-update-cmd -Read more at https://getcomposer.org/doc/03-cli.md#run-script-run +Read more at https://getcomposer.org/doc/03-cli.md#run-script--run EOT ) ; diff --git a/core/vendor/composer/composer/src/Composer/Command/SelfUpdateCommand.php b/core/vendor/composer/composer/src/Composer/Command/SelfUpdateCommand.php index be1d4f8aef..03da479a84 100644 --- a/core/vendor/composer/composer/src/Composer/Command/SelfUpdateCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/SelfUpdateCommand.php @@ -68,7 +68,7 @@ protected function configure(): void php composer.phar self-update -Read more at https://getcomposer.org/doc/03-cli.md#self-update-selfupdate +Read more at https://getcomposer.org/doc/03-cli.md#self-update--selfupdate EOT ) ; @@ -102,6 +102,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int class_exists('Composer\Util\Platform'); class_exists('Composer\Downloader\FilesystemException'); class_exists('Composer\Console\GithubActionError'); + class_exists('Symfony\Component\Console\Helper\Helper'); + class_exists('Symfony\Component\String\UnicodeString'); $config = Factory::createConfig(); diff --git a/core/vendor/composer/composer/src/Composer/Command/ShowCommand.php b/core/vendor/composer/composer/src/Composer/Command/ShowCommand.php index 6c068ec69f..1804e9503d 100644 --- a/core/vendor/composer/composer/src/Composer/Command/ShowCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/ShowCommand.php @@ -12,7 +12,9 @@ namespace Composer\Command; +use Composer\Cache; use Composer\Composer; +use Composer\Config; use Composer\DependencyResolver\DefaultPolicy; use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterInterface; use Composer\IO\IOInterface; @@ -24,6 +26,7 @@ use Composer\Package\PackageInterface; use Composer\Package\Version\VersionParser; use Composer\Package\Version\VersionSelector; +use Composer\Policy\CooldownPolicyConfig; use Composer\Pcre\Preg; use Composer\Plugin\CommandEvent; use Composer\Plugin\PluginEvents; @@ -42,6 +45,7 @@ use Composer\Semver\Semver; use Composer\Spdx\SpdxLicenses; use Composer\Util\PackageInfo; +use DateTimeImmutable; use DateTimeInterface; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -97,6 +101,7 @@ protected function configure(): void new InputOption('minor-only', 'm', InputOption::VALUE_NONE, 'Show only packages that have minor SemVer-compatible updates. Use with the --latest or --outdated option.'), new InputOption('patch-only', null, InputOption::VALUE_NONE, 'Show only packages that have patch SemVer-compatible updates. Use with the --latest or --outdated option.'), new InputOption('sort-by-age', 'A', InputOption::VALUE_NONE, 'Displays the installed version\'s age, and sorts packages oldest first. Use with the --latest or --outdated option.'), + new InputOption('cooldown-period', null, InputOption::VALUE_REQUIRED, 'Overrides the configured policy.cooldown.period while looking up latest versions, even when the policy is disabled or not blocking, 0 disables it for this run. Use with the --latest or --outdated option'), new InputOption('direct', 'D', InputOption::VALUE_NONE, 'Shows only packages that are directly required by the root package'), new InputOption('strict', null, InputOption::VALUE_NONE, 'Return a non-zero exit code when there are outdated packages'), new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text', ['json', 'text']), @@ -109,7 +114,7 @@ protected function configure(): void The show command displays detailed information about a package, or lists all packages available. -Read more at https://getcomposer.org/doc/03-cli.md#show-info +Read more at https://getcomposer.org/doc/03-cli.md#show--info EOT ) ; @@ -288,6 +293,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $input->setOption('latest', false); } + $cooldown = null; + if ($input->getOption('latest') && $composer !== null) { + $cooldown = $this->getCooldownPolicyConfig($composer->getConfig(), $input); + } + $packageFilter = $input->getArgument('package'); // show single package or single version @@ -339,8 +349,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $latestPackage = null; + $withheld = null; if ($input->getOption('latest')) { - $latestPackage = $this->findLatestPackage($package, $composer, $platformRepo, $input->getOption('major-only'), $input->getOption('minor-only'), $input->getOption('patch-only'), $platformReqFilter); + $found = $this->findLatestPackage($package, $composer, $platformRepo, $input->getOption('major-only'), $input->getOption('minor-only'), $input->getOption('patch-only'), $platformReqFilter, $cooldown); + $latestPackage = $found['latest']; + $withheld = $found['withheld']; } if ( $input->getOption('outdated') @@ -364,9 +377,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ('json' === $format) { - $this->printPackageInfoAsJson($package, $versions, $installedRepo, $latestPackage ?: null); + $this->printPackageInfoAsJson($package, $versions, $installedRepo, $latestPackage ?: null, $withheld); } else { - $this->printPackageInfo($package, $versions, $installedRepo, $latestPackage ?: null); + $this->printPackageInfo($package, $versions, $installedRepo, $latestPackage ?: null, $withheld); } return $exitCode; @@ -460,6 +473,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $indent = $showAllTypes ? ' ' : ''; /** @var PackageInterface[] $latestPackages */ $latestPackages = []; + /** @var array newer releases still in the cooldown period than the newest installable version */ + $withheldPackages = []; $exitCode = 0; $viewData = []; $viewMetaData = []; @@ -467,22 +482,42 @@ protected function execute(InputInterface $input, OutputInterface $output): int $writeVersion = false; $writeDescription = false; + $latestLookups = []; + if ($showLatest) { + foreach (['platform', 'locked', 'installed'] as $type) { + foreach ($packages[$type] ?? [] as $package) { + if (is_object($package) && !Preg::isMatch($ignoredPackagesRegex, $package->getPrettyName())) { + $latestLookups[$type][] = $package; + } + } + } + + // load all metadata in parallel upfront so the per-package lookups hit the fresh cache, which needs the cache to be usable + if ($latestLookups !== [] && $composer !== null && Cache::isUsable($composer->getConfig()->get('cache-repo-dir'))) { + $names = []; + foreach ($latestLookups as $lookups) { + foreach ($lookups as $package) { + $names[$package->getName()] = true; + } + } + $this->getRepositorySet($composer)->preloadPackages(array_keys($names)); + } + } + foreach (['platform' => true, 'locked' => true, 'available' => false, 'installed' => true] as $type => $showVersion) { if (isset($packages[$type])) { ksort($packages[$type]); $nameLength = $versionLength = $latestLength = $releaseDateLength = 0; - if ($showLatest && $showVersion) { - foreach ($packages[$type] as $package) { - if (is_object($package) && !Preg::isMatch($ignoredPackagesRegex, $package->getPrettyName())) { - $latestPackage = $this->findLatestPackage($package, $composer, $platformRepo, $showMajorOnly, $showMinorOnly, $showPatchOnly, $platformReqFilter); - if ($latestPackage === null) { - continue; - } - - $latestPackages[$package->getPrettyName()] = $latestPackage; - } + foreach ($latestLookups[$type] ?? [] as $package) { + unset($withheldPackages[$package->getPrettyName()]); + $found = $this->findLatestPackage($package, $composer, $platformRepo, $showMajorOnly, $showMinorOnly, $showPatchOnly, $platformReqFilter, $cooldown); + if ($found['latest'] !== null) { + $latestPackages[$package->getPrettyName()] = $found['latest']; + } + if ($found['withheld'] !== null) { + $withheldPackages[$package->getPrettyName()] = $found['withheld']; } } @@ -518,11 +553,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int // When using --major-only, and no bigger version than current major is found then it is considered up to date $packageIsUpToDate = $packageIsUpToDate || ($latestPackage === null && $showMajorOnly); $packageIsIgnored = Preg::isMatch($ignoredPackagesRegex, $package->getPrettyName()); - if ($input->getOption('outdated') && ($packageIsUpToDate || $packageIsIgnored)) { + $withheld = $withheldPackages[$package->getPrettyName()] ?? null; + if ($input->getOption('outdated') && ($packageIsIgnored || ($packageIsUpToDate && $withheld === null))) { continue; } - if ($input->getOption('outdated') || $input->getOption('strict')) { + // a release still in the cooldown period cannot be installed yet, so it does not make the package outdated + if (($input->getOption('outdated') || $input->getOption('strict')) && !$packageIsUpToDate) { $hasOutdatedPackages = true; } @@ -553,19 +590,33 @@ protected function execute(InputInterface $input, OutputInterface $output): int $packageViewData['release-date'] = ''; } } - if ($writeLatest && $latestPackage) { - $packageViewData['latest'] = $latestPackage->getFullPrettyVersion(); + // the installable update takes the slot, else the release waiting for the cooldown period to end + $hasInstallableUpdate = $latestPackage !== null && $latestPackage->getFullPrettyVersion() !== $package->getFullPrettyVersion(); + $shownPackage = $withheld !== null && !$hasInstallableUpdate ? $withheld['package'] : $latestPackage; + if ($writeLatest && $shownPackage !== null) { + $shortAvailableIn = $withheld !== null && $cooldown !== null ? $cooldown->formatTimeUntilAvailable($withheld['releaseDate'], new DateTimeImmutable(), true) : ''; + $packageViewData['latest'] = $shownPackage->getFullPrettyVersion(); if ($format === 'text') { $packageViewData['latest'] = ltrim($packageViewData['latest'], 'v'); } - $packageViewData['latest-status'] = $this->getUpdateStatus($latestPackage, $package); - $latestLength = max($latestLength, strlen($packageViewData['latest'])); - - if ($latestPackage->getReleaseDate() !== null) { - $packageViewData['latest-release-date'] = $latestPackage->getReleaseDate()->format(DateTimeInterface::ATOM); + $packageViewData['latest-release-date'] = $shownPackage->getReleaseDate() !== null ? $shownPackage->getReleaseDate()->format(DateTimeInterface::ATOM) : ''; + if ($withheld !== null && !$hasInstallableUpdate) { + $packageViewData['latest-status'] = 'cooldown'; + $packageViewData['cooldown-available-in'] = $withheld['availableIn']; + if ($format === 'text') { + $packageViewData['latest'] .= ' ('.$shortAvailableIn.' left)'; + } } else { - $packageViewData['latest-release-date'] = ''; + $packageViewData['latest-status'] = $this->getUpdateStatus($shownPackage, $package); + if ($withheld !== null) { + $packageViewData['cooldown-latest'] = $withheld['package']->getFullPrettyVersion(); + $packageViewData['cooldown-available-in'] = $withheld['availableIn']; + if ($format === 'text') { + $packageViewData['latest'] .= ' ('.ltrim($packageViewData['cooldown-latest'], 'v').' in '.$shortAvailableIn.')'; + } + } } + $latestLength = max($latestLength, strlen($packageViewData['latest'])); } elseif ($writeLatest) { $packageViewData['latest'] = '[none matched]'; $packageViewData['latest-status'] = 'up-to-date'; @@ -628,6 +679,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->writeError('Legend:'); $io->writeError('! patch or minor release available - update recommended'); $io->writeError('~ major release available - update possible'); + if ($cooldown !== null) { + $io->writeError('c newer release still in the cooldown period - not installable yet'); + } if (!$input->getOption('outdated')) { $io->writeError('= up to date version'); } @@ -635,6 +689,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->writeError('Color legend:'); $io->writeError('- patch or minor release available - update recommended'); $io->writeError('- major release available - update possible'); + if ($cooldown !== null) { + $io->writeError('- release still in the cooldown period - not installable yet (shown without color)'); + } if (!$input->getOption('outdated')) { $io->writeError('- up to date version'); } @@ -734,9 +791,10 @@ private function printPackages(IOInterface $io, array $packages, string $indent, $updateStatus = $package['latest-status']; $style = $this->updateStatusToVersionStyle($updateStatus); if (!$io->isDecorated()) { - $latestVersion = str_replace(['up-to-date', 'semver-safe-update', 'update-possible'], ['=', '!', '~'], $updateStatus) . ' ' . $latestVersion; + $latestVersion = str_replace(['up-to-date', 'semver-safe-update', 'update-possible', 'cooldown'], ['=', '!', '~', 'c'], $updateStatus) . ' ' . $latestVersion; } - $io->write(' <' . $style . '>' . str_pad($latestVersion, ($padLatest ? $latestLength : 0), ' ') . '', false); + $latestVersion = str_pad($latestVersion, ($padLatest ? $latestLength : 0), ' '); + $io->write(' ' . ($style !== '' ? '<' . $style . '>' . $latestVersion . '' : $latestVersion), false); if ($writeReleaseDate && isset($package['release-age'])) { $io->write(' '.str_pad($package['release-age'], ($padReleaseDate ? $releaseDateLength : 0), ' '), false); } @@ -865,12 +923,13 @@ protected function getPackage(InstalledRepository $installedRepo, RepositoryInte * Prints package info. * * @param array $versions + * @param array{package: PackageInterface, releaseDate: DateTimeInterface, availableIn: string}|null $withheld newer release still in the cooldown period */ - protected function printPackageInfo(CompletePackageInterface $package, array $versions, InstalledRepository $installedRepo, ?PackageInterface $latestPackage = null): void + protected function printPackageInfo(CompletePackageInterface $package, array $versions, InstalledRepository $installedRepo, ?PackageInterface $latestPackage = null, ?array $withheld = null): void { $io = $this->getIO(); - $this->printMeta($package, $versions, $installedRepo, $latestPackage ?: null); + $this->printMeta($package, $versions, $installedRepo, $latestPackage ?: null, $withheld); $this->printLinks($package, Link::TYPE_REQUIRE); $this->printLinks($package, Link::TYPE_DEV_REQUIRE, 'requires (dev)'); @@ -890,8 +949,9 @@ protected function printPackageInfo(CompletePackageInterface $package, array $ve * Prints package metadata. * * @param array $versions + * @param array{package: PackageInterface, releaseDate: DateTimeInterface, availableIn: string}|null $withheld newer release still in the cooldown period */ - protected function printMeta(CompletePackageInterface $package, array $versions, InstalledRepository $installedRepo, ?PackageInterface $latestPackage = null): void + protected function printMeta(CompletePackageInterface $package, array $versions, InstalledRepository $installedRepo, ?PackageInterface $latestPackage = null, ?array $withheld = null): void { $isInstalledPackage = !PlatformRepository::isPlatformPackage($package->getName()) && $installedRepo->hasPackage($package); @@ -903,10 +963,17 @@ protected function printMeta(CompletePackageInterface $package, array $versions, if ($isInstalledPackage && $package->getReleaseDate() !== null) { $io->write('released : ' . $package->getReleaseDate()->format('Y-m-d') . ', ' . $this->getRelativeTime($package->getReleaseDate())); } - if ($latestPackage) { + $hasInstallableUpdate = $latestPackage !== null && $latestPackage->getFullPrettyVersion() !== $package->getFullPrettyVersion(); + if ($withheld !== null && !$hasInstallableUpdate) { + $io->write('latest : ' . $withheld['package']->getPrettyVersion() . ' (still in the cooldown period, ' . $withheld['availableIn'] . ' left)'); + $latestPackage = $latestPackage ?? $package; + } elseif ($latestPackage !== null) { $style = $this->getVersionStyle($latestPackage, $package); $releasedTime = $latestPackage->getReleaseDate() === null ? '' : ' released ' . $latestPackage->getReleaseDate()->format('Y-m-d') . ', ' . $this->getRelativeTime($latestPackage->getReleaseDate()); $io->write('latest : <'.$style.'>' . $latestPackage->getPrettyVersion() . '' . $releasedTime); + if ($withheld !== null) { + $io->write('cooldown : ' . $withheld['package']->getPrettyVersion() . ' (still in the cooldown period, ' . $withheld['availableIn'] . ' left)'); + } } else { $latestPackage = $package; } @@ -1037,8 +1104,9 @@ protected function printLicenses(CompletePackageInterface $package): void * Prints package info in JSON format. * * @param array $versions + * @param array{package: PackageInterface, releaseDate: DateTimeInterface, availableIn: string}|null $withheld newer release still in the cooldown period */ - protected function printPackageInfoAsJson(CompletePackageInterface $package, array $versions, InstalledRepository $installedRepo, ?PackageInterface $latestPackage = null): void + protected function printPackageInfoAsJson(CompletePackageInterface $package, array $versions, InstalledRepository $installedRepo, ?PackageInterface $latestPackage = null, ?array $withheld = null): void { $json = [ 'name' => $package->getPrettyName(), @@ -1052,8 +1120,18 @@ protected function printPackageInfoAsJson(CompletePackageInterface $package, arr $json = $this->appendVersions($json, $versions); $json = $this->appendLicenses($json, $package); - if ($latestPackage) { + $hasInstallableUpdate = $latestPackage !== null && $latestPackage->getFullPrettyVersion() !== $package->getFullPrettyVersion(); + if ($withheld !== null && !$hasInstallableUpdate) { + $json['latest'] = $withheld['package']->getPrettyVersion(); + $json['latest-status'] = 'cooldown'; + $json['cooldown-available-in'] = $withheld['availableIn']; + $latestPackage = $latestPackage ?? $package; + } elseif ($latestPackage !== null) { $json['latest'] = $latestPackage->getPrettyVersion(); + if ($withheld !== null) { + $json['cooldown-latest'] = $withheld['package']->getPrettyVersion(); + $json['cooldown-available-in'] = $withheld['availableIn']; + } } else { $latestPackage = $package; } @@ -1309,6 +1387,11 @@ protected function generatePackageTree( 'version' => $require->getPrettyConstraint(), ]; + $installedVersion = $this->getInstalledVersion($installedRepo, $requireName); + if (null !== $installedVersion) { + $treeChildDesc['installed'] = $installedVersion; + } + $deepChildren = $this->addTree($requireName, $require, $installedRepo, $remoteRepos, $packagesInTree); if ($deepChildren) { @@ -1415,6 +1498,11 @@ protected function addTree( 'version' => $require->getPrettyConstraint(), ]; + $installedVersion = $this->getInstalledVersion($installedRepo, $requireName); + if (null !== $installedVersion) { + $treeChildDesc['installed'] = $installedVersion; + } + if (!in_array($requireName, $currentTree, true)) { $currentTree[] = $requireName; $deepChildren = $this->addTree($requireName, $require, $installedRepo, $remoteRepos, $currentTree); @@ -1430,12 +1518,31 @@ protected function addTree( return $children; } + /** + * Returns the pretty version of the installed package matching the given + * name (honoring replace/provide), or null if it is not installed. + */ + private function getInstalledVersion(InstalledRepository $installedRepo, string $name): ?string + { + foreach ($installedRepo->findPackagesWithReplacersAndProviders($name) as $package) { + // resolve aliases to report the actually installed version rather than the alias version + while ($package instanceof AliasPackage) { + $package = $package->getAliasOf(); + } + + return $package->getPrettyVersion(); + } + + return null; + } + private function updateStatusToVersionStyle(string $updateStatus): string { // 'up-to-date' is printed green // 'semver-safe-update' is printed red // 'update-possible' is printed yellow - return str_replace(['up-to-date', 'semver-safe-update', 'update-possible'], ['info', 'highlight', 'comment'], $updateStatus); + // 'cooldown' is printed without color, as it is neither fine nor actionable yet + return str_replace(['up-to-date', 'semver-safe-update', 'update-possible', 'cooldown'], ['info', 'highlight', 'comment', ''], $updateStatus); } private function getUpdateStatus(PackageInterface $latestPackage, PackageInterface $package): string @@ -1468,13 +1575,16 @@ private function writeTreeLine(string $line): void } /** - * Given a package, this finds the latest package matching it + * Given a package, this finds the latest package matching it, and the release still in the cooldown + * period that becomes installable first when a cooldown policy applies + * + * @return array{latest: ?PackageInterface, withheld: ?array{package: PackageInterface, releaseDate: DateTimeInterface, availableIn: string}} */ - private function findLatestPackage(PackageInterface $package, Composer $composer, PlatformRepository $platformRepo, bool $majorOnly, bool $minorOnly, bool $patchOnly, PlatformRequirementFilterInterface $platformReqFilter): ?PackageInterface + private function findLatestPackage(PackageInterface $package, Composer $composer, PlatformRepository $platformRepo, bool $majorOnly, bool $minorOnly, bool $patchOnly, PlatformRequirementFilterInterface $platformReqFilter, ?CooldownPolicyConfig $cooldown = null): array { // find the latest version allowed in this repo set $name = $package->getName(); - $versionSelector = new VersionSelector($this->getRepositorySet($composer), $platformRepo); + $versionSelector = new VersionSelector($this->getRepositorySet($composer), $platformRepo, $cooldown); $stability = $composer->getPackage()->getMinimumStability(); $flags = $composer->getPackage()->getStabilityFlags(); if (isset($flags[$name])) { @@ -1492,7 +1602,7 @@ private function findLatestPackage(PackageInterface $package, Composer $composer // dev-x branches are considered to be on the latest major version always, do not look up for a new commit as that is deemed a minor upgrade (albeit risky) if ($majorOnly) { - return null; + return ['latest' => null, 'withheld' => null]; } } @@ -1531,7 +1641,72 @@ private function findLatestPackage(PackageInterface $package, Composer $composer $candidate = $candidate->getAliasOf(); } - return $candidate !== false ? $candidate : null; + $latest = $candidate !== false ? $candidate : null; + // versions exist but none is installable on this platform, so there is nothing to update to + if ($latest === null && count($versionSelector->getPlatformRejectedCandidates()) > 0) { + $latest = $package; + } + $withheld = null; + if ($cooldown !== null && !str_starts_with($package->getVersion(), 'dev-')) { + // a waiting release is only worth mentioning if it is newer than what can be installed already + $baseline = $latest !== null && version_compare($latest->getVersion(), $package->getVersion(), '>') ? $latest : $package; + $installedIsWithheld = false; + foreach ($versionSelector->getWithheldCandidates() as $withheldCandidate) { + $withheldPackage = $withheldCandidate['package']; + while ($withheldPackage instanceof AliasPackage) { + $withheldPackage = $withheldPackage->getAliasOf(); + } + if ($withheldPackage->getVersion() === $package->getVersion()) { + $installedIsWithheld = true; + } + // the first release to clear the cooldown period among those newer than the baseline, + // a withheld backport to an older branch is no update + if (!version_compare($withheldPackage->getVersion(), $baseline->getVersion(), '>')) { + continue; + } + if ($withheld === null || $withheldCandidate['releaseDate'] < $withheld['releaseDate']) { + $withheld = ['package' => $withheldPackage, 'releaseDate' => $withheldCandidate['releaseDate']]; + } + } + + // the installed version is itself still in the cooldown period, e.g. the period was configured after + // installing it, so the older releases the selector fell back to are no update either + if ($installedIsWithheld && ($latest === null || version_compare($latest->getVersion(), $package->getVersion(), '<'))) { + $latest = $package; + } + + if ($withheld !== null) { + $withheld['availableIn'] = $cooldown->formatTimeUntilAvailable($withheld['releaseDate'], new DateTimeImmutable()); + } + } + + return [ + 'latest' => $latest, + 'withheld' => $withheld, + ]; + } + + /** + * The cooldown policy to apply while looking up latest versions, so that the result matches what + * update would install, or null when no cooldown period applies + */ + private function getCooldownPolicyConfig(Config $config, InputInterface $input): ?CooldownPolicyConfig + { + $override = $input->getOption('cooldown-period'); + if ($override !== null) { + try { + $period = CooldownPolicyConfig::parseDuration($override); + } catch (\RuntimeException $e) { + throw new \InvalidArgumentException('Invalid --cooldown-period value "'.$override.'", use an integer number of seconds or a duration like "7 days".', 0, $e); + } + // an explicit period simulates an enabled, blocking policy whatever the configuration says + $configured = $this->createPolicyConfig($config, $input)->cooldown; + $cooldown = new CooldownPolicyConfig(true, $configured->audit, $configured->ignore, $period); + + return $cooldown->hasCooldown() ? $cooldown : null; + } + + return $this->getBlockingCooldownPolicy($config, $input); } private function getRepositorySet(Composer $composer): RepositorySet diff --git a/core/vendor/composer/composer/src/Composer/Command/SuggestsCommand.php b/core/vendor/composer/composer/src/Composer/Command/SuggestsCommand.php index df63b3eb42..9a3fe7dbc0 100644 --- a/core/vendor/composer/composer/src/Composer/Command/SuggestsCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/SuggestsCommand.php @@ -67,13 +67,51 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $installedRepo = new InstalledRepository($installedRepos); - $reporter = new SuggestedPackagesReporter($this->getIO()); - $filter = $input->getArgument('packages'); + $normalizedFilter = array_map('strtolower', $filter); + $validationRepo = $installedRepo; + if ($locker->isLocked() && $input->getOption('no-dev')) { + $validationRepo = new InstalledRepository([$locker->getLockedRepository(true)]); + } + + $missingPackages = []; + $excludedPackages = []; + foreach ($normalizedFilter as $index => $package) { + if ($installedRepo->findPackages($package) !== []) { + continue; + } + + if ($validationRepo->findPackages($package) !== []) { + $excludedPackages[] = $filter[$index]; + } else { + $missingPackages[] = $filter[$index]; + } + } + + $errors = []; + if ($missingPackages !== []) { + $errors[] = count($missingPackages) === 1 + ? 'Package "'.$missingPackages[0].'" is not installed.' + : 'Packages "'.implode('", "', $missingPackages).'" are not installed.'; + } + if ($excludedPackages !== []) { + $errors[] = count($excludedPackages) === 1 + ? 'Package "'.$excludedPackages[0].'" is excluded by --no-dev.' + : 'Packages "'.implode('", "', $excludedPackages).'" are excluded by --no-dev.'; + } + if ($errors !== []) { + foreach ($errors as $error) { + $this->getIO()->writeError(''.$error.''); + } + + return 1; + } + + $reporter = new SuggestedPackagesReporter($this->getIO()); $packages = $installedRepo->getPackages(); $packages[] = $composer->getPackage(); foreach ($packages as $package) { - if (!empty($filter) && !in_array($package->getName(), $filter)) { + if ($normalizedFilter !== [] && !in_array($package->getName(), $normalizedFilter, true)) { continue; } @@ -96,7 +134,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $mode = SuggestedPackagesReporter::MODE_LIST; } - $reporter->output($mode, $installedRepo, empty($filter) && !$input->getOption('all') ? $composer->getPackage() : null); + $reporter->output($mode, $installedRepo, $normalizedFilter === [] && !$input->getOption('all') ? $composer->getPackage() : null); return 0; } diff --git a/core/vendor/composer/composer/src/Composer/Command/UpdateCommand.php b/core/vendor/composer/composer/src/Composer/Command/UpdateCommand.php index 73917078b6..ea73107773 100644 --- a/core/vendor/composer/composer/src/Composer/Command/UpdateCommand.php +++ b/core/vendor/composer/composer/src/Composer/Command/UpdateCommand.php @@ -66,8 +66,9 @@ protected function configure(): void new InputOption('no-install', null, InputOption::VALUE_NONE, 'Skip the install step after updating the composer.lock file.'), new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after updating the composer.lock file (can also be set via the COMPOSER_NO_AUDIT=1 env var).'), new InputOption('audit-format', null, InputOption::VALUE_REQUIRED, 'Audit output format. Must be "table", "plain", "json", or "summary".', Auditor::FORMAT_SUMMARY, Auditor::FORMATS), - new InputOption('no-security-blocking', null, InputOption::VALUE_NONE, 'DEPRECATED: use --no-blocking instead. Allows installing packages with security advisories or that are abandoned (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).'), - new InputOption('no-blocking', null, InputOption::VALUE_NONE, 'Disables all policy blocking during this command (can also be set via the COMPOSER_NO_BLOCKING=1 env var).'), + new InputOption('no-security-blocking', null, InputOption::VALUE_NONE, 'DEPRECATED: use --no-blocking instead. Allows installing packages with security advisories, that are abandoned or that are still in the cooldown period (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).'), + new InputOption('no-blocking', null, InputOption::VALUE_NONE, 'Disables all policy blocking during this command, including the cooldown period (can also be set via the COMPOSER_NO_BLOCKING=1 env var).'), + new InputOption('no-cooldown', null, InputOption::VALUE_NONE, 'Disables the cooldown policy blocking during this command, other policies still apply.'), new InputOption('no-autoloader', null, InputOption::VALUE_NONE, 'Skips autoloader generation'), new InputOption('no-suggest', null, InputOption::VALUE_NONE, 'DEPRECATED: This flag does not exist anymore.'), new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'), @@ -76,7 +77,7 @@ protected function configure(): void new InputOption('verbose', 'v|vv|vvv', InputOption::VALUE_NONE, 'Shows more details including new commits pulled in when updating packages.'), new InputOption('optimize-autoloader', 'o', InputOption::VALUE_NONE, 'Optimize autoloader during autoloader dump.'), new InputOption('classmap-authoritative', 'a', InputOption::VALUE_NONE, 'Autoload classes from the classmap only. Implicitly enables `--optimize-autoloader`.'), - new InputOption('strict-psr-autoloader', null, InputOption::VALUE_NONE, 'Return a failed status code (6) if PSR-4 or PSR-0 mapping errors are present. Requires --optimize-autoloader to work.'), + new InputOption('strict-psr-autoloader', null, InputOption::VALUE_NONE, 'Return a failed status code (6) if PSR-4 or PSR-0 mapping errors are present. Requires --optimize-autoloader or --classmap-authoritative to work.'), new InputOption('apcu-autoloader', null, InputOption::VALUE_NONE, 'Use APCu to cache found/not-found classes.'), new InputOption('apcu-autoloader-prefix', null, InputOption::VALUE_REQUIRED, 'Use a custom prefix for the APCu autoloader cache. Implicitly enables --apcu-autoloader'), new InputOption('ignore-platform-req', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore a specific platform requirement (php & ext- packages).'), @@ -117,7 +118,7 @@ protected function configure(): void To select packages names interactively with auto-completion use -i. -Read more at https://getcomposer.org/doc/03-cli.md#update-u-upgrade +Read more at https://getcomposer.org/doc/03-cli.md#update--u--upgrade EOT ) ; @@ -259,11 +260,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $optimize = $input->getOption('optimize-autoloader') || $config->get('optimize-autoloader'); $authoritative = $input->getOption('classmap-authoritative') || $config->get('classmap-authoritative'); + $strictPsrAutoloader = $input->getOption('strict-psr-autoloader') || $config->get('strict-psr-autoloader'); $apcuPrefix = $input->getOption('apcu-autoloader-prefix'); $apcu = $apcuPrefix !== null || $input->getOption('apcu-autoloader') || $config->get('apcu-autoloader'); $minimalChanges = $input->getOption('minimal-changes') || $config->get('update-with-minimal-changes'); - if ($input->getOption('strict-psr-autoloader') && !$optimize && !$authoritative) { + if ($strictPsrAutoloader && !$optimize && !$authoritative) { throw new \InvalidArgumentException('--strict-psr-autoloader mode only works with optimized autoloader, use --optimize-autoloader or --classmap-authoritative if you want a strict return value.'); } @@ -283,7 +285,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->setDumpAutoloader(!$input->getOption('no-autoloader')) ->setOptimizeAutoloader($optimize) ->setClassMapAuthoritative($authoritative) - ->setStrictPsrAutoloader($input->getOption('strict-psr-autoloader')) + ->setStrictPsrAutoloader($strictPsrAutoloader) ->setApcuAutoloader($apcu, $apcuPrefix) ->setUpdate(true) ->setInstall(!$input->getOption('no-install')) diff --git a/core/vendor/composer/composer/src/Composer/Composer.php b/core/vendor/composer/composer/src/Composer/Composer.php index ad77805b22..b55430b119 100644 --- a/core/vendor/composer/composer/src/Composer/Composer.php +++ b/core/vendor/composer/composer/src/Composer/Composer.php @@ -51,9 +51,9 @@ class Composer extends PartialComposer * * @see getVersion() */ - public const VERSION = '2.10.3'; + public const VERSION = '2.11.0'; public const BRANCH_ALIAS_VERSION = ''; - public const RELEASE_DATE = '2026-08-27 13:34:23'; + public const RELEASE_DATE = '2026-10-29 10:23:20'; public const SOURCE_VERSION = ''; /** diff --git a/core/vendor/composer/composer/src/Composer/Config.php b/core/vendor/composer/composer/src/Composer/Config.php index ffbb618ba3..df4138f6da 100644 --- a/core/vendor/composer/composer/src/Composer/Config.php +++ b/core/vendor/composer/composer/src/Composer/Config.php @@ -62,6 +62,7 @@ class Config 'autoloader-suffix' => null, 'sort-packages' => false, 'optimize-autoloader' => false, + 'strict-psr-autoloader' => false, 'classmap-authoritative' => false, 'apcu-autoloader' => false, 'prepend-autoloader' => true, @@ -201,7 +202,7 @@ public function merge(array $config, string $source = self::SOURCE_UNKNOWN): voi // override defaults with given config if (!empty($config['config']) && is_array($config['config'])) { foreach ($config['config'] as $key => $val) { - if (in_array($key, ['bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer', 'client-certificate', 'forgejo-token'], true) && isset($this->config[$key])) { + if (in_array($key, ['bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer', 'custom-headers', 'client-certificate', 'forgejo-token'], true) && isset($this->config[$key])) { $this->config[$key] = array_merge($this->config[$key], $val); $this->setSourceOfConfigValue($val, $key, $source); } elseif (in_array($key, ['allow-plugins'], true) && isset($this->config[$key]) && is_array($this->config[$key]) && is_array($val)) { @@ -417,6 +418,7 @@ public function get(string $key, int $flags = 0) case 'use-github-api': case 'lock': case 'source-fallback': + case 'strict-psr-autoloader': // special case for secure-http if ($key === 'secure-http' && $this->get('disable-tls') === true) { return false; @@ -691,14 +693,21 @@ private function disableRepoByName(string $name): void */ public function prohibitUrlByConfig(string $url, ?IOInterface $io = null, array $repoOptions = []): void { - // Return right away if the URL is malformed or custom (see issue #5173), but only for non-HTTP(S) URLs - if (false === filter_var($url, FILTER_VALIDATE_URL) && !Preg::isMatch('{^https?://}i', $url)) { + // git's ::
syntax connects to the address, so check that + $checkedUrl = $url; + while (Preg::isMatchStrictGroups('{^[a-z][a-z0-9+.-]*::(.+)$}i', $checkedUrl, $match)) { + $checkedUrl = $match[1]; + } + + // Return right away for custom URLs without a scheme like local paths (see issue #5173), but do not rely + // on filter_var for this as it rejects many malformed URLs which tools would still happily connect to + if (!Preg::isMatchStrictGroups('{^([a-z][a-z0-9+.-]*)://}i', $checkedUrl, $match)) { return; } - // Extract scheme and throw exception on known insecure protocols - $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); - $hostname = parse_url($url, PHP_URL_HOST); + // Throw exception on known insecure protocols + $scheme = strtolower($match[1]); + $hostname = parse_url($checkedUrl, PHP_URL_HOST); if (in_array($scheme, ['http', 'git', 'ftp', 'svn'])) { if ($this->get('secure-http')) { if ($scheme === 'svn') { diff --git a/core/vendor/composer/composer/src/Composer/Config/JsonConfigSource.php b/core/vendor/composer/composer/src/Composer/Config/JsonConfigSource.php index 7773ac8dec..7d8df51ee7 100644 --- a/core/vendor/composer/composer/src/Composer/Config/JsonConfigSource.php +++ b/core/vendor/composer/composer/src/Composer/Config/JsonConfigSource.php @@ -316,12 +316,12 @@ public function removeConfigSetting(string $name): void public function addProperty(string $name, $value): void { $this->manipulateJson('addProperty', static function (&$config, $key, $val): void { - if (strpos($key, 'extra.') === 0 || strpos($key, 'scripts.') === 0) { - $bits = explode('.', $key); + if (Preg::isMatch('{^(suggest|extra|scripts)\.}', $key)) { + $bits = self::splitPropertyKey($key); $last = array_pop($bits); - $arr = &$config[reset($bits)]; + $arr = &$config; foreach ($bits as $bit) { - if (!isset($arr[$bit])) { + if (!isset($arr[$bit]) || !is_array($arr[$bit])) { $arr[$bit] = []; } $arr = &$arr[$bit]; @@ -339,17 +339,24 @@ public function addProperty(string $name, $value): void public function removeProperty(string $name): void { $this->manipulateJson('removeProperty', static function (&$config, $key): void { - if (strpos($key, 'extra.') === 0 || strpos($key, 'scripts.') === 0 || stripos($key, 'autoload.') === 0 || stripos($key, 'autoload-dev.') === 0) { - $bits = explode('.', $key); + if (Preg::isMatch('{^(suggest|extra|scripts|autoload|autoload-dev)\.}', $key)) { + $bits = self::splitPropertyKey($key); $last = array_pop($bits); - $arr = &$config[reset($bits)]; + $arr = &$config; foreach ($bits as $bit) { - if (!isset($arr[$bit])) { + if (!isset($arr[$bit]) || !is_array($arr[$bit])) { return; } $arr = &$arr[$bit]; } + $wasList = array_is_list($arr); unset($arr[$last]); + // keep lists as lists and emptied objects as objects, as JsonManipulator does + if ($wasList) { + $arr = array_values($arr); + } elseif ($arr === []) { + $arr = new \stdClass; + } } else { unset($config[$key]); } @@ -395,7 +402,7 @@ private function manipulateJson(string $method, callable $fallback, ...$args): v throw new \RuntimeException(sprintf('The file "%s" is not readable.', $this->file->getPath())); } - $contents = file_get_contents($this->file->getPath()); + $contents = (string) file_get_contents($this->file->getPath()); } elseif ($this->authConfig) { $contents = "{\n}\n"; } else { @@ -419,7 +426,7 @@ private function manipulateJson(string $method, callable $fallback, ...$args): v // try to update cleanly if (call_user_func_array([$manipulator, $method], $args)) { - file_put_contents($this->file->getPath(), $manipulator->getContents()); + Filesystem::safeFilePutContents($this->file->getPath(), $manipulator->getContents()); } else { // on failed clean update, call the fallback and rewrite the whole file $config = $this->file->read(); @@ -462,7 +469,7 @@ private function manipulateJson(string $method, callable $fallback, ...$args): v $this->file->validateSchema(JsonFile::LAX_SCHEMA); } catch (JsonValidationException $e) { // restore contents to the original state - file_put_contents($this->file->getPath(), $contents); + Filesystem::safeFilePutContents($this->file->getPath(), $contents); throw new \RuntimeException('Failed to update composer.json with a valid format, reverting to the original content. Please report an issue to us with details (command you run and a copy of your composer.json). '.PHP_EOL.implode(PHP_EOL, $e->getErrors()), 0, $e); } @@ -471,6 +478,21 @@ private function manipulateJson(string $method, callable $fallback, ...$args): v } } + /** + * Mirrors JsonManipulator's key handling: extra/scripts nest at most two levels deep, the rest stays a literal dotted key + * + * @return non-empty-list + */ + private static function splitPropertyKey(string $key): array + { + [$mainNode, $name] = explode('.', $key, 2); + if (in_array($mainNode, ['extra', 'scripts'], true)) { + return array_merge([$mainNode], explode('.', $name, 2)); + } + + return [$mainNode, $name]; + } + /** * Prepend a reference to an element to the beginning of an array. * diff --git a/core/vendor/composer/composer/src/Composer/Console/AbstractOscProgramStatusEncoder.php b/core/vendor/composer/composer/src/Composer/Console/AbstractOscProgramStatusEncoder.php new file mode 100644 index 0000000000..db5ccf235a --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Console/AbstractOscProgramStatusEncoder.php @@ -0,0 +1,46 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Console; + +use Composer\IO\ConsoleIO; +use Composer\Pcre\Preg; +use Symfony\Component\Console\Formatter\OutputFormatter; + +/** + * @internal + */ +abstract class AbstractOscProgramStatusEncoder +{ + abstract public function getCommand(): string; + + abstract public function encode(ProgramStatus $status): string; + + public function encodeStart(): ?string + { + return $this->encode(ProgramStatus::working()); + } + + protected function sanitizeMessage(string $message, ?int $maxBytes = null): string + { + $message = Preg::replace('{[\x00-\x1f\x7f-\x9f]}u', '', ConsoleIO::sanitize($message, false)); + $message = (new OutputFormatter(false))->format($message) ?? ''; + + // Keep the UTF-8 message within the protocol's decoded byte limit. + if ($maxBytes !== null && strlen($message) > $maxBytes) { + $message = substr($message, 0, $maxBytes); + $message = Preg::replace('{[\xc2-\xdf]$|[\xe0-\xef][\x80-\xbf]{0,1}$|[\xf0-\xf4][\x80-\xbf]{0,2}$}', '', $message); + } + + return trim($message); + } +} diff --git a/core/vendor/composer/composer/src/Composer/Console/Application.php b/core/vendor/composer/composer/src/Composer/Console/Application.php index af82671f74..9c16e685c3 100644 --- a/core/vendor/composer/composer/src/Composer/Console/Application.php +++ b/core/vendor/composer/composer/src/Composer/Console/Application.php @@ -31,6 +31,7 @@ use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Seld\JsonLint\ParsingException; +use Seld\Signal\SignalHandler; use Composer\Command; use Composer\Composer; use Composer\Factory; @@ -142,6 +143,56 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu } public function doRun(InputInterface $input, OutputInterface $output): int + { + $io = $this->io = new ConsoleIO($input, $output, new HelperSet([ + new QuestionHelper(), + ])); + $reportStatus = !Platform::isInputCompletionProcess() && $io->isProgramStatusEnabled(); + $originalTerminalStatus = Platform::getEnv('COMPOSER_TERMINAL_STATUS'); + if ($reportStatus) { + $io->startProgramStatus(); + } + + $signalHandler = $reportStatus ? SignalHandler::create( + [SignalHandler::SIGINT, SignalHandler::SIGTERM, SignalHandler::SIGHUP], + static function (string $signal, SignalHandler $handler) use ($io): void { + $io->writeProgramStatus(ProgramStatus::idle()); + $handler->exitWithLastSignal(); + } + ) : null; + + try { + if ($reportStatus) { + // This IO keeps its encoders while nested Composer processes inherit disabled reporting. + Platform::putEnv('COMPOSER_TERMINAL_STATUS', 'off'); + } + $result = $this->doRunComposer($input, $output, $io); + if ($reportStatus) { + $io->writeProgramStatus(ProgramStatus::fromExitCode($result)); + } + + return $result; + } catch (\Throwable $e) { + if ($reportStatus) { + $io->writeProgramStatus(ProgramStatus::error()); + } + + throw $e; + } finally { + if ($reportStatus) { + if ($originalTerminalStatus === false) { + Platform::clearEnv('COMPOSER_TERMINAL_STATUS'); + } else { + Platform::putEnv('COMPOSER_TERMINAL_STATUS', $originalTerminalStatus); + } + } + if ($signalHandler !== null) { + $signalHandler->unregister(); + } + } + } + + private function doRunComposer(InputInterface $input, OutputInterface $output, ConsoleIO $io): int { $this->disablePluginsByDefault = $input->hasParameterOption('--no-plugins'); $this->disableScriptsByDefault = $input->hasParameterOption('--no-scripts'); @@ -154,10 +205,6 @@ public function doRun(InputInterface $input, OutputInterface $output): int $input->setInteractive(false); } - $io = $this->io = new ConsoleIO($input, $output, new HelperSet([ - new QuestionHelper(), - ])); - // Register error handler again to pass it the IO instance ErrorHandler::register($io); @@ -451,7 +498,7 @@ function_exists('php_uname') ? php_uname('s') . ' / ' . php_uname('r') : 'Unknow try { if ($input->hasParameterOption('--profile')) { $startTime = microtime(true); - $this->io->enableDebugging($startTime); + $io->enableDebugging($startTime); } $result = parent::doRun($input, $output); @@ -569,12 +616,12 @@ private function hintCommonErrors(\Throwable $exception, OutputInterface $output if (Platform::isWindows() && false !== strpos($exception->getMessage(), 'The system cannot find the path specified')) { $io->writeError('The following exception may be caused by a stale entry in your cmd.exe AutoRun', true, IOInterface::QUIET); - $io->writeError('Check https://getcomposer.org/doc/articles/troubleshooting.md#-the-system-cannot-find-the-path-specified-windows- for details', true, IOInterface::QUIET); + $io->writeError('Check https://getcomposer.org/doc/articles/troubleshooting.md#the-system-cannot-find-the-path-specified-windows for details', true, IOInterface::QUIET); } if (false !== strpos($exception->getMessage(), 'fork failed - Cannot allocate memory')) { $io->writeError('The following exception is caused by a lack of memory or swap, or not having swap configured', true, IOInterface::QUIET); - $io->writeError('Check https://getcomposer.org/doc/articles/troubleshooting.md#proc-open-fork-failed-errors for details', true, IOInterface::QUIET); + $io->writeError('Check https://getcomposer.org/doc/articles/troubleshooting.md#proc_open-fork-failed-errors for details', true, IOInterface::QUIET); } if ($exception instanceof ProcessTimedOutException) { @@ -631,6 +678,9 @@ public function getComposer(bool $required = true, ?bool $disablePlugins = null, if ($required) { $this->io->writeError($e->getMessage()); if ($this->areExceptionsCaught()) { + if ($this->io instanceof ConsoleIO && !Platform::isInputCompletionProcess()) { + $this->io->writeProgramStatus(ProgramStatus::error()); + } exit(1); } throw $e; diff --git a/core/vendor/composer/composer/src/Composer/Console/Osc7501ProgramStatusEncoder.php b/core/vendor/composer/composer/src/Composer/Console/Osc7501ProgramStatusEncoder.php new file mode 100644 index 0000000000..ecee5145f5 --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Console/Osc7501ProgramStatusEncoder.php @@ -0,0 +1,65 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Console; + +/** + * Encodes a root record for the OSC 7501 Program Status Protocol. + * + * @see https://www.superlogical.com/rex/docs/build/program-status + * + * @internal + */ +final class Osc7501ProgramStatusEncoder extends AbstractOscProgramStatusEncoder +{ + public const PROTOCOL = 'osc7501'; + public const KIND_PERMISSION = 'permission'; + public const KIND_QUESTION = 'question'; + public const KIND_AUTH = 'auth'; + + private const VALID_KINDS = [self::KIND_PERMISSION, self::KIND_QUESTION, self::KIND_AUTH]; + + public function getCommand(): string + { + return '7501'; + } + + public function encode(ProgramStatus $status): string + { + $kind = $this->getKind($status); + $payload = 'state='.$status->getState().':app=composer'; + if ($status->getProgress() !== null) { + $payload .= ':progress='.$status->getProgress(); + } + if ($kind !== null) { + $payload .= ':kind='.$kind; + } + if ($status->getMessage() !== null) { + $payload .= ':msg='.base64_encode($this->sanitizeMessage($status->getMessage(), 2048)); + } + + return $payload; + } + + private function getKind(ProgramStatus $status): ?string + { + $kind = $status->getProtocolOption(self::PROTOCOL, 'kind'); + if ($kind === null) { + return null; + } + if ($status->getState() !== ProgramStatus::STATE_BLOCKED || !is_string($kind) || !in_array($kind, self::VALID_KINDS, true)) { + return null; + } + + return $kind; + } +} diff --git a/core/vendor/composer/composer/src/Composer/Console/Osc9ProgressEncoder.php b/core/vendor/composer/composer/src/Composer/Console/Osc9ProgressEncoder.php new file mode 100644 index 0000000000..d18dc5a0b0 --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Console/Osc9ProgressEncoder.php @@ -0,0 +1,50 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Console; + +/** + * Encodes ConEmu-compatible OSC 9 progress reports. + * + * @see https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC + * + * @internal + */ +final class Osc9ProgressEncoder extends AbstractOscProgramStatusEncoder +{ + public function getCommand(): string + { + return '9;4'; + } + + public function encodeStart(): ?string + { + // Progress and prompts activate the indicator instead of every command starting a spinner. + return null; + } + + public function encode(ProgramStatus $status): string + { + $progress = $status->getProgress(); + switch ($status->getState()) { + case ProgramStatus::STATE_WORKING: + return $progress === null ? '3' : '1;'.$progress; + case ProgramStatus::STATE_BLOCKED: + return '4'.($progress === null ? '' : ';'.$progress); + case ProgramStatus::STATE_ERROR: + return '2'; + default: + // Completion and cancellation remove the progress indicator. + return '0'; + } + } +} diff --git a/core/vendor/composer/composer/src/Composer/Console/OscHelper.php b/core/vendor/composer/composer/src/Composer/Console/OscHelper.php new file mode 100644 index 0000000000..e670e0caff --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Console/OscHelper.php @@ -0,0 +1,70 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Console; + +use Composer\Pcre\Preg; +use Symfony\Component\Console\Helper\Helper; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Output\StreamOutput; + +/** + * Writes Operating System Commands without formatting or adding a newline. + * + * @internal + */ +final class OscHelper extends Helper +{ + public function getName(): string + { + return 'osc'; + } + + public function write(OutputInterface $output, string $command, string $payload): void + { + if (Preg::isMatch('{[\x00-\x1f\x7f-\x9f]}u', $command) || Preg::isMatch('{[\x00-\x1f\x7f-\x9f]}u', $payload)) { + throw new \InvalidArgumentException('OSC commands and payloads must not contain control characters.'); + } + + if (!$this->supportsOutput($output)) { + return; + } + + $output->write("\033]".$command.';'.$payload."\033\\", false, OutputInterface::OUTPUT_RAW); + } + + public function supportsOutput(OutputInterface $output): bool + { + if (!$output->isDecorated() || $output->getVerbosity() <= OutputInterface::VERBOSITY_QUIET) { + return false; + } + + // Forced ANSI decoration does not make a redirected stream a terminal. + return !$output instanceof StreamOutput || $this->isTerminal($output->getStream()); + } + + /** @param resource $stream */ + private function isTerminal($stream): bool + { + // Platform's MSYS shortcut cannot establish whether this stream is redirected. + if (function_exists('stream_isatty')) { + return stream_isatty($stream); + } + if (function_exists('posix_isatty')) { + return posix_isatty($stream); + } + + $stat = @fstat($stream); + + return $stat !== false && ($stat['mode'] & 0170000) === 0020000; + } +} diff --git a/core/vendor/composer/composer/src/Composer/Console/ProgramStatus.php b/core/vendor/composer/composer/src/Composer/Console/ProgramStatus.php new file mode 100644 index 0000000000..c97f9f3a0a --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Console/ProgramStatus.php @@ -0,0 +1,139 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Console; + +/** + * The current program state, independent of its terminal representation. + * + * @experimental This API may change in a future minor release + */ +final class ProgramStatus +{ + public const STATE_IDLE = 'idle'; + public const STATE_WORKING = 'working'; + public const STATE_DONE = 'done'; + public const STATE_BLOCKED = 'blocked'; + public const STATE_ERROR = 'error'; + public const STATE_CLEAR = 'clear'; + + private const VALID_STATES = [ + self::STATE_IDLE, + self::STATE_WORKING, + self::STATE_DONE, + self::STATE_BLOCKED, + self::STATE_ERROR, + self::STATE_CLEAR, + ]; + + /** @var string */ + private $state; + /** @var string|null */ + private $message; + /** @var array> */ + private $protocolOptions = []; + + /** @var int|null */ + private $progress; + + public function __construct(string $state, ?string $message = null) + { + if (!in_array($state, self::VALID_STATES, true)) { + throw new \InvalidArgumentException('Unknown program status state: '.$state); + } + + $this->state = $state; + $this->message = $message; + } + + public static function idle(?string $message = null): self + { + return new self(self::STATE_IDLE, $message); + } + + public static function working(?string $message = null): self + { + return new self(self::STATE_WORKING, $message); + } + + public static function done(?string $message = null): self + { + return new self(self::STATE_DONE, $message); + } + + public static function blocked(?string $message = null): self + { + return new self(self::STATE_BLOCKED, $message); + } + + public static function error(?string $message = null): self + { + return new self(self::STATE_ERROR, $message); + } + + public static function clear(?string $message = null): self + { + return new self(self::STATE_CLEAR, $message); + } + + public static function fromExitCode(int $exitCode): self + { + // Shell exit codes for SIGHUP, SIGINT, and SIGTERM represent cancellation. + if (in_array($exitCode, [129, 130, 143], true)) { + return self::idle(); + } + + return $exitCode === 0 ? self::done() : self::error(); + } + + public function withProgress(?int $progress): self + { + if ($progress !== null && ($progress < 0 || $progress > 100 || !in_array($this->state, [self::STATE_WORKING, self::STATE_BLOCKED], true))) { + $progress = null; + } + + $status = clone $this; + $status->progress = $progress; + + return $status; + } + + /** @param mixed $value */ + public function withProtocolOption(string $protocol, string $name, $value): self + { + $status = clone $this; + $status->protocolOptions[$protocol][$name] = $value; + + return $status; + } + + /** @return mixed */ + public function getProtocolOption(string $protocol, string $name) + { + return $this->protocolOptions[$protocol][$name] ?? null; + } + + public function getState(): string + { + return $this->state; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function getProgress(): ?int + { + return $this->progress; + } +} diff --git a/core/vendor/composer/composer/src/Composer/Console/TerminalStatusPolicy.php b/core/vendor/composer/composer/src/Composer/Console/TerminalStatusPolicy.php new file mode 100644 index 0000000000..ad5c120e56 --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Console/TerminalStatusPolicy.php @@ -0,0 +1,105 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Console; + +use Composer\Pcre\Preg; +use Composer\Util\Platform; + +/** + * Selects terminal status protocols without reading terminal input. + * + * @internal + */ +final class TerminalStatusPolicy +{ + /** @var list */ + private $encoders; + + /** @param array $environment */ + public function __construct(array $environment) + { + $protocols = $this->selectProtocols($environment); + $this->encoders = []; + // Send rich status last so a mapped progress report cannot overwrite it. + if (in_array('9;4', $protocols, true)) { + $this->encoders[] = new Osc9ProgressEncoder(); + } + if (in_array('7501', $protocols, true)) { + $this->encoders[] = new Osc7501ProgramStatusEncoder(); + } + } + + public static function fromEnvironment(): self + { + $environment = []; + foreach (['COMPOSER_TERMINAL_STATUS', 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'WT_SESSION', 'ConEmuANSI', 'TMUX', 'STY', 'SSH_TTY', 'SSH_CONNECTION'] as $name) { + $value = Platform::getEnv($name); + if ($value !== false) { + $environment[$name] = $value; + } + } + + return new self($environment); + } + + /** @return list */ + public function getEncoders(): array + { + return $this->encoders; + } + + /** + * @param array $environment + * @return list + */ + private function selectProtocols(array $environment): array + { + $setting = trim($environment['COMPOSER_TERMINAL_STATUS'] ?? 'auto'); + if ($setting === '' || $setting === 'auto') { + return $this->supportsProgress($environment) ? ['7501', '9;4'] : ['7501']; + } + if ($setting === 'off') { + return []; + } + + // Unknown values are ignored rather than failing, so e.g. "0" or "false" disable reporting. + $protocols = array_intersect(array_map('trim', explode(',', $setting)), ['7501', '9;4']); + + return array_values(array_unique($protocols)); + } + + /** @param array $environment */ + private function supportsProgress(array $environment): bool + { + // Outer terminal identity does not establish support through another terminal. + foreach (['TMUX', 'STY', 'SSH_TTY', 'SSH_CONNECTION'] as $name) { + if (($environment[$name] ?? '') !== '') { + return false; + } + } + if (Preg::isMatch('{^(?:screen|tmux)(?:[-.]|$)}', $environment['TERM'] ?? '')) { + return false; + } + if (($environment['WT_SESSION'] ?? '') !== '' || ($environment['ConEmuANSI'] ?? '') === 'ON') { + return true; + } + + $minimumVersions = ['ghostty' => '1.2.0', 'iTerm.app' => '3.6.6']; + $program = $environment['TERM_PROGRAM'] ?? ''; + $version = $environment['TERM_PROGRAM_VERSION'] ?? ''; + + return isset($minimumVersions[$program]) + && Preg::isMatch('{^\d+\.\d+\.\d+$}', $version) + && version_compare($version, $minimumVersions[$program], '>='); + } +} diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/CooldownPoolFilter.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/CooldownPoolFilter.php new file mode 100644 index 0000000000..072b99e365 --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/CooldownPoolFilter.php @@ -0,0 +1,153 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\DependencyResolver; + +use Composer\Policy\CooldownPolicyConfig; +use Composer\Package\AliasPackage; +use Composer\Package\PackageInterface; +use Composer\Package\RootPackageInterface; +use Composer\Repository\PlatformRepository; +use DateTimeImmutable; +use DateTimeInterface; + +/** + * Withholds package versions published more recently than the configured cooldown period. + * + * Unlike the list-based policies (advisories/malware/custom lists) this filter is purely + * time-based and has no remote sources to fetch, so it is wired as a dedicated pool filter + * rather than through FilterListPoolFilter. + * + * @internal + */ +class CooldownPoolFilter +{ + /** @var CooldownPolicyConfig */ + private $config; + + /** @var DateTimeImmutable */ + private $now; + + /** @var array */ + private $effectiveDateCache = []; + + /** @var array> Repository name => "name (version)" of versions judged without a repository-provided publication date */ + private $unverifiedVersions = []; + + public function __construct(CooldownPolicyConfig $config, ?DateTimeImmutable $now = null) + { + $this->config = $config; + $this->now = $now ?? new DateTimeImmutable(); + } + + /** + * Filter packages from the pool that have not yet cleared the cooldown + */ + public function filter(Pool $pool, Request $request): Pool + { + if (!$this->config->hasCooldown() || !$this->config->block) { + return $pool; + } + + $this->effectiveDateCache = []; + $this->unverifiedVersions = []; + + $packages = []; + $policyRemovedVersions = $pool->getAllPolicyRemovedVersions(); + + foreach ($pool->getPackages() as $package) { + // An alias shares the release date of the package it aliases but reports its own + // stability, so decide on the aliased package to keep the pair consistent + $target = $package instanceof AliasPackage ? $package->getAliasOf() : $package; + + // Skip filtering for packages that should always be allowed through: + // 1. Root packages + // 2. Platform packages (php, ext-*, lib-*, etc.) + // 3. Locked packages kept by a partial update + // 4. Dev versions (mutable, no stable release date concept) + // 5. Ignored packages (matching configured policy.cooldown.ignore rules) + if ($target instanceof RootPackageInterface + || PlatformRepository::isPlatformPackage($target->getName()) + || $request->isLockedPackage($package) + || $request->isLockedPackage($target) + || $target->isDev() + || $this->config->isIgnored($target, 'block') + ) { + $packages[] = $package; + continue; + } + + // A Composer repository has to vouch for the publication date, see the policy config + $this->config->assertPublishedTimeProvided($target); + + // Nothing vouches for the date of this version, keep track so the user learns the protection is weaker + if ($target->getPublishedDate() === null && !$package instanceof AliasPackage && $target->getRepository() !== null) { + $this->unverifiedVersions[$target->getRepository()->getRepoName()][] = $package->getPrettyName().' ('.$package->getPrettyVersion().')'; + } + + // 6. Packages without release date (conservative - don't block unverifiable) + if ($this->effectiveDate($target) === null) { + $packages[] = $package; + continue; + } + + // Check if package is old enough + $releaseDate = $this->effectiveDate($target); + if (!$this->config->isWithinCooldown($releaseDate, $this->now)) { + $packages[] = $package; + continue; + } + + // Package is too new - filter it out and track for error messages + Pool::recordPolicyRemoval($policyRemovedVersions, $package, PolicyRemovalReason::cooldown( + $package->getName(), + $package->getPrettyVersion(), + $releaseDate->format(DateTimeInterface::ATOM), + $this->config->formatTimeUntilAvailable($releaseDate, $this->now), + $target->getPublishedDate() !== null ? 'published-time' : 'time' + )); + } + + return new Pool( + $packages, + $pool->getUnacceptableFixedOrLockedPackages(), + $pool->getAllRemovedVersions(), + $pool->getAllRemovedVersionsByPackage(), + $policyRemovedVersions + ); + } + + /** + * Versions the last filter() run had to judge by the package-supplied time field, or by no date at + * all, grouped by the name of the repository they came from + * + * @return array> + */ + public function getUnverifiedVersions(): array + { + return $this->unverifiedVersions; + } + + /** + * Resolve a package's effective date once per filter() run and reuse it across the + * skip and cooldown checks. + */ + private function effectiveDate(PackageInterface $package): ?DateTimeInterface + { + $key = spl_object_id($package); + if (!array_key_exists($key, $this->effectiveDateCache)) { + $this->effectiveDateCache[$key] = $this->config->getEffectiveDate($package); + } + + return $this->effectiveDateCache[$key]; + } +} diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/FilterListPoolFilter.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/FilterListPoolFilter.php index 6c55e5f623..c677b28f57 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/FilterListPoolFilter.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/FilterListPoolFilter.php @@ -105,7 +105,7 @@ public function filter(Pool $pool, Request $request): Pool $lockedNameVersionMap = $checkLockedAgainstInstall ? $this->buildLockedNameVersionMap($request) : []; $packages = []; - $filterListRemovedVersions = []; + $policyRemovedVersions = $pool->getAllPolicyRemovedVersions(); foreach ($pool->getPackages() as $package) { if (!self::isFilterable($package)) { $packages[] = $package; @@ -129,9 +129,7 @@ public function filter(Pool $pool, Request $request): Pool } if (count($matchingEntries) > 0) { - foreach ($package->getNames(false) as $packageName) { - $filterListRemovedVersions[$packageName][$package->getVersion()] = $matchingEntries; - } + Pool::recordPolicyRemoval($policyRemovedVersions, $package, PolicyRemovalReason::filterList($package->getName(), $package->getPrettyVersion(), $matchingEntries)); continue; } @@ -139,7 +137,7 @@ public function filter(Pool $pool, Request $request): Pool $packages[] = $package; } - return new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), $pool->getAllRemovedVersions(), $pool->getAllRemovedVersionsByPackage(), $pool->getAllSecurityRemovedPackageVersions(), $pool->getAllAbandonedRemovedPackageVersions(), $filterListRemovedVersions); + return new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), $pool->getAllRemovedVersions(), $pool->getAllRemovedVersionsByPackage(), $policyRemovedVersions); } /** diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/LockTransaction.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/LockTransaction.php index c872506599..755c48d3ff 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/LockTransaction.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/LockTransaction.php @@ -14,7 +14,6 @@ use Composer\Package\AliasPackage; use Composer\Package\BasePackage; -use Composer\Package\Loader\ValidatingArrayLoader; use Composer\Package\Package; use Composer\Pcre\Preg; @@ -71,8 +70,6 @@ public function setResultPackages(Pool $pool, Decisions $decisions): void if ($literal > 0) { $package = $pool->literalToPackage($literal); - ValidatingArrayLoader::validatePackage($package); - $this->resultPackages['all'][] = $package; if (!isset($this->unlockableMap[$package->id])) { $this->resultPackages['non-dev'][] = $package; diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/PolicyRemovalReason.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/PolicyRemovalReason.php new file mode 100644 index 0000000000..7d6f490935 --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/PolicyRemovalReason.php @@ -0,0 +1,300 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\DependencyResolver; + +use Composer\Advisory\PartialSecurityAdvisory; +use Composer\Advisory\SecurityAdvisory; +use Composer\FilterList\FilterListEntry; +use Symfony\Component\Console\Formatter\OutputFormatter; + +/** + * Why a dependency policy removed a package version from the pool, and how to render that to the user + * + * @internal + * @final + */ +class PolicyRemovalReason +{ + public const ABANDONED = 'abandoned'; + public const ADVISORIES = 'advisories'; + public const FILTER_LIST = 'filter-list'; + public const COOLDOWN = 'cooldown'; + + public const COOLDOWN_REMEDY = 'To exempt the package from the cooldown policy, add it to the "policy.cooldown.ignore" config, or run the command with --no-cooldown for a one-off bypass.'; + + /** @var self::* */ + private $type; + /** @var string */ + private $packageName; + /** @var string */ + private $prettyVersion; + /** @var list */ + private $advisories = []; + /** @var list */ + private $filterListEntries = []; + /** @var array{releaseDate: string, availableIn: string, source: string}|null */ + private $cooldown = null; + + /** + * @param self::* $type + */ + private function __construct(string $type, string $packageName, string $prettyVersion) + { + $this->type = $type; + $this->packageName = $packageName; + $this->prettyVersion = $prettyVersion; + } + + public static function abandoned(string $packageName, string $prettyVersion): self + { + return new self(self::ABANDONED, $packageName, $prettyVersion); + } + + /** + * @param array $advisories + */ + public static function advisories(string $packageName, string $prettyVersion, array $advisories): self + { + $reason = new self(self::ADVISORIES, $packageName, $prettyVersion); + $reason->advisories = array_values($advisories); + + return $reason; + } + + /** + * @param array $entries + */ + public static function filterList(string $packageName, string $prettyVersion, array $entries): self + { + $reason = new self(self::FILTER_LIST, $packageName, $prettyVersion); + $reason->filterListEntries = array_values($entries); + + return $reason; + } + + /** + * @param string $source 'published-time' when the repository vouched for the date, 'time' when it came from the package + */ + public static function cooldown(string $packageName, string $prettyVersion, string $releaseDate, string $availableIn, string $source): self + { + $reason = new self(self::COOLDOWN, $packageName, $prettyVersion); + $reason->cooldown = ['releaseDate' => $releaseDate, 'availableIn' => $availableIn, 'source' => $source]; + + return $reason; + } + + /** + * Merges the reasons of several versions removed by the same policy, so one sentence can explain them all + * + * @param non-empty-array $reasons + */ + public static function combine(array $reasons): self + { + $first = reset($reasons); + $combined = new self($first->type, $first->packageName, $first->prettyVersion); + $seen = []; + foreach ($reasons as $reason) { + if ($reason->type !== $first->type) { + throw new \LogicException('Cannot combine reasons of different policies: '.$first->type.' and '.$reason->type); + } + + foreach ($reason->advisories as $advisory) { + if (!isset($seen[$advisory->advisoryId])) { + $seen[$advisory->advisoryId] = true; + $combined->advisories[] = $advisory; + } + } + + foreach ($reason->filterListEntries as $entry) { + if (!isset($seen[spl_object_id($entry)])) { + $seen[spl_object_id($entry)] = true; + $combined->filterListEntries[] = $entry; + } + } + + // point at the version that becomes available soonest + if ($reason->cooldown !== null && ($combined->cooldown === null || new \DateTimeImmutable($reason->cooldown['releaseDate']) < new \DateTimeImmutable($combined->cooldown['releaseDate']))) { + $combined->cooldown = $reason->cooldown; + $combined->prettyVersion = $reason->prettyVersion; + } + } + + return $combined; + } + + /** + * @return self::* + */ + public function getType(): string + { + return $this->type; + } + + public function getPackageName(): string + { + return $this->packageName; + } + + public function getPrettyVersion(): string + { + return $this->prettyVersion; + } + + /** + * @return list + */ + public function getAdvisories(): array + { + return $this->advisories; + } + + /** + * @return array{releaseDate: string, availableIn: string, source: string}|null + */ + public function getCooldownInfo(): ?array + { + return $this->cooldown; + } + + /** + * The verb that makes the description a sentence, e.g. "they are" + "abandoned" + */ + public function getVerb(bool $plural): string + { + if ($this->type === self::FILTER_LIST) { + return $plural ? 'were' : 'was'; + } + + return $plural ? 'are' : 'is'; + } + + public function getDescription(): string + { + switch ($this->type) { + case self::ABANDONED: + return 'abandoned'; + case self::ADVISORIES: + return 'affected by security advisories ("' . implode('", "', $this->getLinkedAdvisoryIdentifiers()) . '")'; + case self::FILTER_LIST: + return implode(', ', $this->getFilterListDescriptions()); + case self::COOLDOWN: + $availableIn = ''; + if ($this->cooldown !== null) { + // When the policy had to rely on the author-controlled `time` field, say so, as the protection is weaker + $source = $this->cooldown['source'] === 'time' ? ', based on the package-supplied time field as the repository provides no published-time' : ''; + $availableIn = ' (available in ' . $this->cooldown['availableIn'] . $source . ')'; + } + + return 'still in the cooldown period configured in "policy.cooldown"' . $availableIn; + } + + // @phpstan-ignore deadCode.unreachable + throw new \LogicException('Unknown policy: '.$this->type); + } + + /** + * How to let the package through while keeping the policy on, or '' if there is no such config + */ + public function getRemedy(): string + { + switch ($this->type) { + case self::ABANDONED: + return ''; + case self::ADVISORIES: + return $this->getAdvisoryDetailsHint() . ' To ignore the advisories, add their IDs to the "policy.advisories.ignore-id" config or add the package to "policy.advisories.ignore".'; + case self::FILTER_LIST: + return 'To ignore filters for this package, add the package to the ' . implode(' and ', array_map(static function (string $listName): string { + return '"policy.' . $listName . '.ignore"'; + }, $this->getFilterListNames())) . ' config.'; + case self::COOLDOWN: + return self::COOLDOWN_REMEDY; + } + + // @phpstan-ignore deadCode.unreachable + throw new \LogicException('Unknown policy: '.$this->type); + } + + /** + * The config keys that turn the policy off, e.g. '"policy.advisories.block"' + */ + public function getOffSwitch(): string + { + if ($this->type === self::FILTER_LIST) { + return implode(' and ', array_map(static function (string $listName): string { + return '"policy.' . $listName . '.block"'; + }, $this->getFilterListNames())); + } + + return '"policy.' . $this->type . '.block"'; + } + + /** + * @return string[] identifiers linked to their advisory page where one is known + */ + private function getLinkedAdvisoryIdentifiers(): array + { + return array_map(static function (PartialSecurityAdvisory $advisory): string { + if ($advisory instanceof SecurityAdvisory && $advisory->link !== null && $advisory->link !== '') { + return 'link).'>'.$advisory->advisoryId.''; + } + + if (str_starts_with($advisory->advisoryId, 'PKSA-')) { + return 'advisoryId).'>'.$advisory->advisoryId.''; + } + + return $advisory->advisoryId; + }, $this->advisories); + } + + private function getAdvisoryDetailsHint(): string + { + foreach ($this->advisories as $advisory) { + if (!str_starts_with($advisory->advisoryId, 'PKSA-')) { + return 'Review the advisory details above for more information.'; + } + } + + return 'Go to https://packagist.org/security-advisories/ to find advisory details.'; + } + + /** + * @return list + */ + private function getFilterListNames(): array + { + return array_keys($this->getFilterListDescriptions()); + } + + /** + * @return array list name => description + */ + private function getFilterListDescriptions(): array + { + $lists = []; + foreach ($this->filterListEntries as $entry) { + $source = (bool) $entry->source ? ' reported by ' . $entry->source : ''; + $url = (bool) $entry->url ? ' (see ' . $entry->url . ')' : ''; + $reason = (bool) $entry->reason ? ' reason: ' . $entry->reason : ''; + + $lists[$entry->listName][] = $source . $url . $reason; + } + + $result = []; + foreach ($lists as $listName => $listEntries) { + $action = $listName === 'malware' ? 'flagged as ' : 'filtered by '; + $result[$listName] = $action . $listName . implode(', ', $listEntries); + } + + return $result; + } +} diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/Pool.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/Pool.php index c00ab08dac..9e786fe1c0 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/Pool.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/Pool.php @@ -12,9 +12,6 @@ namespace Composer\DependencyResolver; -use Composer\Advisory\PartialSecurityAdvisory; -use Composer\Advisory\SecurityAdvisory; -use Composer\FilterList\FilterListEntry; use Composer\Package\BasePackage; use Composer\Package\Version\VersionParser; use Composer\Semver\CompilingMatcher; @@ -43,32 +40,24 @@ class Pool implements \Countable protected $removedVersions = []; /** @var array> Map of package object id => removed normalized versions => removed pretty version */ protected $removedVersionsByPackage = []; - /** @var array>> Map of package name => normalized version => security advisories */ - private $securityRemovedVersions = []; - /** @var array> Map of package name => normalized version => pretty version */ - private $abandonedRemovedVersions = []; - /** @var array>> Map of package name => normalized version => filter list entries */ - private $filterListRemovedVersions = []; + /** @var array> Map of package name => normalized version => why a policy removed it */ + private $policyRemovedVersions = []; /** * @param BasePackage[] $packages * @param BasePackage[] $unacceptableFixedOrLockedPackages * @param array> $removedVersions * @param array> $removedVersionsByPackage - * @param array>> $securityRemovedVersions - * @param array> $abandonedRemovedVersions - * @param array>> $filterListRemovedVersions + * @param array> $policyRemovedVersions */ - public function __construct(array $packages = [], array $unacceptableFixedOrLockedPackages = [], array $removedVersions = [], array $removedVersionsByPackage = [], array $securityRemovedVersions = [], array $abandonedRemovedVersions = [], array $filterListRemovedVersions = []) + public function __construct(array $packages = [], array $unacceptableFixedOrLockedPackages = [], array $removedVersions = [], array $removedVersionsByPackage = [], array $policyRemovedVersions = []) { $this->versionParser = new VersionParser; $this->setPackages($packages); $this->unacceptableFixedOrLockedPackages = $unacceptableFixedOrLockedPackages; $this->removedVersions = $removedVersions; $this->removedVersionsByPackage = $removedVersionsByPackage; - $this->securityRemovedVersions = $securityRemovedVersions; - $this->abandonedRemovedVersions = $abandonedRemovedVersions; - $this->filterListRemovedVersions = $filterListRemovedVersions; + $this->policyRemovedVersions = $policyRemovedVersions; } /** @@ -118,113 +107,53 @@ public function getAllRemovedVersionsByPackage(): array return $this->removedVersionsByPackage; } - public function isSecurityRemovedPackageVersion(string $packageName, ?ConstraintInterface $constraint): bool - { - foreach ($this->securityRemovedVersions[$packageName] ?? [] as $version => $packageWithSecurityAdvisories) { - if ($constraint !== null && $constraint->matches(new Constraint('==', $version))) { - return true; - } - } - - return false; - } - /** - * @return string[] + * Versions of a package that dependency policies removed from the pool, with the reason for each + * + * @return array normalized version => reason, in ascending version order */ - public function getSecurityAdvisoryIdentifiersForPackageVersion(string $packageName, ?ConstraintInterface $constraint): array + public function getPolicyRemovedVersions(string $packageName, ?ConstraintInterface $constraint = null): array { - foreach ($this->securityRemovedVersions[$packageName] ?? [] as $version => $packageWithSecurityAdvisories) { - if ($constraint !== null && $constraint->matches(new Constraint('==', $version))) { - return array_map(static function ($advisory) { - return $advisory->advisoryId; - }, $packageWithSecurityAdvisories); - } - } - - return []; - } - - public function isAbandonedRemovedPackageVersion(string $packageName, ?ConstraintInterface $constraint): bool - { - foreach ($this->abandonedRemovedVersions[$packageName] ?? [] as $version => $prettyVersion) { - if ($constraint !== null && $constraint->matches(new Constraint('==', $version))) { - return true; + $result = []; + foreach ($this->policyRemovedVersions[$packageName] ?? [] as $version => $reason) { + if ($constraint === null || $constraint->matches(new Constraint('==', $version))) { + $result[$version] = $reason; } } + uksort($result, static function (string $a, string $b): int { + return version_compare($a, $b); + }); - return false; + return $result; } - /** - * @return array>> - */ - public function getAllSecurityRemovedPackageVersions(): array + public function getPolicyRemovalReason(string $packageName, string $version): ?PolicyRemovalReason { - return $this->securityRemovedVersions; + return $this->policyRemovedVersions[$packageName][$version] ?? null; } /** - * @return array> + * Records a removal under every name of the package, but a replacer never hides the removal of the package itself + * + * @internal + * @param array> $policyRemovedVersions */ - public function getAllAbandonedRemovedPackageVersions(): array + public static function recordPolicyRemoval(array &$policyRemovedVersions, BasePackage $package, PolicyRemovalReason $reason): void { - return $this->abandonedRemovedVersions; - } - - public function isFilterListRemovedPackageVersion(string $packageName, ?ConstraintInterface $constraint): bool - { - foreach ($this->filterListRemovedVersions[$packageName] ?? [] as $version => $entries) { - if ($constraint !== null && $constraint->matches(new Constraint('==', $version))) { - return true; + foreach ($package->getNames(false) as $name) { + $existing = $policyRemovedVersions[$name][$package->getVersion()] ?? null; + if ($existing === null || ($existing->getPackageName() !== $name && $package->getName() === $name)) { + $policyRemovedVersions[$name][$package->getVersion()] = $reason; } } - - return false; - } - - /** - * @return array>> - */ - public function getAllFilterListRemovedPackageVersions(): array - { - return $this->filterListRemovedVersions; } /** - * @return string[] + * @return array> */ - public function getFilterListEntryForPackageVersion(string $packageName, ?ConstraintInterface $constraint): array + public function getAllPolicyRemovedVersions(): array { - $lists = []; - $seen = []; - foreach ($this->filterListRemovedVersions[$packageName] ?? [] as $version => $filterListEntries) { - if ($constraint !== null && $constraint->matches(new Constraint('==', $version))) { - foreach ($filterListEntries as $entry) { - $entryKey = spl_object_id($entry); - if (isset($seen[$entryKey])) { - continue; - } - - $seen[$entryKey] = true; - - $source = (bool) $entry->source ? ' reported by ' . $entry->source : ''; - $url = (bool) $entry->url ? ' (see ' . $entry->url . ')' : ''; - $reason = (bool) $entry->reason ? ' reason: ' . $entry->reason : ''; - - $lists[$entry->listName][] = $source . $url . $reason; - } - - } - } - - $result = []; - foreach ($lists as $listName => $listEntries) { - $action = $listName === 'malware' ? 'flagged as ' : 'filtered by '; - $result[$listName] = $action . $listName . implode(', ', $listEntries); - } - - return $result; + return $this->policyRemovedVersions; } /** diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/PoolBuilder.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/PoolBuilder.php index 55c8319c34..c98744a541 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/PoolBuilder.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/PoolBuilder.php @@ -159,6 +159,9 @@ class PoolBuilder /** @var ?FilterListPoolFilter */ private $filterListPoolFilter; + /** @var ?CooldownPoolFilter */ + private $cooldownPoolFilter; + /** * @param int[] $acceptableStabilities array of stability => BasePackage::STABILITY_* value * @phpstan-param array, BasePackage::STABILITY_*> $acceptableStabilities @@ -170,7 +173,7 @@ class PoolBuilder * @phpstan-param array $rootReferences * @param array $temporaryConstraints Runtime temporary constraints that will be used to filter packages */ - public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $temporaryConstraints = [], ?SecurityAdvisoryPoolFilter $securityAdvisoryPoolFilter = null, ?FilterListPoolFilter $filterListPoolFilter = null) + public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $temporaryConstraints = [], ?SecurityAdvisoryPoolFilter $securityAdvisoryPoolFilter = null, ?FilterListPoolFilter $filterListPoolFilter = null, ?CooldownPoolFilter $cooldownPoolFilter = null) { $this->acceptableStabilities = $acceptableStabilities; $this->stabilityFlags = $stabilityFlags; @@ -182,6 +185,7 @@ public function __construct(array $acceptableStabilities, array $stabilityFlags, $this->temporaryConstraints = $temporaryConstraints; $this->securityAdvisoryPoolFilter = $securityAdvisoryPoolFilter; $this->filterListPoolFilter = $filterListPoolFilter; + $this->cooldownPoolFilter = $cooldownPoolFilter; } /** @@ -209,6 +213,7 @@ public function setAllowedTypes(?array $types): void */ public function buildPool(array $repositories, Request $request): Pool { + $start = microtime(true); $this->restrictedPackagesList = $request->getRestrictedPackages() !== null ? array_flip($request->getRestrictedPackages()) : null; if (\count($request->getUpdateAllowList()) > 0) { @@ -220,7 +225,7 @@ public function buildPool(array $repositories, Request $request): Pool } foreach ($request->getLockedRepository()->getPackages() as $lockedPackage) { - if (!$this->isUpdateAllowed($lockedPackage)) { + if (!$request->isInUpdateAllowList($lockedPackage->getName())) { // remember which packages we skipped loading remote content for in this partial update $this->skippedLoad[$lockedPackage->getName()][] = $lockedPackage; foreach ($lockedPackage->getReplaces() as $link) { @@ -352,12 +357,13 @@ public function buildPool(array $repositories, Request $request): Pool $this->skippedLoad = []; $this->indexCounter = 0; - $this->io->debug('Built pool.'); + $this->io->debug('Built pool in '.(microtime(true) - $start).'s'); // filter vulnerable packages before optimizing the pool otherwise we may end up with inconsistent state where the optimizer took away versions // that were not vulnerable and now suddenly the vulnerable ones are removed and we are missing some versions to make it solvable $pool = $this->runSecurityAdvisoryFilter($pool, $repositories, $request); $pool = $this->runFilterListFilter($pool, $request); + $pool = $this->runCooldownFilter($pool, $request); $pool = $this->runOptimizer($request, $pool); Intervals::clear(); @@ -628,21 +634,6 @@ private function getSkippedRootRequires(Request $request, string $name): array return $matches; } - /** - * Checks whether the update allow list allows this package in the lock file to be updated - */ - private function isUpdateAllowed(BasePackage $package): bool - { - foreach ($this->updateAllowList as $pattern) { - $patternRegexp = BasePackage::packageNameToRegexp($pattern); - if (Preg::isMatch($patternRegexp, $package->getName())) { - return true; - } - } - - return false; - } - private function warnAboutNonMatchingUpdateAllowList(Request $request): void { if (null === $request->getLockedRepository()) { @@ -881,4 +872,78 @@ private function runSecurityAdvisoryFilter(Pool $pool, array $repositories, Requ return $pool; } + + private function runCooldownFilter(Pool $pool, Request $request): Pool + { + if (null === $this->cooldownPoolFilter) { + return $pool; + } + + $this->io->debug('Running cooldown pool filter.'); + + $before = microtime(true); + $total = \count($pool->getPackages()); + + $pool = $this->cooldownPoolFilter->filter($pool, $request); + $this->writeCooldownUnverifiedVersions($this->cooldownPoolFilter); + + $filtered = $total - \count($pool->getPackages()); + + if (0 === $filtered) { + return $pool; + } + + $this->io->write(sprintf('Cooldown pool filter completed in %.3f seconds', microtime(true) - $before), true, IOInterface::VERY_VERBOSE); + $this->io->writeError(sprintf( + '%d package version(s) withheld by the cooldown policy%s', + $filtered, + $this->io->isVeryVerbose() ? ':' : ' (run with -vv to list them).' + )); + $this->writeCooldownRemovedVersions($pool); + + return $pool; + } + + private function writeCooldownRemovedVersions(Pool $pool): void + { + if (!$this->io->isVeryVerbose()) { + return; + } + + foreach ($pool->getAllPolicyRemovedVersions() as $packageName => $versions) { + foreach ($versions as $reason) { + $info = $reason->getCooldownInfo(); + // Withheld versions are also recorded under replaced names for problem reporting, list each package once + if ($info === null || $reason->getPackageName() !== $packageName) { + continue; + } + + $this->io->writeError(sprintf( + ' - %s (%s) published %s, available in %s', + $packageName, + $reason->getPrettyVersion(), + $info['releaseDate'], + $info['availableIn'] + )); + } + } + } + + private function writeCooldownUnverifiedVersions(CooldownPoolFilter $filter): void + { + foreach ($filter->getUnverifiedVersions() as $repoName => $versions) { + $this->io->writeError(sprintf( + 'The publication date of %d package version(s) from %s could not be verified, the cooldown policy relied on the time field set by the package authors%s', + \count($versions), + $repoName, + $this->io->isVeryVerbose() ? ':' : ' (run with -vv to list them).' + )); + + if ($this->io->isVeryVerbose()) { + foreach ($versions as $version) { + $this->io->writeError(' - '.$version); + } + } + } + } } diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/PoolOptimizer.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/PoolOptimizer.php index a4714f9c89..0b47213f06 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/PoolOptimizer.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/PoolOptimizer.php @@ -173,7 +173,7 @@ private function applyRemovalsToPool(Pool $pool): Pool } } - $optimizedPool = new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), $removedVersions, $this->removedVersionsByPackage, $pool->getAllSecurityRemovedPackageVersions(), $pool->getAllAbandonedRemovedPackageVersions(), $pool->getAllFilterListRemovedPackageVersions()); + $optimizedPool = new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), $removedVersions, $this->removedVersionsByPackage, $pool->getAllPolicyRemovedVersions()); return $optimizedPool; } @@ -194,40 +194,43 @@ private function optimizeByIdenticalDependencies(Request $request, Pool $pool): $dependencyHash = $this->calculateDependencyHash($package); + $replaceHashParts = []; + $packageVersion = $package->getVersion(); + foreach ($package->getReplaces() as $link) { + if (CompilingMatcher::match($link->getConstraint(), Constraint::OP_EQ, $packageVersion)) { + // Use the same hash part as the regular require hash because that's what the replacement does + $replaceHashParts[] = 'require:' . (string) $link->getConstraint(); + } + } + foreach ($package->getNames(false) as $packageName) { if (!isset($this->requireConstraintsPerPackage[$packageName])) { continue; } - foreach ($this->requireConstraintsPerPackage[$packageName] as $requireConstraint) { - $groupHashParts = []; - - if (CompilingMatcher::match($requireConstraint, Constraint::OP_EQ, $package->getVersion())) { - $groupHashParts[] = 'require:' . (string) $requireConstraint; - } - - if (\count($package->getReplaces()) > 0) { - foreach ($package->getReplaces() as $link) { - if (CompilingMatcher::match($link->getConstraint(), Constraint::OP_EQ, $package->getVersion())) { - // Use the same hash part as the regular require hash because that's what the replacement does - $groupHashParts[] = 'require:' . (string) $link->getConstraint(); - } + $invariantHashParts = $replaceHashParts; + if (isset($this->conflictConstraintsPerPackage[$packageName])) { + foreach ($this->conflictConstraintsPerPackage[$packageName] as $conflictConstraint) { + if (CompilingMatcher::match($conflictConstraint, Constraint::OP_EQ, $packageVersion)) { + $invariantHashParts[] = 'conflict:' . (string) $conflictConstraint; } } + } - if (isset($this->conflictConstraintsPerPackage[$packageName])) { - foreach ($this->conflictConstraintsPerPackage[$packageName] as $conflictConstraint) { - if (CompilingMatcher::match($conflictConstraint, Constraint::OP_EQ, $package->getVersion())) { - $groupHashParts[] = 'conflict:' . (string) $conflictConstraint; - } + $invariantHash = implode('', $invariantHashParts); + $addedToInvariantGroup = false; + foreach ($this->requireConstraintsPerPackage[$packageName] as $requireConstraint) { + if (CompilingMatcher::match($requireConstraint, Constraint::OP_EQ, $packageVersion)) { + $groupHash = 'require:' . (string) $requireConstraint . $invariantHash; + } else { + // Every non-matching constraint yields the same hash, only add the package to it once + if ($addedToInvariantGroup || '' === $invariantHash) { + continue; } + $addedToInvariantGroup = true; + $groupHash = $invariantHash; } - if (0 === \count($groupHashParts)) { - continue; - } - - $groupHash = implode('', $groupHashParts); $identicalDefinitionsPerPackage[$packageName][$groupHash][$dependencyHash][] = $package->id; } } @@ -236,16 +239,18 @@ private function optimizeByIdenticalDependencies(Request $request, Pool $pool): foreach ($identicalDefinitionsPerPackage as $packageName => $constraintGroups) { foreach ($constraintGroups as $constraintGroup) { foreach ($constraintGroup as $packageIds) { + $versions = $this->getGroupVersions($pool, $packageIds); + // Only one package in this constraint group has the same requirements, we're not allowed to remove that package if (1 === \count($packageIds)) { - $this->keepPackageInGroup($pool->packageById($packageIds[0]), $pool, $packageName, $packageIds); + $this->keepPackageInGroup($pool->packageById($packageIds[0]), $packageName, $versions); continue; } // Otherwise we find out which one is the preferred package in this constraint group which is // then not allowed to be removed either foreach ($this->policy->selectPreferredPackages($pool, $packageIds) as $preferredLiteral) { - $this->keepPackageInGroup($pool->literalToPackage($preferredLiteral), $pool, $packageName, $packageIds); + $this->keepPackageInGroup($pool->literalToPackage($preferredLiteral), $packageName, $versions); } } } @@ -274,10 +279,8 @@ private function calculateDependencyHash(BasePackage $package): string $subhash = []; foreach ($links as $link) { - // To get the best dependency hash matches we should use Intervals::compactConstraint() here. - // However, the majority of projects are going to specify their constraints already pretty - // much in the best variant possible. In other words, we'd be wasting time here and it would actually hurt - // performance more than the additional few packages that could be filtered out would benefit the process. + // Intervals::compactConstraint() would let equivalent constraints hash the same, but constraints are + // nearly always written in their compact form already, so it only adds overhead without grouping more packages. $subhash[$link->getTarget()] = (string) $link->getConstraint(); } @@ -303,19 +306,10 @@ private function markPackageForRemoval(int $id): void } /** - * @param list $packageIds + * @param array $versions */ - private function keepPackageInGroup(BasePackage $package, Pool $pool, string $packageName, array $packageIds): void + private function keepPackageInGroup(BasePackage $package, string $packageName, array $versions): void { - $versions = []; - foreach ($packageIds as $packageId) { - $groupPackage = $pool->packageById($packageId); - if ($groupPackage instanceof AliasPackage && $groupPackage->getPrettyVersion() === VersionParser::DEFAULT_BRANCH_ALIAS) { - $groupPackage = $groupPackage->getAliasOf(); - } - $versions[$groupPackage->getVersion()] = $groupPackage->getPrettyVersion(); - } - // Always record versions even if already kept — the package may appear in // groups for multiple names (own name + replacement names) $this->recordRemovedVersionsForPackage($package, $packageName, $versions); @@ -349,6 +343,24 @@ private function keepPackageInGroup(BasePackage $package, Pool $pool, string $pa } } + /** + * @param list $packageIds + * @return array + */ + private function getGroupVersions(Pool $pool, array $packageIds): array + { + $versions = []; + foreach ($packageIds as $packageId) { + $groupPackage = $pool->packageById($packageId); + if ($groupPackage instanceof AliasPackage && $groupPackage->getPrettyVersion() === VersionParser::DEFAULT_BRANCH_ALIAS) { + $groupPackage = $groupPackage->getAliasOf(); + } + $versions[$groupPackage->getVersion()] = $groupPackage->getPrettyVersion(); + } + + return $versions; + } + private function unmarkPackageForRemoval(BasePackage $package): void { unset($this->packagesToRemove[$package->id]); @@ -363,9 +375,11 @@ private function recordRemovedVersionsForPackage(BasePackage $package, string $p return; } - foreach ($versions as $version => $prettyVersion) { - $this->removedVersionsByPackage[spl_object_id($package)][$version] = $prettyVersion; - } + // array_replace rather than += so the last pretty version wins, like a per-key assignment would + $id = spl_object_id($package); + $this->removedVersionsByPackage[$id] = isset($this->removedVersionsByPackage[$id]) + ? array_replace($this->removedVersionsByPackage[$id], $versions) + : $versions; } /** diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/Problem.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/Problem.php index 4d0b528e40..4d4d87ca5c 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/Problem.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/Problem.php @@ -12,7 +12,7 @@ namespace Composer\DependencyResolver; -use Composer\Advisory\SecurityAdvisory; +use Composer\Advisory\PartialSecurityAdvisory; use Composer\Package\CompletePackageInterface; use Composer\Package\AliasPackage; use Composer\Package\BasePackage; @@ -27,7 +27,6 @@ use Composer\Package\Version\VersionParser; use Composer\Repository\PlatformRepository; use Composer\Semver\Constraint\MultiConstraint; -use Symfony\Component\Console\Formatter\OutputFormatter; /** * Represents a problem detected while solving dependencies @@ -114,7 +113,73 @@ public function getPrettyString(RepositorySet $repositorySet, Request $request, return $this->getSortableString($pool, $rule1) <=> $this->getSortableString($pool, $rule2); }); - return self::formatDeduplicatedRules($reasons, ' ', $repositorySet, $request, $pool, $isVerbose, $installedMap, $learnedPool); + return self::formatDeduplicatedRules($reasons, ' ', $repositorySet, $request, $pool, $isVerbose, $installedMap, $learnedPool) + .self::formatPolicyExcludedConflictAlternatives($reasons, $repositorySet, $pool, $isVerbose); + } + + /** + * Explain when a policy removed versions that the reported conflicts do not exclude + * + * @param Rule[] $reasons + */ + private static function formatPolicyExcludedConflictAlternatives(array $reasons, RepositorySet $repositorySet, Pool $pool, bool $isVerbose): string + { + $rootConstraints = []; + $requireConstraints = []; + $conflictConstraints = []; + foreach ($reasons as $rule) { + if ($rule->getReason() === Rule::RULE_ROOT_REQUIRE) { + $data = $rule->getReasonData(); + $rootConstraints[$data['packageName']] = $data['constraint']; + } elseif ($rule->getReason() === Rule::RULE_PACKAGE_REQUIRES) { + $link = $rule->getReasonData(); + $requireConstraints[$link->getTarget()][] = $link->getConstraint(); + } elseif ($rule->getReason() === Rule::RULE_PACKAGE_CONFLICT) { + $link = $rule->getReasonData(); + $conflictConstraints[$link->getTarget()][] = $link->getConstraint(); + } + } + + $messages = []; + foreach ($conflictConstraints as $name => $constraints) { + if (!isset($rootConstraints[$name]) && !isset($requireConstraints[$name])) { + continue; + } + + $removals = []; + foreach ($pool->getPolicyRemovedVersions($name, $rootConstraints[$name] ?? null) as $version => $reason) { + // removals recorded under a replaced name carry the replacer's version + if ($reason->getPackageName() !== $name) { + continue; + } + + $packageVersion = new Constraint('==', $version); + $matches = static function (ConstraintInterface $constraint) use ($packageVersion): bool { + return $constraint->matches($packageVersion); + }; + if (array_any($constraints, $matches) || (isset($requireConstraints[$name]) && !array_any($requireConstraints[$name], $matches))) { + continue; + } + + $removals[$version] = $reason; + } + + if ($removals === []) { + continue; + } + + $plural = \count($removals) > 1; + $versions = array_map(static function (PolicyRemovalReason $reason): string { + return $reason->getPrettyVersion(); + }, $removals); + $prettyVersions = $isVerbose ? array_values($versions) : self::condenseVersionList($versions, 1); + // only the conflicts in the problem are checked, those of the removed versions themselves are unknown + $messages[] = "\n - ".$name.'['.implode(', ', $prettyVersions).'] '.($plural ? 'match' : 'matches') + .' the requirements and '.($plural ? 'are' : 'is').' not excluded by the conflicts above, but ' + .($plural ? 'were' : 'was').' not loaded, '.self::formatPolicyRemovalReasons($removals, $plural, $repositorySet); + } + + return implode('', $messages); } private function getSortableString(Pool $pool, Rule $rule): string @@ -380,64 +445,9 @@ public static function getMissingPackageReason(RepositorySet $repositorySet, Req } } - if ($pool->isAbandonedRemovedPackageVersion($packageName, $constraint)) { - return ["- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but these were not loaded, because they are abandoned and you configured "policy.abandoned.block" to true.']; - } - - if ($pool->isSecurityRemovedPackageVersion($packageName, $constraint)) { - $advisories = $repositorySet->getMatchingSecurityAdvisories($packages, false, true); - if (isset($advisories['advisories'][$packageName]) && \count($advisories['advisories'][$packageName]) > 0) { - $advisoriesList = array_map(static function (SecurityAdvisory $advisory): string { - if ($advisory->link !== null && $advisory->link !== '') { - return 'link).'>'.$advisory->advisoryId.''; - } - - if (str_starts_with($advisory->advisoryId, 'PKSA-')) { - return 'advisoryId).'>'.$advisory->advisoryId.''; - } - - return $advisory->advisoryId; - }, $advisories['advisories'][$packageName]); - $advisoryIds = array_map(static function (SecurityAdvisory $advisory): string { - return $advisory->advisoryId; - }, $advisories['advisories'][$packageName]); - } else { - $advisoryIds = $pool->getSecurityAdvisoryIdentifiersForPackageVersion($packageName, $constraint); - $advisoriesList = array_map(static function (string $advisoryId): string { - if (str_starts_with($advisoryId, 'PKSA-')) { - return ''.$advisoryId.''; - } - - return $advisoryId; - }, $advisoryIds); - } - - $hasPackagistAdvisories = true; - foreach ($advisoryIds as $advisoryId) { - if (!str_starts_with($advisoryId, 'PKSA-')) { - $hasPackagistAdvisories = false; - break; - } - } - - $advisoryDetailsHint = $hasPackagistAdvisories - ? ' Go to https://packagist.org/security-advisories/ to find advisory details.' - : ' Review the advisory details above for more information.'; - - return ["- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but these were not loaded, because they are affected by security advisories ("' . implode('", "', $advisoriesList). '").'.$advisoryDetailsHint.' To ignore the advisories, add their IDs to the "policy.advisories.ignore-id" config or add the package to "policy.advisories.ignore". To turn the feature off entirely, you can set "policy.advisories.block" to false.']; - } - - if ($pool->isFilterListRemovedPackageVersion($packageName, $constraint)) { - $filters = $pool->getFilterListEntryForPackageVersion($packageName, $constraint); - $ignorePaths = implode(' and ', array_map(static function (string $listName): string { - return '"policy.' . $listName . '.ignore"'; - }, array_keys($filters))); - - $offPaths = implode(' and ', array_map(static function (string $listName): string { - return '"policy.' . $listName . '.block"'; - }, array_keys($filters))); - - return ["- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but these were not loaded, because they were ' . implode(', ', $filters). '. To ignore filters for this package, add the package to the ' . $ignorePaths . ' config. To turn the feature off entirely, you can set ' . $offPaths . ' to false.']; + $policyRemovals = $pool->getPolicyRemovedVersions($packageName, $constraint); + if (\count($policyRemovals) > 0) { + return ["- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but these were not loaded, '.self::formatPolicyRemovalReasons($policyRemovals, true, $repositorySet)]; } if (!array_any($packages, static function ($p): bool { @@ -517,20 +527,11 @@ public static function getMissingPackageReason(RepositorySet $repositorySet, Req */ public static function getMissingLockedPackageReason(Pool $pool, BasePackage $package): array { - $packageName = $package->getName(); - $constraint = new Constraint(Constraint::STR_OP_EQ, $package->getVersion()); - $prefix = "- Package $packageName ".$package->getPrettyVersion().' (in the lock file) '; + $prefix = "- Package ".$package->getName()." ".$package->getPrettyVersion().' (in the lock file) '; - if ($pool->isFilterListRemovedPackageVersion($packageName, $constraint)) { - $filters = $pool->getFilterListEntryForPackageVersion($packageName, $constraint); - $ignorePaths = implode(' and ', array_map(static function (string $listName): string { - return '"policy.' . $listName . '.ignore"'; - }, array_keys($filters))); - $offPaths = implode(' and ', array_map(static function (string $listName): string { - return '"policy.' . $listName . '.block"'; - }, array_keys($filters))); - - return [$prefix, 'was not loaded, because it was ' . implode(', ', $filters). '. To ignore filters for this package, add the package to the ' . $ignorePaths . ' config. To turn the feature off entirely, you can set ' . $offPaths . ' to false.']; + $reason = $pool->getPolicyRemovalReason($package->getName(), $package->getVersion()); + if ($reason !== null) { + return [$prefix, 'was not loaded, because it '.self::formatPolicyRemovalSentence($reason, false)]; } throw new \LogicException("Filter list removed locked package must have version removed from pool."); @@ -763,4 +764,94 @@ private static function getProvidersList(RepositorySet $repositorySet, string $p return null; } + + /** + * @return string the reason as the end of a sentence starting with "because they"/"because it", followed by the remedies + */ + private static function formatPolicyRemovalSentence(PolicyRemovalReason $reason, bool $plural): string + { + $remedy = $reason->getRemedy(); + + return $reason->getVerb($plural).' '.$reason->getDescription().'.'.($remedy !== '' ? ' '.$remedy : '').' To turn the feature off entirely, you can set '.$reason->getOffSwitch().' to false.'; + } + + /** + * Explains why policies removed the versions, as "because ..." followed by the remedies + * + * When several policies removed different versions, one sentence would attribute every reason to + * every version, so the versions are then listed grouped by their own reason + * + * @param non-empty-array $removals normalized version => reason, in version order + */ + private static function formatPolicyRemovalReasons(array $removals, bool $plural, RepositorySet $repositorySet): string + { + $removals = self::loadCompleteAdvisories($removals, $repositorySet); + + $policies = array_unique(array_map(static function (PolicyRemovalReason $reason): string { + return $reason->getType(); + }, $removals)); + if (\count($policies) === 1) { + return 'because '.($plural ? 'they ' : 'it ').self::formatPolicyRemovalSentence(PolicyRemovalReason::combine($removals), $plural); + } + + $versionsByReason = []; + $remedies = []; + $offSwitches = []; + foreach ($removals as $reason) { + $versionsByReason[$reason->getDescription()][$reason->getPrettyVersion()] = true; + if ($reason->getRemedy() !== '') { + $remedies[$reason->getRemedy()] = true; + } + $offSwitches[$reason->getOffSwitch()] = true; + } + + $lines = []; + foreach ($versionsByReason as $description => $versions) { + $lines[] = implode(', ', array_keys($versions)) . ': ' . $description; + } + + $remedies['To turn a policy off entirely, you can set ' . implode(' or ', array_keys($offSwitches)) . ' to false.'] = true; + + return "because:\n - " . implode("\n - ", $lines) . "\n " . implode(' ', array_keys($remedies)); + } + + /** + * The pool may only hold partial advisories, load the complete ones to link to their details + * + * @param non-empty-array $removals normalized version => reason + * @return non-empty-array + */ + private static function loadCompleteAdvisories(array $removals, RepositorySet $repositorySet): array + { + $versionsByName = []; + foreach ($removals as $version => $reason) { + if ($reason->getType() === PolicyRemovalReason::ADVISORIES) { + $versionsByName[$reason->getPackageName()][] = new Constraint('==', (string) $version); + } + } + + $completeAdvisories = []; + foreach ($versionsByName as $name => $constraints) { + $packages = $repositorySet->findPackages($name, MultiConstraint::create($constraints, false)); + foreach ($repositorySet->getMatchingSecurityAdvisories($packages, false, true)['advisories'] as $advisories) { + foreach ($advisories as $advisory) { + $completeAdvisories[$advisory->advisoryId] = $advisory; + } + } + } + + if ($completeAdvisories === []) { + return $removals; + } + + foreach ($removals as $version => $reason) { + if ($reason->getType() === PolicyRemovalReason::ADVISORIES) { + $removals[$version] = PolicyRemovalReason::advisories($reason->getPackageName(), $reason->getPrettyVersion(), array_map(static function (PartialSecurityAdvisory $advisory) use ($completeAdvisories): PartialSecurityAdvisory { + return $completeAdvisories[$advisory->advisoryId] ?? $advisory; + }, $reason->getAdvisories())); + } + } + + return $removals; + } } diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/Request.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/Request.php index d85a6d2014..34ecca3b72 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/Request.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/Request.php @@ -14,6 +14,7 @@ use Composer\Package\BasePackage; use Composer\Package\PackageInterface; +use Composer\Pcre\Preg; use Composer\Repository\LockArrayRepository; use Composer\Semver\Constraint\ConstraintInterface; use Composer\Semver\Constraint\MatchAllConstraint; @@ -52,6 +53,8 @@ class Request protected $fixedLockedPackages = []; /** @var array */ protected $updateAllowList = []; + /** @var non-empty-string|null */ + private $updateAllowListRegex = null; /** @var false|self::UPDATE_* */ protected $updateAllowTransitiveDependencies = false; /** @var non-empty-list|null */ @@ -126,9 +129,18 @@ public function unlockPackage(BasePackage $package): void public function setUpdateAllowList(array $updateAllowList, $updateAllowTransitiveDependencies): void { $this->updateAllowList = $updateAllowList; + $this->updateAllowListRegex = \count($updateAllowList) > 0 ? BasePackage::packageNamesToRegexp($updateAllowList) : null; $this->updateAllowTransitiveDependencies = $updateAllowTransitiveDependencies; } + /** + * Whether the package name matches one of the update allow list patterns (not including transitive dependencies) + */ + public function isInUpdateAllowList(string $packageName): bool + { + return $this->updateAllowListRegex !== null && Preg::isMatch($this->updateAllowListRegex, $packageName); + } + /** * @return array */ diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/RuleSetGenerator.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/RuleSetGenerator.php index f4a5691439..e6aa937aa8 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/RuleSetGenerator.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/RuleSetGenerator.php @@ -17,7 +17,6 @@ use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterInterface; use Composer\Package\BasePackage; use Composer\Package\AliasPackage; -use Composer\Semver\Constraint\Constraint; /** * @author Nils Adermann @@ -261,7 +260,8 @@ protected function addRulesForRequest(Request $request, PlatformRequirementFilte // stale positive id from any prior Pool it was part of (Pool only // re-assigns ids for packages it actually contains, so removed // entries keep their previous id). - if ($request->isLockedPackage($package) && $this->pool->isFilterListRemovedPackageVersion($package->getName(), new Constraint(Constraint::STR_OP_EQ, $package->getVersion()))) { + $policyRemoval = $request->isLockedPackage($package) ? $this->pool->getPolicyRemovalReason($package->getName(), $package->getVersion()) : null; + if ($policyRemoval !== null && $policyRemoval->getType() === PolicyRemovalReason::FILTER_LIST) { continue; } diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/RuleWatchGraph.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/RuleWatchGraph.php index 0aa6fc13e1..02e821aee6 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/RuleWatchGraph.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/RuleWatchGraph.php @@ -41,11 +41,12 @@ class RuleWatchGraph */ public function insert(RuleWatchNode $node): void { - if ($node->getRule()->isAssertion()) { + $rule = $node->getRule(); + if ($rule->isAssertion()) { return; } - if (!$node->getRule() instanceof MultiConflictRule) { + if (!$rule instanceof MultiConflictRule) { foreach ([$node->watch1, $node->watch2] as $literal) { if (!isset($this->watchChains[$literal])) { $this->watchChains[$literal] = new RuleWatchChain; @@ -54,7 +55,7 @@ public function insert(RuleWatchNode $node): void $this->watchChains[$literal]->unshift($node); } } else { - foreach ($node->getRule()->getLiterals() as $literal) { + foreach ($rule->getLiterals() as $literal) { if (!isset($this->watchChains[$literal])) { $this->watchChains[$literal] = new RuleWatchChain; } diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/SecurityAdvisoryPoolFilter.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/SecurityAdvisoryPoolFilter.php index a19f852f64..85910b179a 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/SecurityAdvisoryPoolFilter.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/SecurityAdvisoryPoolFilter.php @@ -16,6 +16,7 @@ use Composer\Advisory\PartialSecurityAdvisory; use Composer\Advisory\SecurityAdvisory; use Composer\IO\IOInterface; +use Composer\Package\AliasPackage; use Composer\Package\PackageInterface; use Composer\Package\RootPackageInterface; use Composer\Policy\PolicyConfig; @@ -65,6 +66,7 @@ public function filter(Pool $pool, array $repositories, Request $request): Pool $packagesForAdvisories = []; foreach ($pool->getPackages() as $package) { + $package = self::getAdvisoryTarget($package); if (!$package instanceof RootPackageInterface && !PlatformRepository::isPlatformPackage($package->getName()) && !$request->isLockedPackage($package)) { $packagesForAdvisories[] = $package; } @@ -90,21 +92,16 @@ public function filter(Pool $pool, array $repositories, Request $request): Pool $ignoreAbandonedForBlocking = $abandoned->getFlatIgnoreForOperation('block'); $packages = []; - $securityRemovedVersions = []; - $abandonedRemovedVersions = []; + $policyRemovedVersions = $pool->getAllPolicyRemovedVersions(); foreach ($pool->getPackages() as $package) { if ($abandoned->block && count($this->auditor->filterAbandonedPackages([$package], $ignoreAbandonedForBlocking)) !== 0) { - foreach ($package->getNames(false) as $packageName) { - $abandonedRemovedVersions[$packageName][$package->getVersion()] = $package->getPrettyVersion(); - } + Pool::recordPolicyRemoval($policyRemovedVersions, $package, PolicyRemovalReason::abandoned($package->getName(), $package->getPrettyVersion())); continue; } $matchingAdvisories = $this->getMatchingAdvisories($package, $advisoryMap); if (count($matchingAdvisories) > 0) { - foreach ($package->getNames(false) as $packageName) { - $securityRemovedVersions[$packageName][$package->getVersion()] = $matchingAdvisories; - } + Pool::recordPolicyRemoval($policyRemovedVersions, $package, PolicyRemovalReason::advisories($package->getName(), $package->getPrettyVersion(), $matchingAdvisories)); continue; } @@ -112,7 +109,7 @@ public function filter(Pool $pool, array $repositories, Request $request): Pool $packages[] = $package; } - return new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), $pool->getAllRemovedVersions(), $pool->getAllRemovedVersionsByPackage(), $securityRemovedVersions, $abandonedRemovedVersions); + return new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), $pool->getAllRemovedVersions(), $pool->getAllRemovedVersionsByPackage(), $policyRemovedVersions); } /** @@ -121,7 +118,9 @@ public function filter(Pool $pool, array $repositories, Request $request): Pool */ private function getMatchingAdvisories(PackageInterface $package, array $advisoryMap): array { - if ($package->isDev()) { + $package = self::getAdvisoryTarget($package); + + if (RepositorySet::isUnversionedBranch($package)) { return []; } @@ -141,4 +140,16 @@ private function getMatchingAdvisories(PackageInterface $package, array $advisor return $matchingAdvisories; } + + /** + * A root alias version is chosen by the root package author and says nothing about the actual code, so advisories are checked against the aliased package + */ + private static function getAdvisoryTarget(PackageInterface $package): PackageInterface + { + if ($package instanceof AliasPackage && $package->isRootPackageAlias()) { + return $package->getAliasOf(); + } + + return $package; + } } diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/Solver.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/Solver.php index 06beb99067..d9fc3803d0 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/Solver.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/Solver.php @@ -17,7 +17,6 @@ use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterInterface; use Composer\IO\IOInterface; use Composer\Package\BasePackage; -use Composer\Semver\Constraint\Constraint; /** * @author Nils Adermann @@ -186,8 +185,8 @@ protected function checkForRootRequireProblems(Request $request, PlatformRequire protected function checkForFilterListRemovedLockedPackages(Request $request): void { foreach ($request->getLockedPackages() as $package) { - $constraint = new Constraint(Constraint::STR_OP_EQ, $package->getVersion()); - if (!$this->pool->isFilterListRemovedPackageVersion($package->getName(), $constraint)) { + $reason = $this->pool->getPolicyRemovalReason($package->getName(), $package->getVersion()); + if ($reason === null || $reason->getType() !== PolicyRemovalReason::FILTER_LIST) { continue; } @@ -695,12 +694,11 @@ private function runSat(): void } $rule = $this->rules->ruleById[$i]; - $literals = $rule->getLiterals(); - if ($rule->isDisabled()) { continue; } + $literals = $rule->getLiterals(); $decisionQueue = []; // make sure that diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/SolverProblemsException.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/SolverProblemsException.php index bd76e4fa3f..e57083ed90 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/SolverProblemsException.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/SolverProblemsException.php @@ -64,7 +64,7 @@ public function getPrettyString(RepositorySet $repositorySet, Request $request, $hints = []; if (!$isDevExtraction && (str_contains($text, 'could not be found') || str_contains($text, 'no matching package found'))) { - $hints[] = "Potential causes:\n - A typo in the package name\n - The package is not available in a stable-enough version according to your minimum-stability setting\n see for more details.\n - It's a private package and you forgot to add a custom repository to find it\n\nRead for further common problems."; + $hints[] = "Potential causes:\n - A typo in the package name\n - The package is not available in a stable-enough version according to your minimum-stability setting\n see for more details.\n - It's a private package and you forgot to add a custom repository to find it\n\nRead for further common problems."; } if (\count($missingExtensions) > 0) { diff --git a/core/vendor/composer/composer/src/Composer/DependencyResolver/Transaction.php b/core/vendor/composer/composer/src/Composer/DependencyResolver/Transaction.php index 54fa8ce9da..7f07de55c5 100644 --- a/core/vendor/composer/composer/src/Composer/DependencyResolver/Transaction.php +++ b/core/vendor/composer/composer/src/Composer/DependencyResolver/Transaction.php @@ -15,6 +15,7 @@ use Composer\Package\AliasPackage; use Composer\Package\CompletePackageInterface; use Composer\Package\Link; +use Composer\Package\Loader\ValidatingArrayLoader; use Composer\Package\PackageInterface; use Composer\Repository\PlatformRepository; use Composer\DependencyResolver\Operation\OperationInterface; @@ -53,6 +54,11 @@ class Transaction */ public function __construct(array $presentPackages, array $resultPackages) { + // present packages may come from installed.json which is loaded without validation + foreach (array_merge($presentPackages, $resultPackages) as $package) { + ValidatingArrayLoader::validatePackage($package); + } + $this->presentPackages = $presentPackages; $this->setResultPackageMaps($resultPackages); $this->operations = $this->calculateOperations(); diff --git a/core/vendor/composer/composer/src/Composer/Downloader/ArchiveDownloader.php b/core/vendor/composer/composer/src/Composer/Downloader/ArchiveDownloader.php index 1f37d7dfc3..45d864260e 100644 --- a/core/vendor/composer/composer/src/Composer/Downloader/ArchiveDownloader.php +++ b/core/vendor/composer/composer/src/Composer/Downloader/ArchiveDownloader.php @@ -112,19 +112,23 @@ public function install(PackageInterface $package, string $path, bool $output = } /** - * Returns the folder content, excluding .DS_Store + * Returns the folder content, optionally excluding OS metadata entries * - * @param string $dir Directory + * @param string $dir Directory + * @param bool $skipMetadata * @return \SplFileInfo[] */ - $getFolderContent = static function ($dir): array { + $getFolderContent = static function ($dir, bool $skipMetadata = false): array { $finder = Finder::create() ->ignoreVCS(false) ->ignoreDotFiles(false) - ->notName('.DS_Store') ->depth(0) ->in($dir); + if ($skipMetadata) { + $finder->notName(Platform::METADATA_NAME_REGEX); + } + return iterator_to_array($finder); }; $renameRecursively = null; @@ -169,7 +173,8 @@ public function install(PackageInterface $package, string $path, bool $output = } } - $contentDir = $getFolderContent($temporaryDir); + // metadata siblings must not prevent unwrapping a single top-level dir + $contentDir = $getFolderContent($temporaryDir, true); $singleDirAtTopLevel = 1 === count($contentDir) && is_dir((string) reset($contentDir)); if ($renameAsOne) { diff --git a/core/vendor/composer/composer/src/Composer/Downloader/DownloadManager.php b/core/vendor/composer/composer/src/Composer/Downloader/DownloadManager.php index 038da916b7..54ffec35c0 100644 --- a/core/vendor/composer/composer/src/Composer/Downloader/DownloadManager.php +++ b/core/vendor/composer/composer/src/Composer/Downloader/DownloadManager.php @@ -38,10 +38,6 @@ class DownloadManager private $filesystem; /** @var array */ private $downloaders = []; - /** @var bool */ - private $sourceFallback = false; - /** @var bool */ - private $sourceFallbackDeprecationWarned = false; /** * Initializes download manager. @@ -93,22 +89,6 @@ public function setPreferences(array $preferences): self return $this; } - /** - * Allow fallback to alternative sources when download fails. - */ - public function setSourceFallback(bool $sourceFallback): self - { - if ($sourceFallback && !$this->sourceFallbackDeprecationWarned) { - $this->io->writeError('The source-fallback option is deprecated and will be removed in Composer 2.11 as automatic fallback between dist and source has security implications.'); - $this->io->writeError('If you have a legitimate use case and do not want us to remove it in 2.11, please open an issue at https://github.com/composer/composer/issues to let us know.'); - $this->sourceFallbackDeprecationWarned = true; - } - - $this->sourceFallback = $sourceFallback; - - return $this; - } - /** * Sets installer downloader for a specific installation type. * @@ -218,20 +198,16 @@ public function download(PackageInterface $package, string $targetDir, ?PackageI $handleError = function ($e) use ($sources, $source, $package, $io, $download) { if ($e instanceof \RuntimeException && !$e instanceof IrrecoverableDownloadException) { $nextSource = $sources[0] ?? null; - // Only fallback from dist to source is gated by the sourceFallback flag, as it can - // silently switch to less-trusted code or cause other issues where people rely on dists. + // Only fallback from dist to source is risky as it can silently switch to less-trusted + // code or cause other issues where people rely on dists. // Falling back the other way around (source -> dist) is always allowed. - $blocked = $nextSource === 'source' && !$this->sourceFallback; - if ($nextSource === null || $blocked) { - if ($blocked) { - $io->writeError( - ' Failed to download '. - $package->getPrettyName(). - ' from ' . $source . ': '. - $e->getMessage().'' - ); - $io->writeError(' Source fallback is disabled. Not trying alternative sources.'); - } + if ($nextSource !== 'dist') { + $io->writeError( + ' Failed to download '. + $package->getPrettyName(). + ' from ' . $source . ': '. + $e->getMessage().'' + ); throw $e; } diff --git a/core/vendor/composer/composer/src/Composer/Downloader/FileDownloader.php b/core/vendor/composer/composer/src/Composer/Downloader/FileDownloader.php index 940bb62a8d..4a805aea00 100644 --- a/core/vendor/composer/composer/src/Composer/Downloader/FileDownloader.php +++ b/core/vendor/composer/composer/src/Composer/Downloader/FileDownloader.php @@ -27,6 +27,7 @@ use Composer\Plugin\PreFileDownloadEvent; use Composer\EventDispatcher\EventDispatcher; use Composer\Installer\BinaryInstaller; +use Composer\Pcre\Preg; use Composer\Util\Filesystem; use Composer\Util\Http\Response; use Composer\Util\Platform; @@ -155,6 +156,7 @@ public function download(PackageInterface $package, string $path, ?PackageInterf $this->filesystem->ensureDirectoryExists(dirname($fileName)); $accept = null; + /** @var (callable(\Throwable): mixed)|null $reject */ $reject = null; $download = function () use ($output, $cacheKeyGenerator, $package, $fileName, &$urls, &$accept, &$reject) { $url = reset($urls); @@ -223,7 +225,7 @@ public function download(PackageInterface $package, string $path, ?PackageInterf }); }; - $accept = function (Response $response) use ($package, $fileName, &$urls): string { + $accept = function (Response $response) use ($package, $fileName, &$urls, &$reject, &$retries) { $url = reset($urls); $cacheKey = $url['cacheKey']; $fileSize = @filesize($fileName); @@ -236,6 +238,21 @@ public function download(PackageInterface $package, string $path, ?PackageInterf FileDownloader::$responseHeaders[$package->getName()] = $response->getHeaders(); } + try { + $this->validateDownloadedFile($package, $fileName); + } catch (TransportException $e) { + // a broken file from a local source will not get better by downloading it again + if (!Preg::isMatch('{^https?://}i', $url['processed'])) { + $retries = 0; + } elseif ($retries > 0) { + $this->io->writeError(' '.$e->getMessage().', retrying'); + } + $response->collect(); + assert($reject !== null); + + return $reject($e); + } + if ($this->cache !== null && !$this->cache->isReadOnly()) { $this->lastCacheWrites[$package->getName()] = $cacheKey; $this->cache->copyFrom($cacheKey, $fileName); @@ -388,6 +405,17 @@ protected function getDistPath(PackageInterface $package, int $component): strin return pathinfo((string) parse_url(strtr((string) $package->getDistUrl(), '\\', '/'), PHP_URL_PATH), $component); } + /** + * Checks that a downloaded file is usable, e.g. not truncated, before it is written to the cache + * + * Throwing a TransportException makes the file get downloaded again + * + * @throws TransportException + */ + protected function validateDownloadedFile(PackageInterface $package, string $fileName): void + { + } + protected function clearLastCacheWrite(PackageInterface $package): void { if ($this->cache !== null && isset($this->lastCacheWrites[$package->getName()])) { diff --git a/core/vendor/composer/composer/src/Composer/Downloader/FossilDownloader.php b/core/vendor/composer/composer/src/Composer/Downloader/FossilDownloader.php index 67d8f321bf..e8b8fdd9a8 100644 --- a/core/vendor/composer/composer/src/Composer/Downloader/FossilDownloader.php +++ b/core/vendor/composer/composer/src/Composer/Downloader/FossilDownloader.php @@ -13,6 +13,7 @@ namespace Composer\Downloader; use Composer\Util\Platform; +use Composer\Util\Url; use React\Promise\PromiseInterface; use Composer\Package\PackageInterface; use Composer\Pcre\Preg; @@ -114,7 +115,7 @@ protected function getCommitLogs(string $fromReference, string $toReference, str private function execute(array $command, ?string $cwd = null, ?string &$output = null): void { if (0 !== $this->process->execute($command, $output, $cwd)) { - throw new RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); + throw new RuntimeException(Url::sanitize('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput())); } } diff --git a/core/vendor/composer/composer/src/Composer/Downloader/HgDownloader.php b/core/vendor/composer/composer/src/Composer/Downloader/HgDownloader.php index 6b01feb2a9..f7989292c5 100644 --- a/core/vendor/composer/composer/src/Composer/Downloader/HgDownloader.php +++ b/core/vendor/composer/composer/src/Composer/Downloader/HgDownloader.php @@ -15,6 +15,7 @@ use React\Promise\PromiseInterface; use Composer\Package\PackageInterface; use Composer\Util\Hg as HgUtils; +use Composer\Util\Url; /** * @author Per Bernhardt @@ -48,7 +49,7 @@ protected function doInstall(PackageInterface $package, string $path, string $ur $command = ['hg', 'up', '--', (string) $package->getSourceReference()]; if (0 !== $this->process->execute($command, $ignoredOutput, realpath($path))) { - throw new \RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); + throw new \RuntimeException(Url::sanitize('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput())); } return \React\Promise\resolve(null); @@ -105,7 +106,7 @@ protected function getCommitLogs(string $fromReference, string $toReference, str $command = ['hg', 'log', '-r', $fromReference.':'.$toReference, '--style', 'compact']; if (0 !== $this->process->execute($command, $output, realpath($path))) { - throw new \RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); + throw new \RuntimeException(Url::sanitize('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput())); } return $output; diff --git a/core/vendor/composer/composer/src/Composer/Downloader/ZipDownloader.php b/core/vendor/composer/composer/src/Composer/Downloader/ZipDownloader.php index 09b6538dd4..1fe7594430 100644 --- a/core/vendor/composer/composer/src/Composer/Downloader/ZipDownloader.php +++ b/core/vendor/composer/composer/src/Composer/Downloader/ZipDownloader.php @@ -100,6 +100,23 @@ public function download(PackageInterface $package, string $path, ?PackageInterf return parent::download($package, $path, $prevPackage, $output); } + /** + * @inheritDoc + */ + protected function validateDownloadedFile(PackageInterface $package, string $fileName): void + { + $size = @filesize($fileName); + if (false === $size) { + return; + } + + // the end of central directory record is 22 bytes followed by an up to 64KiB comment, a truncated download lacks it + $tail = @file_get_contents($fileName, false, null, max(0, $size - 22 - 65535)); + if (false !== $tail && !str_contains($tail, "PK\x05\x06")) { + throw new TransportException('The downloaded archive for '.$package->getName().' is truncated or corrupt, zip end of central directory not found (file size: '.$size.' bytes)', 0); + } + } + /** * extract $file to $path with "unzip" command * diff --git a/core/vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php b/core/vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php index ac963797b9..cc8acee17f 100644 --- a/core/vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php +++ b/core/vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php @@ -233,30 +233,47 @@ protected function doDispatch(Event $event) } elseif ($this->isComposerScript($callable)) { $this->io->writeError(sprintf('> %s: %s', $formattedEventNameWithArgs, $callable), true, IOInterface::VERBOSE); - $script = explode(' ', substr($callable, 1)); - $scriptName = $script[0]; - unset($script[0]); - - $index = array_search('@additional_args', $script, true); - if ($index !== false) { - $args = array_splice($script, $index, 0, $additionalArgs); - } else { - $args = array_merge($script, $additionalArgs); - } - $flags = $event->getFlags(); - if (isset($flags['script-alias-input'])) { - $argsString = implode(' ', array_map(static function ($arg) { return ProcessExecutor::escape($arg); }, $script)); - $flags['script-alias-input'] = $argsString . ' ' . $flags['script-alias-input']; - unset($argsString); - } if (strpos($callable, '@composer ') === 0) { - $exec = $this->getPhpExecCommand() . ' ' . ProcessExecutor::escape(Platform::getEnv('COMPOSER_BINARY')) . ' ' . implode(' ', $args); + // the script's own args are passed to the shell as is, but the additional args come from the command line so they must be escaped + $args = implode(' ', array_map([ProcessExecutor::class, 'escape'], $additionalArgs)); + $exec = substr($callable, strlen('@composer ')); + if (str_contains($exec, '@additional_args')) { + $exec = str_replace('@additional_args', $args, $exec); + } elseif ($args !== '') { + $exec .= ' '.$args; + } + $exec = $this->getPhpExecCommand() . ' ' . ProcessExecutor::escape(Platform::getEnv('COMPOSER_BINARY')) . ' ' . $exec; if (0 !== ($exitCode = $this->executeTty($exec))) { $this->io->writeError(sprintf('Script %s handling the %s event returned with error code '.$exitCode.'', $callable, $event->getName()), true, IOInterface::QUIET); throw new ScriptExecutionException('Error Output: '.$this->process->getErrorOutput(), $exitCode); } } else { + // the fixed args are passed on as arguments of the referenced script, which escapes them, so they need to be unquoted + $script = self::splitScriptArgs(substr($callable, 1)); + $scriptName = (string) array_shift($script); + + $args = []; + $aliasArgs = []; + $flags = $event->getFlags(); + $hasAdditionalArgs = in_array('@additional_args', $script, true); + foreach ($script as $arg) { + if ($arg === '@additional_args') { + $args = array_merge($args, $additionalArgs); + $aliasArgs[] = $flags['script-alias-input'] ?? ''; + } else { + $args[] = $arg; + $aliasArgs[] = ProcessExecutor::escape($arg); + } + } + if (!$hasAdditionalArgs) { + $args = array_merge($args, $additionalArgs); + $aliasArgs[] = $flags['script-alias-input'] ?? ''; + } + if (isset($flags['script-alias-input'])) { + $flags['script-alias-input'] = implode(' ', $aliasArgs); + } + if (!$this->getListeners(new Event($scriptName))) { $this->io->writeError(sprintf('You made a reference to a non-existent script %s', $callable), true, IOInterface::QUIET); } @@ -345,6 +362,10 @@ protected function doDispatch(Event $event) throw $e; } } else { + if ($callable === '') { + continue; + } + $args = implode(' ', array_map(['Composer\Util\ProcessExecutor', 'escape'], $additionalArgs)); // @putenv does not receive arguments @@ -645,6 +666,22 @@ protected function isCommandClass(string $callable): bool return str_contains($callable, '\\') && !str_contains($callable, ' ') && str_ends_with($callable, 'Command'); } + /** + * Splits on whitespace, keeping quoted parts together and unquoting them + * + * @return list + */ + private static function splitScriptArgs(string $command): array + { + Preg::matchAll('{(?:[^\s"\']+|"[^"]*"|\'[^\']*\'|["\'])+}', $command, $matches); + + return array_map(static function (string $token): string { + return Preg::replaceCallback('{(["\'])(.*?)\1}s', static function (array $match): string { + return $match[2]; + }, $token); + }, $matches[0]); + } + /** * Checks if string given references a composer run-script */ diff --git a/core/vendor/composer/composer/src/Composer/Factory.php b/core/vendor/composer/composer/src/Composer/Factory.php index 08370935a5..598258a8f2 100644 --- a/core/vendor/composer/composer/src/Composer/Factory.php +++ b/core/vendor/composer/composer/src/Composer/Factory.php @@ -426,9 +426,11 @@ public function createComposer(IOInterface $io, $localConfig = null, $disablePlu } $locker = new Package\Locker($io, new JsonFile($config->get('lock') ? $lockFile : Platform::getDevNull(), null, $io), $im, file_get_contents($composerFile), $process); + $locker->setRepositoryManager($rm); $composer->setLocker($locker); } elseif ($composer instanceof Composer) { $locker = new Package\Locker($io, new JsonFile(Platform::getDevNull(), null, $io), $im, JsonFile::encode($localConfig), $process); + $locker->setRepositoryManager($rm); $composer->setLocker($locker); } @@ -478,7 +480,9 @@ protected function addLocalRepository(IOInterface $io, RepositoryManager $rm, st $fs = new Filesystem($process); } - $rm->setLocalRepository(new Repository\InstalledFilesystemRepository(new JsonFile($vendorDir.'/composer/installed.json', null, $io), true, $rootPackage, $fs)); + $localRepo = new Repository\InstalledFilesystemRepository(new JsonFile($vendorDir.'/composer/installed.json', null, $io), true, $rootPackage, $fs); + $localRepo->setRepositoryManager($rm); + $rm->setLocalRepository($localRepo); } /** @@ -529,10 +533,6 @@ public function createDownloadManager(IOInterface $io, Config $config, HttpDownl $dm->setPreferences($preferred); } - if ($config->get('source-fallback')) { - $dm->setSourceFallback(true); - } - $dm->setDownloader('git', new Downloader\GitDownloader($io, $config, $process, $fs)); $dm->setDownloader('svn', new Downloader\SvnDownloader($io, $config, $process, $fs)); $dm->setDownloader('fossil', new Downloader\FossilDownloader($io, $config, $process, $fs)); diff --git a/core/vendor/composer/composer/src/Composer/IO/ConsoleIO.php b/core/vendor/composer/composer/src/Composer/IO/ConsoleIO.php index 8701563072..d67a6f97e9 100644 --- a/core/vendor/composer/composer/src/Composer/IO/ConsoleIO.php +++ b/core/vendor/composer/composer/src/Composer/IO/ConsoleIO.php @@ -12,6 +12,11 @@ namespace Composer\IO; +use Composer\Console\OscHelper; +use Composer\Console\Osc7501ProgramStatusEncoder; +use Composer\Console\ProgramStatus; +use Composer\Console\AbstractOscProgramStatusEncoder; +use Composer\Console\TerminalStatusPolicy; use Composer\Pcre\Preg; use Composer\Question\StrictConfirmationQuestion; use Symfony\Component\Console\Helper\HelperSet; @@ -44,6 +49,10 @@ class ConsoleIO extends BaseIO /** @var string|false */ private $sendTimestamps = false; + /** @var ProgramStatus|null */ + private $programStatus; + /** @var list */ + private $programStatusEncoders; /** @var float */ private $startTime; /** @var array */ @@ -61,6 +70,10 @@ public function __construct(InputInterface $input, OutputInterface $output, Help $this->input = $input; $this->output = $output; $this->helperSet = $helperSet; + if (!$helperSet->has('osc')) { + $helperSet->set(new OscHelper()); + } + $this->programStatusEncoders = TerminalStatusPolicy::fromEnvironment()->getEncoders(); $this->verbosityMap = [ self::QUIET => OutputInterface::VERBOSITY_QUIET, self::NORMAL => OutputInterface::VERBOSITY_NORMAL, @@ -126,6 +139,63 @@ public function isDebug() return $this->output->isDebug(); } + /** + * Sends a terminal command to stderr without changing normal message output. + * + * @experimental This API may change in a future minor release + */ + public function writeOsc(string $command, string $payload): void + { + /** @var OscHelper $helper */ + $helper = $this->helperSet->get('osc'); + $helper->write($this->getErrorOutput(), $command, $payload); + } + + /** + * @internal + */ + public function isProgramStatusEnabled(): bool + { + /** @var OscHelper $helper */ + $helper = $this->helperSet->get('osc'); + + return $this->programStatusEncoders !== [] && $helper->supportsOutput($this->getErrorOutput()); + } + + /** + * @internal + */ + public function startProgramStatus(): void + { + $this->programStatus = ProgramStatus::working(); + foreach ($this->programStatusEncoders as $encoder) { + $payload = $encoder->encodeStart(); + if ($payload !== null) { + $this->writeOsc($encoder->getCommand(), $payload); + } + } + } + + /** + * @experimental This API may change in a future minor release + */ + public function writeProgramStatus(ProgramStatus $status): void + { + $this->programStatus = $status; + foreach ($this->programStatusEncoders as $encoder) { + $this->writeOsc($encoder->getCommand(), $encoder->encode($status)); + } + } + + /** + * @experimental This API may change in a future minor release + */ + public function writeProgress(?int $progress): void + { + $status = $this->programStatus ?? ProgramStatus::working(); + $this->writeProgramStatus($status->withProgress($progress)); + } + /** * @inheritDoc */ @@ -287,11 +357,11 @@ public function getProgressBar(int $max = 0) */ public function ask($question, $default = null) { - /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ - $helper = $this->helperSet->get('question'); $question = new Question(self::sanitize($question), is_string($default) ? self::sanitize($default) : $default); - return $helper->ask($this->input, $this->getErrorOutput(), $question); + return $this->askQuestion($question, [ + Osc7501ProgramStatusEncoder::PROTOCOL => ['kind' => Osc7501ProgramStatusEncoder::KIND_QUESTION], + ]); } /** @@ -299,11 +369,11 @@ public function ask($question, $default = null) */ public function askConfirmation($question, $default = true) { - /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ - $helper = $this->helperSet->get('question'); $question = new StrictConfirmationQuestion(self::sanitize($question), is_string($default) ? self::sanitize($default) : $default); - return $helper->ask($this->input, $this->getErrorOutput(), $question); + return $this->askQuestion($question, [ + Osc7501ProgramStatusEncoder::PROTOCOL => ['kind' => Osc7501ProgramStatusEncoder::KIND_PERMISSION], + ]); } /** @@ -311,13 +381,13 @@ public function askConfirmation($question, $default = true) */ public function askAndValidate($question, $validator, $attempts = null, $default = null) { - /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ - $helper = $this->helperSet->get('question'); $question = new Question(self::sanitize($question), is_string($default) ? self::sanitize($default) : $default); $question->setValidator($validator); $question->setMaxAttempts($attempts); - return $helper->ask($this->input, $this->getErrorOutput(), $question); + return $this->askQuestion($question, [ + Osc7501ProgramStatusEncoder::PROTOCOL => ['kind' => Osc7501ProgramStatusEncoder::KIND_QUESTION], + ]); } /** @@ -325,12 +395,12 @@ public function askAndValidate($question, $validator, $attempts = null, $default */ public function askAndHideAnswer($question) { - /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ - $helper = $this->helperSet->get('question'); $question = new Question(self::sanitize($question)); $question->setHidden(true); - return $helper->ask($this->input, $this->getErrorOutput(), $question); + return $this->askQuestion($question, [ + Osc7501ProgramStatusEncoder::PROTOCOL => ['kind' => Osc7501ProgramStatusEncoder::KIND_AUTH], + ]); } /** @@ -338,14 +408,14 @@ public function askAndHideAnswer($question) */ public function select($question, $choices, $default, $attempts = false, $errorMessage = 'Value "%s" is invalid', $multiselect = false) { - /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ - $helper = $this->helperSet->get('question'); $question = new ChoiceQuestion(self::sanitize($question), self::sanitize($choices), is_string($default) ? self::sanitize($default) : $default); $question->setMaxAttempts($attempts ?: null); // IOInterface requires false, and Question requires null or int $question->setErrorMessage($errorMessage); $question->setMultiselect($multiselect); - $result = $helper->ask($this->input, $this->getErrorOutput(), $question); + $result = $this->askQuestion($question, [ + Osc7501ProgramStatusEncoder::PROTOCOL => ['kind' => Osc7501ProgramStatusEncoder::KIND_QUESTION], + ]); $isAssoc = (bool) \count(array_filter(array_keys($choices), 'is_string')); if ($isAssoc) { @@ -366,6 +436,33 @@ public function select($question, $choices, $default, $attempts = false, $errorM return $results; } + /** + * @param array> $protocolOptions + * @return mixed + */ + private function askQuestion(Question $question, array $protocolOptions = []) + { + /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ + $helper = $this->helperSet->get('question'); + if (!$this->isInteractive()) { + return $helper->ask($this->input, $this->getErrorOutput(), $question); + } + + $previousStatus = $this->programStatus ?? ProgramStatus::working(); + $status = ProgramStatus::blocked($question->getQuestion()); + foreach ($protocolOptions as $protocol => $options) { + foreach ($options as $name => $value) { + $status = $status->withProtocolOption($protocol, $name, $value); + } + } + $this->writeProgramStatus($status); + try { + return $helper->ask($this->input, $this->getErrorOutput(), $question); + } finally { + $this->writeProgramStatus($previousStatus); + } + } + public function getTable(): Table { return new Table($this->output); diff --git a/core/vendor/composer/composer/src/Composer/Installer.php b/core/vendor/composer/composer/src/Composer/Installer.php index 93d6fa006f..eef6b78c04 100644 --- a/core/vendor/composer/composer/src/Composer/Installer.php +++ b/core/vendor/composer/composer/src/Composer/Installer.php @@ -26,6 +26,7 @@ use Composer\DependencyResolver\Request; use Composer\DependencyResolver\FilterListPoolFilter; use Composer\DependencyResolver\SecurityAdvisoryPoolFilter; +use Composer\DependencyResolver\CooldownPoolFilter; use Composer\DependencyResolver\Solver; use Composer\DependencyResolver\SolverProblemsException; use Composer\DependencyResolver\PolicyInterface; @@ -43,6 +44,7 @@ use Composer\Installer\InstallerEvents; use Composer\Installer\SuggestedPackagesReporter; use Composer\IO\IOInterface; +use Composer\Json\JsonFile; use Composer\Package\AliasPackage; use Composer\Package\RootAliasPackage; use Composer\Package\BasePackage; @@ -450,10 +452,11 @@ public function run(): int if ($auditConfig->audit) { if ($this->update && !$this->install) { - $packages = $lockedRepository->getCanonicalPackages(); + // aliases are included as a branch alias version can match advisories which the dev-* branch cannot + $packages = $lockedRepository->getPackages(); $target = 'locked'; } else { - $packages = $localRepo->getCanonicalPackages(); + $packages = $localRepo->getPackages(); $target = 'installed'; } if (count($packages) > 0) { @@ -513,7 +516,6 @@ protected function doUpdate(InstalledRepositoryInterface $localRepo, bool $doIns $this->io->writeError('Loading composer repositories with package information'); // creating repository set - $policy = $this->createPolicy(true, $lockedRepository); $repositorySet = $this->createRepositorySet(true, $platformRepo, $aliases); $repositories = $this->repositoryManager->getRepositories(); foreach ($repositories as $repository) { @@ -531,7 +533,8 @@ protected function doUpdate(InstalledRepositoryInterface $localRepo, bool $doIns $request->setUpdateAllowList($this->updateAllowList, $this->updateAllowTransitiveDependencies); } - $pool = $repositorySet->createPool($request, $this->io, $this->eventDispatcher, $this->createPoolOptimizer($policy), $this->ignoredTypes, $this->allowedTypes, $this->createSecurityAuditPoolFilter(), $this->createFilterListPoolFilter(ListPolicyConfig::BLOCK_SCOPE_UPDATE)); + $policy = $this->createPolicy(true, $request); + $pool = $repositorySet->createPool($request, $this->io, $this->eventDispatcher, $this->createPoolOptimizer($policy), $this->ignoredTypes, $this->allowedTypes, $this->createSecurityAuditPoolFilter(), $this->createFilterListPoolFilter(ListPolicyConfig::BLOCK_SCOPE_UPDATE), $this->createCooldownPoolFilter()); $this->io->writeError('Updating dependencies'); @@ -750,6 +753,12 @@ protected function extractDevPackages(LockTransaction $lockTransaction, Platform */ protected function doInstall(InstalledRepositoryInterface $localRepo, bool $alreadySolved = false): int { + if (!$alreadySolved && ($this->locker->getLockData()['content-hash'] ?? null) === JsonFile::CONFLICTED_CONTENT_HASH) { + $this->io->writeError(''.JsonFile::CONFLICTED_CONTENT_HASH.'', true, IOInterface::QUIET); + + return self::ERROR_LOCK_FILE_INVALID; + } + if ($this->config->get('lock')) { $this->io->writeError('Installing dependencies from lock file'.($this->devMode ? ' (including require-dev)' : '').''); } @@ -976,7 +985,7 @@ private function createRepositorySet(bool $forUpdate, PlatformRepository $platfo return $repositorySet; } - private function createPolicy(bool $forUpdate, ?LockArrayRepository $lockedRepo = null): DefaultPolicy + private function createPolicy(bool $forUpdate, ?Request $request = null): DefaultPolicy { $preferStable = null; $preferLowest = null; @@ -994,10 +1003,11 @@ private function createPolicy(bool $forUpdate, ?LockArrayRepository $lockedRepo } $preferredVersions = null; - if ($forUpdate && $this->minimalUpdate && $lockedRepo !== null) { + $lockedRepo = $request !== null ? $request->getLockedRepository() : null; + if ($forUpdate && $this->minimalUpdate && $request !== null && $lockedRepo !== null) { $preferredVersions = []; foreach ($lockedRepo->getPackages() as $pkg) { - if ($pkg instanceof AliasPackage || ($this->updateAllowList !== null && in_array($pkg->getName(), $this->updateAllowList, true))) { + if ($pkg instanceof AliasPackage || $request->isInUpdateAllowList($pkg->getName())) { continue; } $preferredVersions[$pkg->getName()] = $pkg->getVersion(); @@ -1195,6 +1205,19 @@ private function createFilterListPoolFilter(string $blockScope): ?FilterListPool return new FilterListPoolFilter($policyConfig, new FilterListAuditor(), $this->repositoryManager->getHttpDownloader(), $blockScope, $this->repositoryManager->getRepositories(), $this->io); } + private function createCooldownPoolFilter(): ?CooldownPoolFilter + { + $policyConfig = $this->getPolicyConfig(); + + // Cooldown blocks during update/require only for now, and is skipped when + // only refreshing mirror/dist information (no version changes happen then) + if ($policyConfig->enabled && $policyConfig->cooldown->shouldBlock(ListPolicyConfig::BLOCK_SCOPE_UPDATE) && !$this->updateMirrors) { + return new CooldownPoolFilter($policyConfig->cooldown); + } + + return null; + } + /** * Create Installer */ diff --git a/core/vendor/composer/composer/src/Composer/Installer/BinaryInstaller.php b/core/vendor/composer/composer/src/Composer/Installer/BinaryInstaller.php index 1b494e32d3..fa8da2535d 100644 --- a/core/vendor/composer/composer/src/Composer/Installer/BinaryInstaller.php +++ b/core/vendor/composer/composer/src/Composer/Installer/BinaryInstaller.php @@ -17,7 +17,6 @@ use Composer\Pcre\Preg; use Composer\Util\Filesystem; use Composer\Util\Platform; -use Composer\Util\ProcessExecutor; use Composer\Util\Silencer; /** @@ -29,6 +28,9 @@ */ class BinaryInstaller { + /** characters that cannot be embedded safely in the generated proxies */ + private const UNSAFE_PROXY_CHARS = '{[*$`"&^|<>()%!;\x00-\x1f\x7f]}'; + /** @var string */ protected $binDir; /** @var string */ @@ -59,6 +61,13 @@ public function installBinaries(PackageInterface $package, string $installPath, Platform::workaroundFilesystemIssues(); foreach ($binaries as $bin) { + // ValidatingArrayLoader rejects these at publish time, but bins reach here from sources + // it never sees, e.g. path repositories, custom installers, or the ensureBinariesPresence() + // loop which reads installed.json through the non-validating ArrayLoader. + if (!self::isSafeBinPath($bin)) { + $this->io->writeError(' Skipped installation of bin '.$bin.' for package '.$package->getName().': the bin path contains invalid characters'); + continue; + } $binPath = $installPath.'/'.$bin; if (!file_exists($binPath)) { $this->io->writeError(' Skipped installation of bin '.$bin.' for package '.$package->getName().': file not found in package'); @@ -81,6 +90,10 @@ public function installBinaries(PackageInterface $package, string $installPath, // already checked the binary's existence). The following helpers // will require absolute paths to work properly. $binPath = realpath($binPath); + if (false === $binPath) { + $this->io->writeError(' Skipped installation of bin '.$bin.' for package '.$package->getName().': the bin path could not be resolved'); + continue; + } } $this->initializeBinDir(); $link = $this->binDir.'/'.basename($bin); @@ -103,6 +116,10 @@ public function installBinaries(PackageInterface $package, string $installPath, } if ($binCompat === "full") { + if (!self::isRepresentableInBatchProxy($binPath) || !self::isRepresentableInBatchProxy($this->binDir)) { + $this->io->writeError(' Skipped installation of bin '.$bin.' for package '.$package->getName().': the path contains a double quote or line break, which cannot be used in a Windows bin proxy'); + continue; + } $this->installFullBinaries($binPath, $link, $bin, $package); } else { $this->installUnixyProxyBinaries($binPath, $link); @@ -144,11 +161,95 @@ public static function determineBinaryCaller(string $bin): string $handle = fopen($bin, 'r'); $line = fgets($handle); fclose($handle); - if (Preg::isMatchStrictGroups('{^#!/(?:usr/bin/env )?(?:[^/]+/)*(.+)$}m', (string) $line, $match)) { - return trim($match[1]); + // trailing whitespace covers the CR of a bin using CRLF line endings, leading whitespace is + // left alone as the kernel only honors a "#!" at the very first byte of the file + $shebang = rtrim((string) $line, " \t\r\n"); + + return self::shebangCaller($shebang) ?? 'php'; + } + + /** + * Returns the command a shebang line invokes, incl. its arguments, or null if it cannot be used + * + * The shebang comes from the very bin the proxies run, so its arguments are carried along as is. + * Only characters which would break the command line of the .bat proxy are refused. + */ + private static function shebangCaller(string $shebang): ?string + { + if (!Preg::isMatchStrictGroups('{^#!(?:/[a-zA-Z0-9_.+-]+)*/([a-zA-Z0-9_.+][a-zA-Z0-9_.+-]*(?:[ \t].*)?)$}', $shebang, $match) + || Preg::isMatch(self::UNSAFE_PROXY_CHARS, $match[1]) + ) { + return null; + } + + // "env" is transparent, the interpreter is then its first argument, or the first one after + // the -S which makes env split a multi-word shebang + $caller = Preg::replace('{^env[ \t]+(?:-S[ \t]*)?}', '', $match[1]); + if ($caller === '' || $caller === 'env') { + return null; } - return 'php'; + // other env options, e.g. "env -i bash", need env itself to run + return $caller[0] === '-' ? 'env '.$caller : $caller; + } + + /** + * Checks whether a shebang caller runs a php binary, e.g. "php", "php8.2" or "php -d x=1" + */ + private static function isPhpInterpreter(string $caller): bool + { + return Preg::isMatch('{^php[0-9.]*(?:[ \t]|$)}', $caller); + } + + /** + * Checks that a bin path from a package can be embedded in the generated proxies + * + * These characters cannot occur in a legitimate bin and only serve to make the proxies harder + * to generate safely. Spaces, backslashes and single quotes are deliberately accepted, as + * published packages do use them. Only ever pass the package-relative bin path: the absolute + * one carries the user's own project path, which may well contain e.g. parentheses. + * + * @internal + */ + public static function isSafeBinPath(string $bin): bool + { + return !Preg::isMatch(self::UNSAFE_PROXY_CHARS, $bin); + } + + /** + * Quotes a string so a POSIX shell reads it as a single literal word + * + * ProcessExecutor::escape() cannot be used here as it switches to cmd.exe rules when Composer + * itself runs on Windows, while this proxy is always read by sh, incl. on Windows for Cygwin. + */ + private static function escapeShellArg(string $arg): string + { + return "'".str_replace("'", "'\\''", $arg)."'"; + } + + /** + * Escapes a value for a `SET "VAR="` statement of a generated .bat proxy + * + * Quoting the assignment makes cmd.exe read & | < > ^ ( ) literally, and ! is inert thanks to + * the proxy's DISABLEDELAYEDEXPANSION. Percent expansion runs before quotes are applied though, + * so those must be doubled -- %% collapses back to one % in a batch file, which is what this + * generates, unlike on a command line. + */ + private static function escapeBatchSetValue(string $value): string + { + return str_replace('%', '%%', $value); + } + + /** + * Checks that a path can be represented in a .bat proxy at all + * + * A double quote would end the SET "..." quoting, a line break would start a new batch line, and + * cmd.exe stops reading a batch file at a 0x1A. None can be escaped, and stripping them could + * point the proxy at a different existing file, so such bins are skipped instead. + */ + private static function isRepresentableInBatchProxy(string $path): bool + { + return !Preg::isMatch('{["\r\n\x1a]}', $path); } /** @@ -190,14 +291,14 @@ protected function installFullBinaries(string $binPath, string $link, string $bi } } if (!file_exists($link)) { - file_put_contents($link, $this->generateWindowsProxyCode($binPath, $link)); + Filesystem::safeFilePutContents($link, $this->generateWindowsProxyCode($binPath, $link)); Silencer::call('chmod', $link, 0777 & ~umask()); } } protected function installUnixyProxyBinaries(string $binPath, string $link): void { - file_put_contents($link, $this->generateUnixyProxyCode($binPath, $link)); + Filesystem::safeFilePutContents($link, $this->generateUnixyProxyCode($binPath, $link)); Silencer::call('chmod', $link, 0777 & ~umask()); } @@ -209,24 +310,21 @@ protected function initializeBinDir(): void protected function generateWindowsProxyCode(string $bin, string $link): string { - $binPath = $this->filesystem->findShortestPath($link, $bin); $caller = self::determineBinaryCaller($bin); // if the target is a php file, we run the unixy proxy file // to ensure that _composer_autoload_path gets defined, instead // of running the binary directly - if ($caller === 'php') { - return "@ECHO OFF\r\n". - "setlocal DISABLEDELAYEDEXPANSION\r\n". - "SET BIN_TARGET=%~dp0/".trim(ProcessExecutor::escape(basename($link, '.bat')), '"\'')."\r\n". - "SET COMPOSER_RUNTIME_BIN_DIR=%~dp0\r\n". - "{$caller} \"%BIN_TARGET%\" %*\r\n"; - } + $target = self::isPhpInterpreter($caller) + ? basename($link, '.bat') + : $this->filesystem->findShortestPath($link, $bin); + // quoting the SET statements keeps cmd.exe from acting on & | < > ^ ( ) in either the + // package's bin path or the user's own project path return "@ECHO OFF\r\n". "setlocal DISABLEDELAYEDEXPANSION\r\n". - "SET BIN_TARGET=%~dp0/".trim(ProcessExecutor::escape($binPath), '"\'')."\r\n". - "SET COMPOSER_RUNTIME_BIN_DIR=%~dp0\r\n". + "SET \"BIN_TARGET=%~dp0/".self::escapeBatchSetValue($target)."\"\r\n". + "SET \"COMPOSER_RUNTIME_BIN_DIR=%~dp0\"\r\n". "{$caller} \"%BIN_TARGET%\" %*\r\n"; } @@ -234,16 +332,22 @@ protected function generateUnixyProxyCode(string $bin, string $link): string { $binPath = $this->filesystem->findShortestPath($link, $bin); - $binDir = ProcessExecutor::escape(dirname($binPath)); - $binFile = basename($binPath); + $binDir = self::escapeShellArg(dirname($binPath)); + $binFile = self::escapeShellArg(basename($binPath)); $binContents = (string) file_get_contents($bin, false, null, 0, 500); // For php files, we generate a PHP proxy instead of a shell one, // which allows calling the proxy with a custom php process if (Preg::isMatch('{^(#!.*\r?\n)?[\r\n\t ]*<\?php}', $binContents, $match)) { - // carry over the existing shebang if present, otherwise add our own - $proxyCode = $match[1] === null ? '#!/usr/bin/env php' : trim($match[1]); + // carry over the existing shebang if present and safe to embed, otherwise add our own. + // Only a php one is kept as the proxy body below is PHP. + $shebang = $match[1] === null ? '' : rtrim($match[1], " \t\r\n"); + $caller = self::shebangCaller($shebang); + $proxyCode = $caller !== null && self::isPhpInterpreter($caller) ? $shebang : '#!/usr/bin/env php'; $binPathExported = $this->filesystem->findShortestPathCode($link, $bin, false, true); + // a package controls every segment of its bin paths, and a "*/" in the path below would + // close the docblock it goes into and have the rest of it parsed as PHP code + $binPathComment = str_replace('*/', '* /', str_replace(["\r", "\n"], ' ', $binPath)); $streamProxyCode = $streamHint = ''; $globalsCode = '$GLOBALS[\'_composer_bin_dir\'] = __DIR__;'."\n"; $phpunitHack1 = $phpunitHack2 = ''; @@ -379,7 +483,7 @@ public function url_stat(\$path, \$flags) /** * Proxy PHP file generated by Composer * - * This file includes the referenced bin path ($binPath) + * This file includes the referenced bin path ($binPathComment) *$streamHint * @generated */ @@ -425,12 +529,12 @@ public function url_stat(\$path, \$flags) bashSource="\$BASH_SOURCE" if [ -n "\$bashSource" ]; then if [ "\$bashSource" != "\$0" ]; then - source "\${dir}/$binFile" "\$@" + source "\${dir}/"$binFile "\$@" return fi fi -exec "\${dir}/$binFile" "\$@" +exec "\${dir}/"$binFile "\$@" PROXY; } diff --git a/core/vendor/composer/composer/src/Composer/Installer/InstallationManager.php b/core/vendor/composer/composer/src/Composer/Installer/InstallationManager.php index 3b0b0edfe9..e0c9231361 100644 --- a/core/vendor/composer/composer/src/Composer/Installer/InstallationManager.php +++ b/core/vendor/composer/composer/src/Composer/Installer/InstallationManager.php @@ -12,6 +12,7 @@ namespace Composer\Installer; +use Composer\Console\ProgramStatus; use Composer\IO\IOInterface; use Composer\IO\ConsoleIO; use Composer\Package\PackageInterface; @@ -186,6 +187,9 @@ public function execute(InstalledRepositoryInterface $repo, array $operations, b $signalHandler = SignalHandler::create([SignalHandler::SIGINT, SignalHandler::SIGTERM, SignalHandler::SIGHUP], function (string $signal, SignalHandler $handler) use (&$cleanupPromises) { $this->io->writeError('Received '.$signal.', aborting', true, IOInterface::DEBUG); $this->runCleanup($cleanupPromises); + if ($this->io instanceof ConsoleIO) { + $this->io->writeProgramStatus(ProgramStatus::idle()); + } $handler->exitWithLastSignal(); }); @@ -431,7 +435,14 @@ private function waitOnPromises(array $promises): void ) { $progress = $this->io->getProgressBar(); } - $this->loop->wait($promises, $progress); + $onProgress = $progress !== null && $this->io instanceof ConsoleIO ? [$this->io, 'writeProgress'] : null; + try { + $this->loop->wait($promises, $progress, $onProgress); + } finally { + if ($onProgress !== null) { + $onProgress(null); + } + } if ($progress !== null) { $progress->clear(); // ProgressBar in non-decorated output does not output a final line-break and clear() does nothing diff --git a/core/vendor/composer/composer/src/Composer/Json/JsonFile.php b/core/vendor/composer/composer/src/Composer/Json/JsonFile.php index 205f22242d..1668b0fb21 100644 --- a/core/vendor/composer/composer/src/Composer/Json/JsonFile.php +++ b/core/vendor/composer/composer/src/Composer/Json/JsonFile.php @@ -46,6 +46,9 @@ class JsonFile public const INDENT_DEFAULT = ' '; + /** @internal */ + public const CONFLICTED_CONTENT_HASH = 'VCS merge conflict detected. Please run `composer update --lock`.'; + /** @var string */ private $path; /** @var ?HttpDownloader */ @@ -182,7 +185,7 @@ private function filePutContentsIfModified(string $path, string $content) { $currentContent = @file_get_contents($path); if (false === $currentContent || $currentContent !== $content) { - return file_put_contents($path, $content); + return Filesystem::safeFilePutContents($path, $content); } return 0; @@ -347,7 +350,7 @@ public static function parseJson(?string $json, ?string $file = null) if ($file !== null && str_ends_with($file, '.lock') && str_contains($json, '"content-hash"')) { $replaced = Preg::replace( '{\r?\n<<<<<<< [^\r\n]+\r?\n\s+"content-hash": *"[0-9a-f]+", *\r?\n(?:\|{7} [^\r\n]+\r?\n\s+"content-hash": *"[0-9a-f]+", *\r?\n)?=======\r?\n\s+"content-hash": *"[0-9a-f]+", *\r?\n>>>>>>> [^\r\n]+(\r?\n)}', - ' "content-hash": "VCS merge conflict detected. Please run `composer update --lock`.",$1', + ' "content-hash": "'.self::CONFLICTED_CONTENT_HASH.'",$1', $json, 1, $count diff --git a/core/vendor/composer/composer/src/Composer/Json/JsonManipulator.php b/core/vendor/composer/composer/src/Composer/Json/JsonManipulator.php index 10cde2585e..5d3799411b 100644 --- a/core/vendor/composer/composer/src/Composer/Json/JsonManipulator.php +++ b/core/vendor/composer/composer/src/Composer/Json/JsonManipulator.php @@ -119,7 +119,7 @@ public function addLink(string $type, string $package, string $constraint, bool /** * Sorts packages by importance (platform packages first, then PHP dependencies) and alphabetically. * - * @link https://getcomposer.org/doc/02-libraries.md#platform-packages + * @link https://getcomposer.org/doc/01-basic-usage.md#platform-packages * * @param array $packages */ @@ -531,20 +531,26 @@ public function addSubNode(string $mainNode, string $name, $value, bool $append } // child exists - $childRegex = '{'.self::DEFINES.'(?P"'.preg_quote($name).'"\s*:\s*)(?P(?&json))(?P,?)}x'; - if (Preg::isMatch($childRegex, $children, $matches)) { - $children = Preg::replaceCallback($childRegex, function ($matches) use ($subName, $value): string { - if ($subName !== null && is_string($matches['content'])) { - $curVal = json_decode($matches['content'], true); - if (!is_array($curVal)) { - $curVal = []; - } - $curVal[$subName] = $value; - $value = $curVal; + if (is_array($decoded[$mainNode]) && array_key_exists($name, $decoded[$mainNode])) { + $matches = $this->matchChild($name, $children); + if ($matches === null) { + return false; + } + assert(is_string($matches[0])); + assert(is_string($matches['start'])); + assert(is_string($matches['content'])); + assert(is_string($matches['end'])); + + if ($subName !== null) { + $curVal = json_decode($matches['content'], true); + if (!is_array($curVal)) { + $curVal = []; } + $curVal[$subName] = $value; + $value = $curVal; + } - return $matches['start'] . $this->format($value, 1) . $matches['end']; - }, $children); + $children = $matches['start'] . $this->format($value, 1) . $matches['end'] . substr($children, strlen($matches[0])); } elseif (Preg::isMatch('#^\{(?P\s*?)(?P\S+.*?)?(?P\s*)\}$#s', $children, $match)) { $whitespace = $match['trailingspace']; if (null !== $match['content']) { @@ -630,32 +636,20 @@ public function removeSubNode(string $mainNode, string $name): bool return true; } - // try and find a match for the subkey - $keyRegex = str_replace('/', '\\\\?/', preg_quote($name)); - if (Preg::isMatch('{"'.$keyRegex.'"\s*:}i', $children)) { - // find best match for the value of "name" - if (Preg::isMatchAll('{'.self::DEFINES.'"'.$keyRegex.'"\s*:\s*(?:(?&json))}x', $children, $matches)) { - $bestMatch = ''; - foreach ($matches[0] as $match) { - assert(is_string($match)); - if (strlen($bestMatch) < strlen($match)) { - $bestMatch = $match; - } - } - $childrenClean = Preg::replace('{,\s*'.preg_quote($bestMatch).'}i', '', $children, -1, $count); - if (1 !== $count) { - $childrenClean = Preg::replace('{'.preg_quote($bestMatch).'\s*,?\s*}i', '', $childrenClean, -1, $count); - if (1 !== $count) { - return false; - } - } - } - } else { - $childrenClean = $children; + $matches = $this->matchChild($name, $children); + if ($matches === null) { + return false; } + assert(is_string($matches[0])); + assert(is_string($matches['prefix'])); + assert(is_string($matches['end'])); - if (!isset($childrenClean)) { - throw new \InvalidArgumentException("JsonManipulator: \$childrenClean is not defined. Please report at https://github.com/composer/composer/issues/new."); + // drop the pair along with the comma before it, or after it if it is the first pair + $rest = substr($children, strlen($matches[0]) - strlen($matches['end'])); + if (Preg::isMatch('{,\s*$}', $matches['prefix'])) { + $childrenClean = Preg::replace('{,\s*$}', '', $matches['prefix']) . $rest; + } else { + $childrenClean = $matches['prefix'] . Preg::replace('{^\s*,?\s*}', '', $rest); } // no child data left, $name was the only key in @@ -1042,6 +1036,33 @@ public function removeMainKeyIfEmpty(string $key): bool return true; } + /** + * Matches the top-level pair for $name in an object, skipping nested keys with the same name + * + * @return array|null null if the pair could not be matched + */ + private function matchChild(string $name, string $children): ?array + { + // spaces are escaped for the x modifier, slashes may have been written escaped + $key = str_replace([' ', '/'], ['\\ ', '\\\\?/'], preg_quote(JsonFile::encode($name))); + + // possessive loop over the other pairs, a lazy one backtracks exponentially when the key is missing + $childRegex = '{'.self::DEFINES.'^(?P(?P\{ \s* (?: (?!'.$key.'\s*:) (?&string) \s* : (?&json) \s* , \s* )*+) '.$key.'\s*:\s*)(?P(?&json))(?P,?)}x'; + + try { + if (!Preg::isMatch($childRegex, $children, $matches)) { + return null; + } + } catch (\RuntimeException $e) { + if (in_array($e->getCode(), [PREG_BACKTRACK_LIMIT_ERROR, PREG_JIT_STACKLIMIT_ERROR], true)) { + return null; + } + throw $e; + } + + return $matches; + } + /** * @param mixed $data */ diff --git a/core/vendor/composer/composer/src/Composer/Package/AliasPackage.php b/core/vendor/composer/composer/src/Composer/Package/AliasPackage.php index 932ea3658a..79bc16f776 100644 --- a/core/vendor/composer/composer/src/Composer/Package/AliasPackage.php +++ b/core/vendor/composer/composer/src/Composer/Package/AliasPackage.php @@ -361,6 +361,11 @@ public function getReleaseDate(): ?\DateTimeInterface return $this->aliasOf->getReleaseDate(); } + public function getPublishedDate(): ?\DateTimeImmutable + { + return $this->aliasOf->getPublishedDate(); + } + public function getBinaries(): array { return $this->aliasOf->getBinaries(); diff --git a/core/vendor/composer/composer/src/Composer/Package/Dumper/ArrayDumper.php b/core/vendor/composer/composer/src/Composer/Package/Dumper/ArrayDumper.php index 9333bd9a01..9ff0f0735d 100644 --- a/core/vendor/composer/composer/src/Composer/Package/Dumper/ArrayDumper.php +++ b/core/vendor/composer/composer/src/Composer/Package/Dumper/ArrayDumper.php @@ -95,6 +95,10 @@ public function dump(PackageInterface $package): array $data['time'] = $package->getReleaseDate()->format(DATE_RFC3339); } + if ($package->getPublishedDate() instanceof \DateTimeInterface) { + $data['published-time'] = $package->getPublishedDate()->format(DATE_RFC3339); + } + if ($package->isDefaultBranch()) { $data['default-branch'] = true; } diff --git a/core/vendor/composer/composer/src/Composer/Package/Loader/ArrayLoader.php b/core/vendor/composer/composer/src/Composer/Package/Loader/ArrayLoader.php index da6e9e424e..ffa97314a8 100644 --- a/core/vendor/composer/composer/src/Composer/Package/Loader/ArrayLoader.php +++ b/core/vendor/composer/composer/src/Composer/Package/Loader/ArrayLoader.php @@ -29,6 +29,15 @@ */ class ArrayLoader implements LoaderInterface { + /** config key => [Link::TYPE_*, setter], matching BasePackage::$supportedLinkTypes */ + private const LINK_TYPES = [ + 'require' => [Link::TYPE_REQUIRE, 'setRequires'], + 'conflict' => [Link::TYPE_CONFLICT, 'setConflicts'], + 'provide' => [Link::TYPE_PROVIDE, 'setProvides'], + 'replace' => [Link::TYPE_REPLACE, 'setReplaces'], + 'require-dev' => [Link::TYPE_DEV_REQUIRE, 'setDevRequires'], + ]; + /** @var VersionParser */ protected $versionParser; /** @var bool */ @@ -54,16 +63,15 @@ public function load(array $config, string $class = 'Composer\Package\CompletePa $package = $this->createObject($config, $class); - foreach (BasePackage::$supportedLinkTypes as $type => $opts) { + foreach (self::LINK_TYPES as $type => [$linkType, $method]) { if (!isset($config[$type]) || !is_array($config[$type])) { continue; } - $method = 'set'.ucfirst($opts['method']); $package->{$method}( $this->parseLinks( $package->getName(), $package->getPrettyVersion(), - $opts['method'], + $linkType, $config[$type] ) ); @@ -240,14 +248,16 @@ private function configureObject(PackageInterface $package, array $config): Base $package->setPhpExt($config['php-ext']); } - if (!empty($config['time'])) { - $time = Preg::isMatch('/^\d++$/D', $config['time']) ? '@'.$config['time'] : $config['time']; + // repositories may send timestamps as JSON numbers, which must not crash the loader + if (!empty($config['time']) && (is_int($config['time']) || is_string($config['time']))) { + $package->setReleaseDateString((string) $config['time']); + } - try { - $date = new \DateTime($time, new \DateTimeZone('UTC')); - $package->setReleaseDate($date); - } catch (\Exception $e) { - } + // Server-set publication timestamp, owned by the repository and not + // overridable by the package author (unlike `time`). Preferred by the + // cooldown policy when present + if (isset($config['published-time']) && (is_int($config['published-time']) || (is_string($config['published-time']) && '' !== $config['published-time']))) { + $package->setPublishedDateString((string) $config['published-time']); } if (!empty($config['notification-url'])) { @@ -333,10 +343,8 @@ private function configureCachedLinks(array &$linkCache, PackageInterface $packa $name = $package->getName(); $prettyVersion = $package->getPrettyVersion(); - foreach (BasePackage::$supportedLinkTypes as $type => $opts) { + foreach (self::LINK_TYPES as $type => [$linkType, $method]) { if (isset($config[$type])) { - $method = 'set'.ucfirst($opts['method']); - $links = []; foreach ($config[$type] as $prettyTarget => $constraint) { $target = strtolower($prettyTarget); @@ -347,10 +355,10 @@ private function configureCachedLinks(array &$linkCache, PackageInterface $packa } if ($constraint === 'self.version') { - $links[$target] = $this->createLink($name, $prettyVersion, $opts['method'], $target, $constraint); + $links[$target] = $this->createLink($name, $prettyVersion, $linkType, $target, $constraint); } else { if (!isset($linkCache[$name][$type][$target][$constraint])) { - $linkCache[$name][$type][$target][$constraint] = [$target, $this->createLink($name, $prettyVersion, $opts['method'], $target, $constraint)]; + $linkCache[$name][$type][$target][$constraint] = [$target, $this->createLink($name, $prettyVersion, $linkType, $target, $constraint)]; } [$target, $link] = $linkCache[$name][$type][$target][$constraint]; diff --git a/core/vendor/composer/composer/src/Composer/Package/Loader/ValidatingArrayLoader.php b/core/vendor/composer/composer/src/Composer/Package/Loader/ValidatingArrayLoader.php index a0350fc05b..f3d60cff25 100644 --- a/core/vendor/composer/composer/src/Composer/Package/Loader/ValidatingArrayLoader.php +++ b/core/vendor/composer/composer/src/Composer/Package/Loader/ValidatingArrayLoader.php @@ -13,6 +13,7 @@ namespace Composer\Package\Loader; use Composer\Exception\SecurityException; +use Composer\Installer\BinaryInstaller; use Composer\Package\BasePackage; use Composer\Package\PackageInterface; use Composer\Package\RootPackageInterface; @@ -118,17 +119,15 @@ public function load(array $config, string $class = 'Composer\Package\CompletePa } else { $this->validateFlatArray('bin'); } - // A ".." path segment in a bin escapes the package install directory and lets the - // package chmod/point at an arbitrary host file during install (GHSA-gjfg-22fp-rrxx). if (isset($this->config['bin']) && is_string($this->config['bin'])) { - if (Preg::isMatch('{(?:^|[\\\\/])\.\.(?:[\\\\/]|$)}', $this->config['bin'])) { - $this->errors[] = 'bin : invalid value ('.$this->config['bin'].'), must not contain a ".." path component'; + if (null !== $error = self::findBinError($this->config['bin'])) { + $this->errors[] = 'bin : invalid value ('.$this->config['bin'].'), '.$error; unset($this->config['bin']); } } elseif (isset($this->config['bin']) && is_array($this->config['bin'])) { foreach ($this->config['bin'] as $key => $bin) { - if (is_string($bin) && Preg::isMatch('{(?:^|[\\\\/])\.\.(?:[\\\\/]|$)}', $bin)) { - $this->errors[] = 'bin.'.$key.' : invalid value ('.$bin.'), must not contain a ".." path component'; + if (is_string($bin) && null !== $error = self::findBinError($bin)) { + $this->errors[] = 'bin.'.$key.' : invalid value ('.$bin.'), '.$error; unset($this->config['bin'][$key]); } } @@ -727,6 +726,27 @@ public static function validatePackage(PackageInterface $package): void } } + /** + * Validates a bin path, returning the reason it is unacceptable or null if it is fine + * + * A ".." segment escapes the package install directory and lets the package chmod/point at an + * arbitrary host file during install (GHSA-gjfg-22fp-rrxx). The character check is the same one + * BinaryInstaller applies before generating a proxy, done here as well so that packagist.org + * and composer validate flag such a bin at the source. + */ + private static function findBinError(string $bin): ?string + { + if (Preg::isMatch('{(?:^|[\\\\/])\.\.(?:[\\\\/]|$)}', $bin)) { + return 'must not contain a ".." path component'; + } + + if (!BinaryInstaller::isSafeBinPath($bin)) { + return 'must not contain any of the characters *$`"&^|<>()%!; nor control characters'; + } + + return null; + } + /** * @phpstan-param non-empty-string $property * @phpstan-param non-empty-string $regex diff --git a/core/vendor/composer/composer/src/Composer/Package/Locker.php b/core/vendor/composer/composer/src/Composer/Package/Locker.php index 900273f9dc..470902f2da 100644 --- a/core/vendor/composer/composer/src/Composer/Package/Locker.php +++ b/core/vendor/composer/composer/src/Composer/Package/Locker.php @@ -18,6 +18,7 @@ use Composer\Repository\InstalledRepository; use Composer\Repository\LockArrayRepository; use Composer\Repository\PlatformRepository; +use Composer\Repository\RepositoryManager; use Composer\Repository\RootPackageRepository; use Composer\Util\ProcessExecutor; use Composer\Package\Dumper\ArrayDumper; @@ -36,6 +37,8 @@ */ class Locker { + /** @var IOInterface */ + private $io; /** @var JsonFile */ private $lockFile; /** @var InstallationManager */ @@ -54,6 +57,8 @@ class Locker private $lockDataCache = null; /** @var bool */ private $virtualFileWritten = false; + /** @var ?RepositoryManager */ + private $repositoryManager = null; /** * Initializes packages locker. @@ -64,6 +69,7 @@ class Locker */ public function __construct(IOInterface $io, JsonFile $lockFile, InstallationManager $installationManager, string $composerFileContents, ?ProcessExecutor $process = null) { + $this->io = $io; $this->lockFile = $lockFile; $this->installationManager = $installationManager; $this->hash = hash('md5', $composerFileContents); @@ -73,6 +79,16 @@ public function __construct(IOInterface $io, JsonFile $lockFile, InstallationMan $this->process = $process ?? new ProcessExecutor($io); } + /** + * Enables keeping http headers out of the lock file, they are restored from the repository config when loading it + * + * @internal + */ + public function setRepositoryManager(RepositoryManager $repositoryManager): void + { + $this->repositoryManager = $repositoryManager; + } + /** * @internal */ @@ -112,6 +128,10 @@ public static function getContentHash(string $composerFileContents): string if (isset($content['config']['platform'])) { $relevantContent['config']['platform'] = $content['config']['platform']; } + // policy changes which versions an update may pick, so changing it should flag the lock as outdated + if (isset($content['config']['policy'])) { + $relevantContent['config']['policy'] = $content['config']['policy']; + } ksort($relevantContent); @@ -181,6 +201,9 @@ public function getLockedRepository(bool $withDevReqs = false): LockArrayReposit $packageByName = []; foreach ($lockedPackages as $info) { $package = $this->loader->load($info); + if ($this->repositoryManager !== null) { + $this->repositoryManager->restoreHttpHeaders($package); + } $packages->addPackage($package); $packageByName[$package->getName()] = $package; @@ -479,6 +502,7 @@ private function fixupJsonDataType(array $lockData): array private function lockPackages(array $packages): array { $locked = []; + $packagesWithHeaders = []; foreach ($packages as $package) { if ($package instanceof AliasPackage) { @@ -498,9 +522,10 @@ private function lockPackages(array $packages): array $spec = $this->dumper->dump($package); unset($spec['version_normalized']); - // always move time to the end of the package definition + // always move time and published-time to the end of the package definition $time = $spec['time'] ?? null; - unset($spec['time']); + $publishedTime = $spec['published-time'] ?? null; + unset($spec['time'], $spec['published-time']); if ($package->isDev() && $package->getInstallationSource() === 'source') { // use the exact commit time of the current reference if it's a dev package $time = $this->getPackageTime($package) ?: $time; @@ -508,12 +533,26 @@ private function lockPackages(array $packages): array if (null !== $time) { $spec['time'] = $time; } + if (null !== $publishedTime) { + $spec['published-time'] = $publishedTime; + } unset($spec['installation-source']); + if ($this->repositoryManager !== null) { + $spec = $this->repositoryManager->removeRestorableHttpHeaders($package, $spec); + } + if (isset($spec['transport-options']['http']['header'])) { + $packagesWithHeaders[] = $name; + } + $locked[] = $spec; } + if (\count($packagesWithHeaders) > 0) { + $this->io->writeError('The transport options of '.implode(', ', $packagesWithHeaders).' contain http headers which will be stored in composer.lock. If they contain credentials, configure them in auth.json or on a composer repository\'s options instead.'); + } + usort($locked, static function ($a, $b) { $comparison = strcmp($a['name'], $b['name']); @@ -625,7 +664,7 @@ public function getMissingRequirementInfo(RootPackageInterface $package, bool $i if ($missingRequirements) { $missingRequirementInfo[] = 'This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.'; $missingRequirementInfo[] = 'Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md'; - $missingRequirementInfo[] = 'and prefer using the "require" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r'; + $missingRequirementInfo[] = 'and prefer using the "require" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require--r'; } return $missingRequirementInfo; diff --git a/core/vendor/composer/composer/src/Composer/Package/Package.php b/core/vendor/composer/composer/src/Composer/Package/Package.php index 0ed5da03ab..4b95501084 100644 --- a/core/vendor/composer/composer/src/Composer/Package/Package.php +++ b/core/vendor/composer/composer/src/Composer/Package/Package.php @@ -57,6 +57,14 @@ class Package extends BasePackage protected $prettyVersion; /** @var ?\DateTimeInterface */ protected $releaseDate; + /** @var ?\DateTimeImmutable */ + protected $publishedDate; + /** @var ?string unparsed `time`, parsed on first getReleaseDate() call */ + private $releaseDateRaw; + /** @var ?string unparsed `published-time`, parsed on first getPublishedDate() call */ + private $publishedDateRaw; + /** @var ?\DateTimeZone */ + private static $utc; /** @var mixed[] */ protected $extra = []; /** @var string[] */ @@ -385,6 +393,18 @@ public function getPrettyVersion(): string public function setReleaseDate(?\DateTimeInterface $releaseDate): void { $this->releaseDate = $releaseDate; + $this->releaseDateRaw = null; + } + + /** + * Sets the release date from a raw string, parsed lazily as most loaded packages never need it + * + * @internal + */ + public function setReleaseDateString(string $releaseDate): void + { + $this->releaseDate = null; + $this->releaseDateRaw = $releaseDate; } /** @@ -392,9 +412,60 @@ public function setReleaseDate(?\DateTimeInterface $releaseDate): void */ public function getReleaseDate(): ?\DateTimeInterface { + if (null !== $this->releaseDateRaw) { + try { + $this->releaseDate = new \DateTime(self::prepareDateString($this->releaseDateRaw), self::getUtc()); + } catch (\Exception $e) { + } + $this->releaseDateRaw = null; + } + return $this->releaseDate; } + public function setPublishedDate(?\DateTimeImmutable $publishedDate): void + { + $this->publishedDate = $publishedDate; + $this->publishedDateRaw = null; + } + + /** + * Sets the published date from a raw string, parsed lazily as most loaded packages never need it + * + * @internal + */ + public function setPublishedDateString(string $publishedDate): void + { + $this->publishedDate = null; + $this->publishedDateRaw = $publishedDate; + } + + /** + * @inheritDoc + */ + public function getPublishedDate(): ?\DateTimeImmutable + { + if (null !== $this->publishedDateRaw) { + try { + $this->publishedDate = new \DateTimeImmutable(self::prepareDateString($this->publishedDateRaw), self::getUtc()); + } catch (\Exception $e) { + } + $this->publishedDateRaw = null; + } + + return $this->publishedDate; + } + + private static function prepareDateString(string $date): string + { + return ctype_digit($date) ? '@'.$date : $date; + } + + private static function getUtc(): \DateTimeZone + { + return self::$utc ?? self::$utc = new \DateTimeZone('UTC'); + } + /** * Set the required packages * diff --git a/core/vendor/composer/composer/src/Composer/Package/PackageInterface.php b/core/vendor/composer/composer/src/Composer/Package/PackageInterface.php index 0bfece430d..e198a9e4ec 100644 --- a/core/vendor/composer/composer/src/Composer/Package/PackageInterface.php +++ b/core/vendor/composer/composer/src/Composer/Package/PackageInterface.php @@ -228,6 +228,15 @@ public function getFullPrettyVersion(bool $truncate = true, int $displayMode = s */ public function getReleaseDate(): ?\DateTimeInterface; + /** + * Returns the publication date of the package as set by the repository. + * + * Unlike getReleaseDate() (which comes from the author-controlled `time` + * field), this is a server-set timestamp the package author cannot + * influence, or null if the repository did not provide one. + */ + public function getPublishedDate(): ?\DateTimeImmutable; + /** * Returns the stability of this package: one of (dev, alpha, beta, RC, stable) * diff --git a/core/vendor/composer/composer/src/Composer/Package/Version/VersionBumper.php b/core/vendor/composer/composer/src/Composer/Package/Version/VersionBumper.php index d6265b55cd..010ecaad79 100644 --- a/core/vendor/composer/composer/src/Composer/Package/Version/VersionBumper.php +++ b/core/vendor/composer/composer/src/Composer/Package/Version/VersionBumper.php @@ -88,7 +88,7 @@ public function bumpRequirement(ConstraintInterface $constraint, PackageInterfac \^v?'.$major.'(?:\.\d+)* # e.g. ^2.anything | ~v?'.$major.'(?:\.\d+){1,3} # e.g. ~2.2 or ~2.2.2 or ~2.2.2.2 | v?'.$major.'(?:\.[*x])+ # e.g. 2.* or 2.*.* or 2.x.x.x etc - | >=v?\d(?:\.\d+)* # e.g. >=2 or >=1.2 etc + | >=v?\d+(?:\.\d+)* # e.g. >=2 or >=1.2 etc | \* # full wildcard ) (?=,|$|\ |\||@) # trailing separator @@ -97,6 +97,17 @@ public function bumpRequirement(ConstraintInterface $constraint, PackageInterfac $modified = $prettyConstraint; foreach (array_reverse($matches['constraint']) as $match) { assert(is_string($match[0])); + // only bump a lower bound whose own AND part contains the installed version, as other ranges of the constraint would be corrupted + if (str_starts_with($match[0], '>=')) { + $partStart = strrpos(substr($prettyConstraint, 0, $match[1]), '|'); + $partStart = $partStart === false ? 0 : $partStart + 1; + $partEnd = strpos($prettyConstraint, '|', $match[1]); + $partEnd = $partEnd === false ? Platform::strlen($prettyConstraint) : $partEnd; + $part = trim(substr($prettyConstraint, $partStart, $partEnd - $partStart)); + if (!$parser->parseConstraints($part)->matches(new Constraint('==', $version))) { + continue; + } + } $suffix = ''; if (substr_count($match[0], '.') === 2 && substr_count($versionWithoutSuffix, '.') === 1) { $suffix = '.0'; diff --git a/core/vendor/composer/composer/src/Composer/Package/Version/VersionSelector.php b/core/vendor/composer/composer/src/Composer/Package/Version/VersionSelector.php index 520c1401fd..02147ce2da 100644 --- a/core/vendor/composer/composer/src/Composer/Package/Version/VersionSelector.php +++ b/core/vendor/composer/composer/src/Composer/Package/Version/VersionSelector.php @@ -24,6 +24,8 @@ use Composer\Package\Loader\ArrayLoader; use Composer\Package\Dumper\ArrayDumper; use Composer\Pcre\Preg; +use Composer\Policy\CooldownPolicyConfig; +use Composer\DependencyResolver\PolicyRemovalReason; use Composer\Repository\RepositorySet; use Composer\Repository\PlatformRepository; use Composer\Semver\Constraint\Constraint; @@ -46,12 +48,26 @@ class VersionSelector /** @var VersionParser */ private $parser; + /** @var ?CooldownPolicyConfig */ + private $cooldown; + + /** @var ?array{package: PackageInterface, releaseDate: \DateTimeInterface} */ + private $firstClearingWithheldCandidate = null; + + /** @var list */ + private $withheldCandidates = []; + + /** @var list */ + private $platformRejectedCandidates = []; + /** - * @param PlatformRepository $platformRepo If passed in, the versions found will be filtered against their requirements to eliminate any not matching the current platform packages + * @param PlatformRepository $platformRepo If passed in, the versions found will be filtered against their requirements to eliminate any not matching the current platform packages + * @param CooldownPolicyConfig $cooldown If passed in, versions still within the configured cooldown are skipped */ - public function __construct(RepositorySet $repositorySet, ?PlatformRepository $platformRepo = null) + public function __construct(RepositorySet $repositorySet, ?PlatformRepository $platformRepo = null, ?CooldownPolicyConfig $cooldown = null) { $this->repositorySet = $repositorySet; + $this->cooldown = $cooldown; if ($platformRepo) { foreach ($platformRepo->getPackages() as $package) { $this->platformConstraints[$package->getName()][] = new Constraint('==', $package->getVersion()); @@ -112,6 +128,13 @@ public function findBestCandidate(string $packageName, ?string $targetPackageVer return version_compare($b->getVersion(), $a->getVersion()); }); + $this->firstClearingWithheldCandidate = null; + $this->withheldCandidates = []; + $this->platformRejectedCandidates = []; + if ($this->cooldown !== null && $this->cooldown->hasCooldown() && $this->cooldown->block) { + $candidates = $this->filterCooldownCandidates($this->cooldown, $candidates, $io, $showWarnings); + } + if (count($this->platformConstraints) > 0 && !($platformRequirementFilter instanceof IgnoreAllPlatformRequirementFilter)) { /** @var array $alreadyWarnedNames */ $alreadyWarnedNames = []; @@ -166,6 +189,7 @@ public function findBestCandidate(string $packageName, ?string $targetPackageVer } if ($skip) { + $this->platformRejectedCandidates[] = $pkg; continue; } @@ -188,6 +212,103 @@ public function findBestCandidate(string $packageName, ?string $targetPackageVer return $package; } + /** + * Returns the candidate withheld by the cooldown in the last findBestCandidate() call that becomes available first + * + * @return ?array{package: PackageInterface, releaseDate: \DateTimeInterface} + * @internal + */ + public function getFirstClearingWithheldCandidate(): ?array + { + return $this->firstClearingWithheldCandidate; + } + + /** + * Returns every candidate withheld by the cooldown in the last findBestCandidate() call, in candidate order + * + * @return list + * @internal + */ + public function getWithheldCandidates(): array + { + return $this->withheldCandidates; + } + + /** + * Returns every candidate skipped for its platform requirements in the last findBestCandidate() call, in candidate order + * + * @return list + * @internal + */ + public function getPlatformRejectedCandidates(): array + { + return $this->platformRejectedCandidates; + } + + /** + * Drops candidates still inside the cooldown so the selected version is one the solver will accept + * + * @param PackageInterface[] $candidates + * @param callable(PackageInterface):bool|bool $showWarnings + * @return PackageInterface[] + */ + private function filterCooldownCandidates(CooldownPolicyConfig $cooldown, array $candidates, ?IOInterface $io, $showWarnings): array + { + $now = new \DateTimeImmutable(); + /** @var array $alreadySeenNames */ + $alreadySeenNames = []; + /** @var array $acceptedNames */ + $acceptedNames = []; + $result = []; + + foreach ($candidates as $pkg) { + // decide on the aliased package, as the pool filter does + $target = $pkg instanceof AliasPackage ? $pkg->getAliasOf() : $pkg; + if ($target->isDev() || $cooldown->isIgnored($target, 'block')) { + $result[] = $pkg; + $acceptedNames[$pkg->getName()] = true; + continue; + } + + $cooldown->assertPublishedTimeProvided($target); + $releaseDate = $cooldown->getEffectiveDate($target); + if ($releaseDate === null || !$cooldown->isWithinCooldown($releaseDate, $now)) { + $result[] = $pkg; + $acceptedNames[$pkg->getName()] = true; + continue; + } + + if ($this->firstClearingWithheldCandidate === null || $releaseDate < $this->firstClearingWithheldCandidate['releaseDate']) { + $this->firstClearingWithheldCandidate = ['package' => $pkg, 'releaseDate' => $releaseDate]; + } + $this->withheldCandidates[] = ['package' => $pkg, 'releaseDate' => $releaseDate]; + + // ranked below a usable candidate (e.g. an LTS backport), so withholding it does not affect the pick + if (isset($acceptedNames[$pkg->getName()])) { + continue; + } + + $isLatestVersion = !isset($alreadySeenNames[$pkg->getName()]); + $alreadySeenNames[$pkg->getName()] = true; + if ($io !== null && ($showWarnings === true || (is_callable($showWarnings) && $showWarnings($pkg)))) { + $reason = PolicyRemovalReason::cooldown( + $pkg->getName(), + $pkg->getPrettyVersion(), + $releaseDate->format(\DateTimeInterface::ATOM), + $cooldown->formatTimeUntilAvailable($releaseDate, $now), + $target->getPublishedDate() !== null ? 'published-time' : 'time' + ); + $io->writeError( + 'Cannot use '.$pkg->getPrettyName().($isLatestVersion ? "'s latest version" : '').' '.$pkg->getPrettyVersion().' as it '.$reason->getVerb(false).' '.$reason->getDescription().'.', + true, + $isLatestVersion ? IOInterface::NORMAL : IOInterface::VERBOSE + ); + } + } + + return $result; + } + /** * Given a concrete version, this returns a ^ constraint (when possible) * that should be used, for example, in composer.json. diff --git a/core/vendor/composer/composer/src/Composer/Plugin/PluginInterface.php b/core/vendor/composer/composer/src/Composer/Plugin/PluginInterface.php index 8bf3879f20..22f1cab19e 100644 --- a/core/vendor/composer/composer/src/Composer/Plugin/PluginInterface.php +++ b/core/vendor/composer/composer/src/Composer/Plugin/PluginInterface.php @@ -32,7 +32,7 @@ interface PluginInterface * * @var string */ - public const PLUGIN_API_VERSION = '2.9.0'; + public const PLUGIN_API_VERSION = '2.11.0'; /** * Apply plugin modifications to Composer diff --git a/core/vendor/composer/composer/src/Composer/Plugin/PluginManager.php b/core/vendor/composer/composer/src/Composer/Plugin/PluginManager.php index 37244aa03a..02c1824c6c 100644 --- a/core/vendor/composer/composer/src/Composer/Plugin/PluginManager.php +++ b/core/vendor/composer/composer/src/Composer/Plugin/PluginManager.php @@ -646,17 +646,15 @@ public function getPluginCapability(PluginInterface $plugin, $capabilityClassNam throw new \RuntimeException("Cannot instantiate Capability, as class $capabilityClass from plugin ".get_class($plugin)." does not exist."); } - $ctorArgs['plugin'] = $plugin; - $capabilityObj = new $capabilityClass($ctorArgs); - - // FIXME these could use is_a and do the check *before* instantiating once drop support for php<5.3.9 - if (!$capabilityObj instanceof Capability || !$capabilityObj instanceof $capabilityClassName) { + if (!is_a($capabilityClass, Capability::class, true) || !is_a($capabilityClass, $capabilityClassName, true)) { throw new \RuntimeException( 'Class ' . $capabilityClass . ' must implement both Composer\Plugin\Capability\Capability and '. $capabilityClassName . '.' ); } - return $capabilityObj; + $ctorArgs['plugin'] = $plugin; + + return new $capabilityClass($ctorArgs); } return null; diff --git a/core/vendor/composer/composer/src/Composer/Policy/CooldownPolicyConfig.php b/core/vendor/composer/composer/src/Composer/Policy/CooldownPolicyConfig.php new file mode 100644 index 0000000000..c2e757820d --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Policy/CooldownPolicyConfig.php @@ -0,0 +1,346 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Policy; + +use Composer\Package\PackageInterface; +use Composer\Pcre\Preg; +use Composer\Repository\ComposerRepository; +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\VersionParser; +use Composer\Util\Platform; +use DateTimeImmutable; +use DateTimeInterface; + +/** + * Configuration for the cooldown policy. + * + * A cooldown withholds package versions whose publication is more recent than + * the configured `period` during update/require, to reduce exposure to a + * compromised-maintainer release. On top of the shared list skeleton + * (block/audit/ignore), it carries the cooldown duration in `period`. + * + * @internal + * @final + * @readonly + */ +class CooldownPolicyConfig extends ListPolicyConfig +{ + public const NAME = 'cooldown'; + + /** + * Cooldown duration in seconds. null means no cooldown is configured (feature inactive). + * + * @var int|null + */ + public $period; + + /** + * @param array> $ignore + * @param self::AUDIT_* $audit + */ + public function __construct( + bool $block, + string $audit, + array $ignore, + ?int $period + ) { + parent::__construct( + self::NAME, + $block, + $audit, + $ignore + ); + + $this->period = $period; + } + + /** + * Whether a cooldown duration is actually configured. Without one, the + * policy is inert regardless of the `block` flag. + */ + public function hasCooldown(): bool + { + return $this->period !== null && $this->period > 0; + } + + /** + * @param self::BLOCK_SCOPE_* $blockScope + */ + public function shouldBlock(string $blockScope): bool + { + if (!$this->hasCooldown()) { + return false; + } + + return parent::shouldBlock($blockScope); + } + + /** + * The timestamp the cooldown is measured against. + * + * Prefers the server-set publication date (which the package author cannot + * influence) and falls back to the author-controlled release date when the + * repository does not provide one. + */ + public function getEffectiveDate(PackageInterface $package): ?DateTimeInterface + { + return $package->getPublishedDate() ?? $package->getReleaseDate(); + } + + /** + * Refuses to apply the cooldown to a version whose Composer repository provides no publication + * date, as the cooldown then cannot tell whether the version is new or old. + * + * @throws \RuntimeException + */ + public function assertPublishedTimeProvided(PackageInterface $package): void + { + if ($package->getPublishedDate() !== null) { + return; + } + + $repository = $package->getRepository(); + if (!$repository instanceof ComposerRepository || !$repository->requiresPublishedTime()) { + return; + } + + throw new \RuntimeException(sprintf( + '%s %s from %s has no published-time, so the cooldown policy configured in "policy.cooldown" cannot tell whether it is new or old. Make sure the repository provides publication times (run "composer clear-cache" if it only recently started to), or set "require-published-time": false on that repository to fall back to the package-supplied "time" field, which offers weaker protection.', + $package->getPrettyName(), + $package->getPrettyVersion(), + $repository->getRepoName() + )); + } + + /** + * Whether a package version matches a configured policy.cooldown.ignore rule + * for the given operation. + * + * @param 'block'|'audit' $operation + */ + public function isIgnored(PackageInterface $package, string $operation): bool + { + $ignore = $this->getIgnoreForOperation($operation); + if (count($ignore) === 0) { + return false; + } + + $packageConstraint = new Constraint('==', $package->getVersion()); + foreach ($ignore as $rules) { + foreach ($rules as $rule) { + // only the package's own name, so declaring a replace of an ignored name cannot skip the cooldown + if (Preg::isMatch($rule->packageNameRegex, $package->getName()) && $rule->constraint->matches($packageConstraint)) { + return true; + } + } + } + + return false; + } + + /** + * Whether the given effective date is still inside the cooldown window + * relative to $now (i.e. too recent to be allowed). + */ + public function isWithinCooldown(DateTimeInterface $effectiveDate, DateTimeInterface $now): bool + { + if (!$this->hasCooldown()) { + return false; + } + + $cutoffDate = (new DateTimeImmutable($now->format(DateTimeInterface::ATOM))) + ->modify("-{$this->period} seconds"); + + return $effectiveDate > $cutoffDate; + } + + /** + * Format the time until a package version will clear the cooldown. + * + * @param bool $short e.g. "3d 4h" instead of "3 days 4 hours", for tight table columns + */ + public function formatTimeUntilAvailable(DateTimeInterface $effectiveDate, DateTimeImmutable $now, bool $short = false): string + { + $availableAt = (new DateTimeImmutable($effectiveDate->format(DateTimeInterface::ATOM))) + ->modify("+{$this->period} seconds"); + $diff = $now->diff($availableAt); + $days = (int) $diff->days; + + $parts = []; + if ($days > 0) { + $parts[] = $short ? $days . 'd' : $days . ' day' . ($days > 1 ? 's' : ''); + } + if ($diff->h > 0) { + $parts[] = $short ? $diff->h . 'h' : $diff->h . ' hour' . ($diff->h > 1 ? 's' : ''); + } + if (count($parts) === 0 && $diff->i > 0) { + $parts[] = $short ? $diff->i . 'm' : $diff->i . ' minute' . ($diff->i > 1 ? 's' : ''); + } + + $result = implode(' ', $parts); + if ($result !== '') { + return $result; + } + + return $short ? '<1m' : 'less than a minute'; + } + + public function withBlockingDisabled() + { + return new static( + false, + $this->audit, + $this->ignore, + $this->period + ); + } + + public function withAudit(string $audit) + { + return new static( + $this->block, + $audit, + $this->ignore, + $this->period + ); + } + + /** + * @param array $policyConfig + */ + public static function fromRawConfig(array $policyConfig, VersionParser $parser): self + { + $cooldownConfig = $policyConfig['cooldown'] ?? []; + $envPeriod = self::getEnvPeriod(); + + // Like the other policies' env overrides, the env var re-enables an explicitly disabled policy + if ($cooldownConfig === false) { + if ($envPeriod === false || $envPeriod === null) { + return self::disabled(); + } + $cooldownConfig = []; + } + + if (!is_array($cooldownConfig)) { + $cooldownConfig = []; + } + + $period = self::parseDuration($cooldownConfig['period'] ?? null); + + // Environment variable overrides the configured duration but preserves + // the block/audit/ignore settings from config + if ($envPeriod !== false) { + $period = $envPeriod; + } + + return new self( + (bool) ($cooldownConfig['block'] ?? true), + $cooldownConfig['audit'] ?? self::AUDIT_IGNORE, + IgnorePackageRule::parseIgnoreMap($cooldownConfig['ignore'] ?? [], $parser), + $period + ); + } + + /** + * @return int|null|false the parsed period, or false when the environment variable is not set + * @throws \RuntimeException on an unparseable value + */ + private static function getEnvPeriod() + { + $envValue = Platform::getEnv('COMPOSER_POLICY_COOLDOWN_PERIOD'); + if ($envValue === false || $envValue === '') { + return false; + } + + try { + return self::parseDuration($envValue); + } catch (\RuntimeException $e) { + throw new \RuntimeException( + "Invalid value for COMPOSER_POLICY_COOLDOWN_PERIOD: {$envValue}. " + . "Use formats like '7 days', '24 hours', or an integer number of seconds.", + 0, + $e + ); + } + } + + public static function disabled(): self + { + return new self( + false, + self::AUDIT_IGNORE, + [], + null + ); + } + + /** + * Parse a duration into seconds. + * + * Accepts an integer number of seconds, an integer string, or an explicit + * unit duration such as "7 days", "24 hours", "30 minutes", "90 seconds" or + * "1 week" (the unit must be lowercase and may be singular or plural). + * null, an empty string, and 0 all mean "no cooldown". + * + * Relative phrases such as "tomorrow" or "next week" are intentionally + * rejected: they would resolve to surprising values (and some to 0, which + * silently disables the policy), so a typo throws instead of distorting the + * cooldown. + * + * @param string|int|null $duration + * @throws \RuntimeException on a negative or unparseable duration + */ + public static function parseDuration($duration): ?int + { + if ($duration === null || $duration === '' || $duration === 0 || $duration === '0') { + return null; + } + + if (is_int($duration)) { + if ($duration < 0) { + throw new \RuntimeException("Invalid policy.cooldown.period: duration cannot be negative ({$duration})."); + } + + return $duration; + } + + // A plain integer is interpreted as a number of seconds. + if (Preg::isMatch('/^\d+$/D', $duration)) { + return (int) $duration; + } + + // Reject any other numeric value (negative or fractional) with a clear message. + if (is_numeric($duration)) { + if ((float) $duration < 0) { + throw new \RuntimeException("Invalid policy.cooldown.period: duration cannot be negative ({$duration})."); + } + + throw new \RuntimeException("Invalid policy.cooldown.period format: {$duration}. Use an integer number of seconds or a duration like '7 days', '24 hours', '30 minutes' or '1 week'."); + } + + // Otherwise only explicit unit durations are accepted. + static $units = [ + 'second' => 1, + 'minute' => 60, + 'hour' => 3600, + 'day' => 86400, + 'week' => 604800, + ]; + + if (Preg::isMatchStrictGroups('/^(\d+)\s*(second|minute|hour|day|week)s?$/D', $duration, $matches)) { + return (int) $matches[1] * $units[$matches[2]]; + } + + throw new \RuntimeException("Invalid policy.cooldown.period format: {$duration}. Use an integer number of seconds or a duration like '7 days', '24 hours', '30 minutes' or '1 week'."); + } +} diff --git a/core/vendor/composer/composer/src/Composer/Policy/CustomListPolicyConfig.php b/core/vendor/composer/composer/src/Composer/Policy/CustomListPolicyConfig.php index 13c67374ca..ea86cd37b4 100644 --- a/core/vendor/composer/composer/src/Composer/Policy/CustomListPolicyConfig.php +++ b/core/vendor/composer/composer/src/Composer/Policy/CustomListPolicyConfig.php @@ -23,21 +23,38 @@ */ class CustomListPolicyConfig extends ListPolicyConfig { + /** + * @var self::BLOCK_SCOPE_* + */ + public $blockScope; + /** * URL sources for custom lists. * @var list */ public $sources; + /** + * Custom lists opt into install-time pool filtering via the base-class hook, + * so a `block-scope` of `install` or `all` is honoured during `composer install`. + * The per-list `block-scope` config field narrows the decision inside shouldBlock(). + */ + protected function supportsInstallBlockScope(): bool + { + return true; + } + /** * @param array> $ignore * @param list $sources * @param self::AUDIT_* $audit + * @param self::BLOCK_SCOPE_* $blockScope */ public function __construct( string $name, bool $block, string $audit, + string $blockScope, array $ignore, array $sources ) { @@ -48,15 +65,31 @@ public function __construct( $ignore ); + $this->blockScope = $blockScope; $this->sources = $sources; } + /** + * @param self::BLOCK_SCOPE_* $blockScope + */ + public function shouldBlock(string $blockScope): bool + { + // Defer the "is this list enabled at all + supports this scope?" question + // to the base class; only the per-list `block-scope` narrowing is list-specific. + if (!parent::shouldBlock($blockScope)) { + return false; + } + + return $this->blockScope === self::BLOCK_SCOPE_ALL || $this->blockScope === $blockScope; + } + public function withBlockingDisabled() { return new static( $this->name, false, $this->audit, + $this->blockScope, $this->ignore, $this->sources ); @@ -68,6 +101,7 @@ public function withAudit(string $audit) $this->name, $this->block, $audit, + $this->blockScope, $this->ignore, $this->sources ); @@ -98,10 +132,22 @@ public static function fromRawConfig(string $listName, $listConfig, VersionParse } } + // Reject unknown values: the global config's schema check only warns, and a typo would silently disable blocking + $blockScope = $listConfig['block-scope'] ?? self::BLOCK_SCOPE_UPDATE; + if (!in_array($blockScope, self::AVAILABLE_BLOCK_SCOPES, true)) { + throw new \UnexpectedValueException(sprintf( + 'Invalid block-scope for policy list "%s": expected one of "%s", got %s.', + $listName, + implode('", "', self::AVAILABLE_BLOCK_SCOPES), + json_encode($blockScope) + )); + } + return new self( $listName, (bool) ($listConfig['block'] ?? true), $listConfig['audit'] ?? self::AUDIT_FAIL, + $blockScope, IgnorePackageRule::parseIgnoreMap($listConfig['ignore'] ?? [], $parser), $sources ); @@ -113,6 +159,7 @@ public static function disabled(string $listName): self $listName, false, self::AUDIT_IGNORE, + self::BLOCK_SCOPE_UPDATE, [], [] ); diff --git a/core/vendor/composer/composer/src/Composer/Policy/ListPolicyConfig.php b/core/vendor/composer/composer/src/Composer/Policy/ListPolicyConfig.php index a6ab0cfe90..e93695bff4 100644 --- a/core/vendor/composer/composer/src/Composer/Policy/ListPolicyConfig.php +++ b/core/vendor/composer/composer/src/Composer/Policy/ListPolicyConfig.php @@ -48,6 +48,12 @@ abstract class ListPolicyConfig public const BLOCK_SCOPE_INSTALL = 'install'; public const BLOCK_SCOPE_ALL = 'all'; + public const AVAILABLE_BLOCK_SCOPES = [ + self::BLOCK_SCOPE_UPDATE, + self::BLOCK_SCOPE_INSTALL, + self::BLOCK_SCOPE_ALL, + ]; + /** @var string List name */ public $name; diff --git a/core/vendor/composer/composer/src/Composer/Policy/PolicyConfig.php b/core/vendor/composer/composer/src/Composer/Policy/PolicyConfig.php index 885de2ed62..eca00fc308 100644 --- a/core/vendor/composer/composer/src/Composer/Policy/PolicyConfig.php +++ b/core/vendor/composer/composer/src/Composer/Policy/PolicyConfig.php @@ -41,6 +41,9 @@ class PolicyConfig /** @var ListPolicyConfig */ public $abandoned; + /** @var CooldownPolicyConfig */ + public $cooldown; + /** @var array Custom named lists */ public $customLists; @@ -53,12 +56,14 @@ class PolicyConfig public const RESERVED_NAMES = [ AdvisoriesPolicyConfig::NAME, AbandonedPolicyConfig::NAME, + CooldownPolicyConfig::NAME, ]; public const BUILTIN_LIST_NAMES = [ AdvisoriesPolicyConfig::NAME, AbandonedPolicyConfig::NAME, MalwarePolicyConfig::NAME, + CooldownPolicyConfig::NAME, ]; /** @@ -90,6 +95,7 @@ public function __construct( AdvisoriesPolicyConfig $advisories, MalwarePolicyConfig $malware, ListPolicyConfig $abandoned, + CooldownPolicyConfig $cooldown, array $customLists, IgnoreUnreachable $ignoreUnreachable ) { @@ -97,6 +103,7 @@ public function __construct( $this->advisories = $advisories; $this->malware = $malware; $this->abandoned = $abandoned; + $this->cooldown = $cooldown; $this->customLists = $customLists; $this->ignoreUnreachable = $ignoreUnreachable; } @@ -157,12 +164,14 @@ public static function fromConfig(Config $config): self $auditRaw = $config->get('audit'); $parser = new VersionParser(); + // The global kill switch wins over COMPOSER_POLICY_COOLDOWN_PERIOD, so it can be set globally in CI if ($policyRaw === false) { return new self( false, AdvisoriesPolicyConfig::disabled(), MalwarePolicyConfig::disabled(), AbandonedPolicyConfig::disabled(), + CooldownPolicyConfig::disabled(), [], IgnoreUnreachable::all() ); @@ -174,6 +183,7 @@ public static function fromConfig(Config $config): self $advisories = AdvisoriesPolicyConfig::fromRawConfig($policyConfig, $auditConfig, $parser); $malware = MalwarePolicyConfig::fromRawConfig($policyConfig, $parser); $abandoned = AbandonedPolicyConfig::fromRawConfig($policyConfig, $auditConfig, $parser); + $cooldown = CooldownPolicyConfig::fromRawConfig($policyConfig, $parser); $customLists = []; foreach ($policyConfig as $listName => $listConfig) { @@ -242,11 +252,22 @@ public static function fromConfig(Config $config): self ); } + $cooldownBlockOverride = Platform::getBoolEnv('COMPOSER_POLICY_COOLDOWN_BLOCK'); + if (null !== $cooldownBlockOverride) { + $cooldown = new CooldownPolicyConfig( + $cooldownBlockOverride, + $cooldown->audit, + $cooldown->ignore, + $cooldown->period + ); + } + return new self( true, $advisories, $malware, $abandoned, + $cooldown, $customLists, $ignoreUnreachable ); @@ -263,6 +284,7 @@ public function getAllLists(): array 'advisories' => $this->advisories, 'malware' => $this->malware, 'abandoned' => $this->abandoned, + 'cooldown' => $this->cooldown, ], $this->customLists); } @@ -327,7 +349,10 @@ public function getActiveBlockFilterListNames(string $blockScope): array private function filterableLists(): array { $allLists = $this->getAllLists(); - unset($allLists['abandoned'], $allLists['advisories']); + // `advisories` and `abandoned` are surfaced via SecurityAdvisoryPoolFilter; + // `cooldown` has its own dedicated CooldownPoolFilter (it is time-based and + // has no URL sources to fetch), so it is likewise excluded here. + unset($allLists['abandoned'], $allLists['advisories'], $allLists['cooldown']); return $allLists; } @@ -359,11 +384,28 @@ public function withBlockingDisabled(): self $this->advisories->withBlockingDisabled(), $this->malware->withBlockingDisabled(), $this->abandoned->withBlockingDisabled(), + $this->cooldown->withBlockingDisabled(), $customLists, $this->ignoreUnreachable ); } + /** + * Create a copy with only the cooldown blocking disabled (for --no-cooldown). + */ + public function withCooldownBlockingDisabled(): self + { + return new self( + $this->enabled, + $this->advisories, + $this->malware, + $this->abandoned, + $this->cooldown->withBlockingDisabled(), + $this->customLists, + $this->ignoreUnreachable + ); + } + /** * Flip the listed scopes (audit, install, update) to true, leaving the * remaining scopes at their current value. At least one scope is required — @@ -376,6 +418,7 @@ public function withIgnoreUnreachable(string ...$scopes): self $this->advisories, $this->malware, $this->abandoned, + $this->cooldown, $this->customLists, $this->ignoreUnreachable->with(...$scopes) ); @@ -391,6 +434,7 @@ public function withIgnoreSeverity(array $severities): self $this->advisories->withIgnoreSeverity($severities), $this->malware, $this->abandoned, + $this->cooldown, $this->customLists, $this->ignoreUnreachable ); @@ -409,6 +453,7 @@ public function withAudit(?string $abandoned) $this->advisories, $this->malware, $this->abandoned->withAudit($abandoned !== null ? $abandoned : $this->abandoned->audit), + $this->cooldown, $this->customLists, $this->ignoreUnreachable ); diff --git a/core/vendor/composer/composer/src/Composer/Repository/AdvisoryProviderInterface.php b/core/vendor/composer/composer/src/Composer/Repository/AdvisoryProviderInterface.php index 528b4ab768..218ff53081 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/AdvisoryProviderInterface.php +++ b/core/vendor/composer/composer/src/Composer/Repository/AdvisoryProviderInterface.php @@ -28,7 +28,8 @@ public function hasSecurityAdvisories(): bool; /** * @param array $packageConstraintMap Map of package name to constraint (can be MatchAllConstraint to fetch all advisories) - * @return ($allowPartialAdvisories is true ? array{namesFound: string[], advisories: array>} : array{namesFound: string[], advisories: array>}) + * @param bool $allowPartialAdvisories If true, loading full advisories is skipped when partial ones are available. If false, full advisories are loaded where the repository can provide them, but partial ones may still be returned + * @return array{namesFound: string[], advisories: array>} */ public function getSecurityAdvisories(array $packageConstraintMap, bool $allowPartialAdvisories = false): array; } diff --git a/core/vendor/composer/composer/src/Composer/Repository/ArtifactRepository.php b/core/vendor/composer/composer/src/Composer/Repository/ArtifactRepository.php index fcd8970494..b8b8386c0b 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/ArtifactRepository.php +++ b/core/vendor/composer/composer/src/Composer/Repository/ArtifactRepository.php @@ -123,6 +123,8 @@ private function getComposerInformation(\SplFileInfo $file): ?BasePackage } $package = JsonFile::parseJson($json, $file->getPathname().'#composer.json'); + // only a Composer repository can vouch for a publication date, the package's own composer.json cannot + unset($package['published-time']); $package['dist'] = [ 'type' => $fileType, 'url' => strtr($file->getPathname(), '\\', '/'), diff --git a/core/vendor/composer/composer/src/Composer/Repository/ComposerRepository.php b/core/vendor/composer/composer/src/Composer/Repository/ComposerRepository.php index daeadcfa6d..4568854b4a 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/ComposerRepository.php +++ b/core/vendor/composer/composer/src/Composer/Repository/ComposerRepository.php @@ -13,7 +13,6 @@ namespace Composer\Repository; use Composer\Advisory\PartialSecurityAdvisory; -use Composer\Advisory\SecurityAdvisory; use Composer\FilterList\ComposerRepositoryFilterInformation; use Composer\FilterList\FilterListApiClient; use Composer\FilterList\FilterListEntryBuilder; @@ -38,10 +37,12 @@ use Composer\Plugin\PreFileDownloadEvent; use Composer\EventDispatcher\EventDispatcher; use Composer\Downloader\TransportException; +use Composer\Exception\SecurityException; use Composer\Semver\Constraint\ConstraintInterface; use Composer\Semver\Constraint\Constraint; use Composer\Semver\Constraint\MatchAllConstraint; use Composer\Util\Http\Response; +use Composer\Util\Http\ResponseWarnings; use Composer\MetadataMinifier\MetadataMinifier; use Composer\Util\Url; use React\Promise\PromiseInterface; @@ -51,9 +52,17 @@ */ class ComposerRepository extends ArrayRepository implements ConfigurableRepositoryInterface, AdvisoryProviderInterface, FilterListProviderInterface { + /** + * Package names per security-advisories API request + * + * Each name costs one form input var, and PHP silently truncates $_POST past + * max_input_vars (1000 by default), which would drop advisories without any error. + */ + private const ADVISORY_API_BATCH_SIZE = 500; + /** * @var mixed[] - * @phpstan-var array{url: string, options?: mixed[], type?: 'composer', allow_ssl_downgrade?: bool} + * @phpstan-var array{url: string, options?: mixed[], type?: 'composer', allow_ssl_downgrade?: bool, 'require-published-time'?: bool} */ private $repoConfig; /** @var mixed[] */ @@ -96,6 +105,8 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito protected $loader; /** @var bool */ private $allowSslDowngrade = false; + /** @var bool */ + private $requirePublishedTime = true; /** @var ?EventDispatcher */ private $eventDispatcher; /** @var ?array> */ @@ -156,7 +167,7 @@ class ComposerRepository extends ArrayRepository implements ConfigurableReposito /** * @param array $repoConfig - * @phpstan-param array{url: non-empty-string, options?: mixed[], type?: 'composer', allow_ssl_downgrade?: bool} $repoConfig + * @phpstan-param array{url: non-empty-string, options?: mixed[], type?: 'composer', allow_ssl_downgrade?: bool, 'require-published-time'?: bool} $repoConfig */ public function __construct(array $repoConfig, IOInterface $io, Config $config, HttpDownloader $httpDownloader, ?EventDispatcher $eventDispatcher = null) { @@ -190,6 +201,9 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config, if (isset($repoConfig['allow_ssl_downgrade']) && true === $repoConfig['allow_ssl_downgrade']) { $this->allowSslDowngrade = true; } + if (isset($repoConfig['require-published-time']) && false === $repoConfig['require-published-time']) { + $this->requirePublishedTime = false; + } $this->options = $repoConfig['options']; $this->url = $repoConfig['url']; @@ -251,6 +265,14 @@ private static function parseUserFilterConfig($rawFilter) return $parsed; } + /** + * Whether the cooldown policy must refuse versions from this repository that carry no published-time + */ + public function requiresPublishedTime(): bool + { + return $this->requirePublishedTime; + } + public function getRepoName() { return 'composer repo ('.Url::sanitize($this->url).')'; @@ -519,7 +541,7 @@ private function loadPackageList(?string $packageFilter = null): array $url .= '?filter='.urlencode($packageFilter); $result = $this->httpDownloader->get($url, $this->options)->decodeJson(); - HttpDownloader::outputWarnings($this->io, $this->url, $result); + ResponseWarnings::output($this->io, $this->url, $result); return $result['packageNames']; } @@ -534,7 +556,7 @@ private function loadPackageList(?string $packageFilter = null): array $result = $this->httpDownloader->get($url, $this->options)->decodeJson(); - HttpDownloader::outputWarnings($this->io, $this->url, $result); + ResponseWarnings::output($this->io, $this->url, $result); if (!$this->cache->isReadOnly()) { $this->cache->write($cacheKey, implode("\n", $result['packageNames'])); @@ -623,7 +645,7 @@ public function search(string $query, int $mode = 0, ?string $type = null) $search = $this->httpDownloader->get($url, $this->options)->decodeJson(); - HttpDownloader::outputWarnings($this->io, $this->url, $search); + ResponseWarnings::output($this->io, $this->url, $search); if (empty($search['results'])) { return []; @@ -660,7 +682,7 @@ public function search(string $query, int $mode = 0, ?string $type = null) $url = $this->listUrl . '?vendor='.urlencode($match['vendor']).'&filter='.urlencode($match['query'].'*'); $result = $this->httpDownloader->get($url, $this->options)->decodeJson(); - HttpDownloader::outputWarnings($this->io, $this->url, $result); + ResponseWarnings::output($this->io, $this->url, $result); $results = []; foreach ($result['packageNames'] as $name) { @@ -719,13 +741,9 @@ public function getSecurityAdvisories(array $packageConstraintMap, bool $allowPa /** * @param array $data * @param string $name - * @return ($allowPartialAdvisories is false ? SecurityAdvisory|null : PartialSecurityAdvisory|SecurityAdvisory|null) */ - $create = function (array $data, string $name) use ($parser, $allowPartialAdvisories, &$packageConstraintMap): ?PartialSecurityAdvisory { + $create = static function (array $data, string $name) use ($parser, &$packageConstraintMap): ?PartialSecurityAdvisory { $advisory = PartialSecurityAdvisory::create($name, $data, $parser); - if (!$allowPartialAdvisories && !$advisory instanceof SecurityAdvisory) { - throw new \RuntimeException('Advisory for '.$name.' could not be loaded as a full advisory from '.$this->getRepoName() . PHP_EOL . var_export($data, true)); - } if (!$advisory->affectedVersions->matches($packageConstraintMap[$name])) { return null; } @@ -775,30 +793,52 @@ static function ($data) use ($name, $create) { } $options['http']['header'][] = 'Content-type: application/x-www-form-urlencoded'; $options['http']['timeout'] = 10; - $options['http']['content'] = http_build_query(['packages' => array_keys($packageConstraintMap)]); - - $response = $this->httpDownloader->get($apiUrl, $options); - $warned = false; - $advisoryData = $response->decodeJson(); - HttpDownloader::outputWarnings($this->io, $this->url, $advisoryData); - /** @var string $name */ - foreach ($advisoryData['advisories'] as $name => $list) { - if (!isset($packageConstraintMap[$name])) { - if (!$warned) { - $this->io->writeError(''.$this->getRepoName().' returned names which were not requested in response to the security-advisories API. '.$name.' was not requested but is present in the response. Requested names were: '.implode(', ', array_keys($packageConstraintMap)).''); - $warned = true; - } - continue; + + $responses = []; + $promises = []; + foreach (array_chunk(array_keys($packageConstraintMap), self::ADVISORY_API_BATCH_SIZE) as $batchIndex => $batch) { + $batchOptions = $options; + $batchOptions['http']['content'] = http_build_query(['packages' => $batch]); + + $promises[] = $this->httpDownloader->add($apiUrl, $batchOptions) + ->then(static function (Response $response) use (&$responses, $batchIndex): void { + $responses[$batchIndex] = $response->decodeJson(); + }); + } + $this->loop->wait($promises); + ksort($responses); + + $repoWarningsShown = false; + $warnedAboutUnrequestedNames = false; + foreach ($responses as $advisoryData) { + if (!$repoWarningsShown) { + ResponseWarnings::output($this->io, $this->url, $advisoryData); + $repoWarningsShown = true; } - if (count($list) > 0) { - $advisories[$name] = array_values(array_filter(array_map( - static function ($data) use ($name, $create) { - return $create($data, $name); - }, - $list - ))); + + /** @var string $name */ + foreach ($advisoryData['advisories'] as $name => $list) { + if (!isset($packageConstraintMap[$name])) { + if (!$warnedAboutUnrequestedNames) { + $requested = array_keys($packageConstraintMap); + $requestedList = count($requested) > 20 + ? implode(', ', array_slice($requested, 0, 20)).' and '.(count($requested) - 20).' more' + : implode(', ', $requested); + $this->io->writeError(''.$this->getRepoName().' returned names which were not requested in response to the security-advisories API. '.$name.' was not requested but is present in the response. Requested names were: '.$requestedList.''); + $warnedAboutUnrequestedNames = true; + } + continue; + } + if (count($list) > 0) { + $advisories[$name] = array_values(array_filter(array_map( + static function ($data) use ($name, $create) { + return $create($data, $name); + }, + $list + ))); + } + $namesFound[$name] = true; } - $namesFound[$name] = true; } } @@ -842,7 +882,7 @@ public function getFilter(array $packageConstraintMap, array $configuredLists): $configuredLists ); $decoded = $response->decodeJson(); - HttpDownloader::outputWarnings($this->io, $this->url, $decoded); + ResponseWarnings::output($this->io, $this->url, $decoded); if (!isset($decoded['filter']) || !is_array($decoded['filter'])) { throw new TransportException('Filter api-url '.$this->filterConfig->apiUrl.' returned an unexpected response for '.$this->getRepoName(), 0); } @@ -1019,7 +1059,7 @@ public function getProviders(string $packageName) throw $e; } - HttpDownloader::outputWarnings($this->io, $this->url, $apiResult); + ResponseWarnings::output($this->io, $this->url, $apiResult); foreach ($apiResult['providers'] as $provider) { $result[$provider['name']] = $provider; @@ -1081,13 +1121,33 @@ private function getProviderNames(): array protected function configurePackageTransportOptions(PackageInterface $package): void { - foreach ($package->getDistUrls() as $url) { - if (strpos($url, $this->baseUrl) === 0) { - $package->setTransportOptions($this->options); + if ($this->isOwnDistUrl($package)) { + $package->setTransportOptions($this->options); + } + } - return; + /** + * @internal + * @return bool true if one of the package's dist URLs belongs to this repository + */ + public function isOwnDistUrl(PackageInterface $package): bool + { + foreach ($package->getDistUrls() as $url) { + // require a boundary after the base URL so e.g. https://example.org.example.net does not match https://example.org + if (Preg::isMatch('{^'.preg_quote($this->baseUrl).'(?:[/?#]|$)}', $url)) { + return true; } } + + return false; + } + + /** + * @internal + */ + public function getBaseUrl(): string + { + return $this->baseUrl; } private function hasProviders(): bool @@ -1201,7 +1261,8 @@ private function whatProvides(string $name, ?array $acceptableStabilities = null continue; } - if (!isset($versionsToLoad[$version['uid']])) { + $uid = $version['uid'] ?? null; + if (null === $uid || !isset($versionsToLoad[$uid])) { if (!isset($version['version_normalized'])) { $version['version_normalized'] = $this->versionParser->normalize($version['version']); } elseif ($version['version_normalized'] === VersionParser::DEFAULT_BRANCH_ALIAS) { @@ -1215,7 +1276,12 @@ private function whatProvides(string $name, ?array $acceptableStabilities = null } if ($this->isVersionAcceptable(null, $normalizedName, $version, $acceptableStabilities, $stabilityFlags)) { - $versionsToLoad[$version['uid']] = $version; + // inline packages have no uid and need no dedup + if (null === $uid) { + $versionsToLoad[] = $version; + } else { + $versionsToLoad[$uid] = $version; + } } } } @@ -1784,7 +1850,7 @@ protected function fetchFile(string $filename, ?string $cacheKey = null, ?string } $data = $response->decodeJson(); - HttpDownloader::outputWarnings($this->io, $this->url, $data); + ResponseWarnings::output($this->io, $this->url, $data); if ($cacheKey && !$this->cache->isReadOnly()) { if ($storeLastModifiedTime) { @@ -1809,7 +1875,7 @@ protected function fetchFile(string $filename, ?string $cacheKey = null, ?string throw $e; } - if ($e instanceof RepositorySecurityException) { + if ($e instanceof RepositorySecurityException || $e instanceof SecurityException) { throw $e; } @@ -1869,7 +1935,7 @@ private function fetchFileIfLastModified(string $filename, string $cacheKey, str } $data = $response->decodeJson(); - HttpDownloader::outputWarnings($this->io, $this->url, $data); + ResponseWarnings::output($this->io, $this->url, $data); $lastModifiedDate = $response->getHeader('last-modified'); $response->collect(); @@ -1883,7 +1949,7 @@ private function fetchFileIfLastModified(string $filename, string $cacheKey, str return $data; } catch (\Exception $e) { - if ($e instanceof \LogicException) { + if ($e instanceof \LogicException || $e instanceof SecurityException) { throw $e; } @@ -1968,7 +2034,7 @@ private function asyncFetchFile(string $filename, string $cacheKey, ?string $las } $data = $response->decodeJson(); - HttpDownloader::outputWarnings($io, $url, $data); + ResponseWarnings::output($io, $url, $data); $lastModifiedDate = $response->getHeader('last-modified'); $response->collect(); diff --git a/core/vendor/composer/composer/src/Composer/Repository/FilesystemRepository.php b/core/vendor/composer/composer/src/Composer/Repository/FilesystemRepository.php index cf021bfd45..5d4217276d 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/FilesystemRepository.php +++ b/core/vendor/composer/composer/src/Composer/Repository/FilesystemRepository.php @@ -42,6 +42,8 @@ class FilesystemRepository extends WritableArrayRepository private $filesystem; /** @var bool|null */ private $devMode = null; + /** @var ?RepositoryManager */ + private $repositoryManager = null; /** * Initializes filesystem repository. @@ -61,6 +63,16 @@ public function __construct(JsonFile $repositoryFile, bool $dumpVersions = false } } + /** + * Enables keeping http headers out of the repository file, they are restored from the repository config when loading it + * + * @internal + */ + public function setRepositoryManager(RepositoryManager $repositoryManager): void + { + $this->repositoryManager = $repositoryManager; + } + /** * @return bool|null true if dev requirements were installed, false if --no-dev was used, null if yet unknown */ @@ -105,6 +117,9 @@ protected function initialize() $loader = new ArrayLoader(null, true); foreach ($packages as $packageData) { $package = $loader->load($packageData); + if ($this->repositoryManager !== null) { + $this->repositoryManager->restoreHttpHeaders($package); + } $this->addPackage($package); } } @@ -134,6 +149,9 @@ public function write(bool $devMode, InstallationManager $installationManager) foreach ($this->getCanonicalPackages() as $package) { $pkgArray = $dumper->dump($package); + if ($this->repositoryManager !== null) { + $pkgArray = $this->repositoryManager->removeRestorableHttpHeaders($package, $pkgArray); + } $path = $installationManager->getInstallPath($package); $installPath = null; if ('' !== $path && null !== $path) { diff --git a/core/vendor/composer/composer/src/Composer/Repository/PackageRepository.php b/core/vendor/composer/composer/src/Composer/Repository/PackageRepository.php index dd0f08bf40..5ab3b1738a 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/PackageRepository.php +++ b/core/vendor/composer/composer/src/Composer/Repository/PackageRepository.php @@ -13,7 +13,6 @@ namespace Composer\Repository; use Composer\Advisory\PartialSecurityAdvisory; -use Composer\Advisory\SecurityAdvisory; use Composer\FilterList\FilterListEntry; use Composer\Package\Loader\ArrayLoader; use Composer\Package\Loader\ValidatingArrayLoader; @@ -91,12 +90,8 @@ public function getSecurityAdvisories(array $packageConstraintMap, bool $allowPa $advisories = []; foreach ($this->securityAdvisories as $packageName => $packageAdvisories) { if (isset($packageConstraintMap[$packageName])) { - $advisories[$packageName] = array_values(array_filter(array_map(function (array $data) use ($packageName, $allowPartialAdvisories, $packageConstraintMap, $parser) { + $advisories[$packageName] = array_values(array_filter(array_map(static function (array $data) use ($packageName, $packageConstraintMap, $parser) { $advisory = PartialSecurityAdvisory::create($packageName, $data, $parser); - if (!$allowPartialAdvisories && !$advisory instanceof SecurityAdvisory) { - throw new \RuntimeException('Advisory for '.$packageName.' could not be loaded as a full advisory from '.$this->getRepoName() . PHP_EOL . var_export($data, true)); - } - if (!$advisory->affectedVersions->matches($packageConstraintMap[$packageName])) { return null; } diff --git a/core/vendor/composer/composer/src/Composer/Repository/PathRepository.php b/core/vendor/composer/composer/src/Composer/Repository/PathRepository.php index 9628862311..b5df8082d8 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/PathRepository.php +++ b/core/vendor/composer/composer/src/Composer/Repository/PathRepository.php @@ -173,6 +173,8 @@ protected function initialize(): void $json = file_get_contents($composerFilePath); $package = JsonFile::parseJson($json, $composerFilePath); + // only a Composer repository can vouch for a publication date, the package's own composer.json cannot + unset($package['published-time']); $package['dist'] = [ 'type' => 'path', 'url' => $url, diff --git a/core/vendor/composer/composer/src/Composer/Repository/PlatformRepository.php b/core/vendor/composer/composer/src/Composer/Repository/PlatformRepository.php index 7b288af064..830a55e4b8 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/PlatformRepository.php +++ b/core/vendor/composer/composer/src/Composer/Repository/PlatformRepository.php @@ -394,8 +394,9 @@ protected function initialize(): void $this->addLibrary($libraries, $name.'-libmbfl', $libmbflMatches['version'], 'mbstring libmbfl version'); } - if (\PHP_VERSION_ID < 90000 && $this->runtime->hasConstant('MB_ONIGURUMA_VERSION')) { - $this->addLibrary($libraries, $name.'-oniguruma', Silencer::call([$this->runtime, 'getConstant'], 'MB_ONIGURUMA_VERSION'), 'mbstring oniguruma version'); + // MB_ONIGURUMA_VERSION is deprecated as of PHP 8.6, reading it would emit a notice + if (\PHP_VERSION_ID < 80600 && $this->runtime->hasConstant('MB_ONIGURUMA_VERSION')) { + $this->addLibrary($libraries, $name.'-oniguruma', $this->runtime->getConstant('MB_ONIGURUMA_VERSION'), 'mbstring oniguruma version'); // Multibyte regex (oniguruma) version => 5.9.5 // oniguruma version => 6.9.0 diff --git a/core/vendor/composer/composer/src/Composer/Repository/RepositoryManager.php b/core/vendor/composer/composer/src/Composer/Repository/RepositoryManager.php index f4d0034162..bd345589e6 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/RepositoryManager.php +++ b/core/vendor/composer/composer/src/Composer/Repository/RepositoryManager.php @@ -15,6 +15,7 @@ use Composer\IO\IOInterface; use Composer\Config; use Composer\EventDispatcher\EventDispatcher; +use Composer\Package\AliasPackage; use Composer\Package\PackageInterface; use Composer\Util\HttpDownloader; use Composer\Util\ProcessExecutor; @@ -44,6 +45,8 @@ class RepositoryManager private $eventDispatcher; /** @var ProcessExecutor */ private $process; + /** @var list|null empty if none of them have http headers configured */ + private $headerRepositories = null; public function __construct(IOInterface $io, Config $config, HttpDownloader $httpDownloader, ?EventDispatcher $eventDispatcher = null, ?ProcessExecutor $process = null) { @@ -99,6 +102,7 @@ public function findPackages(string $name, $constraint): array public function addRepository(RepositoryInterface $repository): void { $this->repositories[] = $repository; + $this->headerRepositories = null; } /** @@ -111,6 +115,7 @@ public function addRepository(RepositoryInterface $repository): void public function prependRepository(RepositoryInterface $repository): void { array_unshift($this->repositories, $repository); + $this->headerRepositories = null; } /** @@ -186,6 +191,83 @@ public function getLocalRepository(): InstalledRepositoryInterface return $this->localRepository; } + /** + * Restores the http headers configured on the composer repository the package's dist URL belongs to + * + * Headers often contain credentials so they are not stored in composer.lock and installed.json, see removeRestorableHttpHeaders() + * + * @internal + */ + public function restoreHttpHeaders(PackageInterface $package): void + { + $options = $package->getTransportOptions(); + if ($package instanceof AliasPackage || isset($options['http']['header'])) { + return; + } + + $headers = $this->findHttpHeaders($package); + if ($headers !== null) { + $options['http']['header'] = $headers; + $package->setTransportOptions($options); + } + } + + /** + * Removes the http headers from a dumped package if restoreHttpHeaders() can restore them + * + * @internal + * @param array $data the dumped package + * @return array + */ + public function removeRestorableHttpHeaders(PackageInterface $package, array $data): array + { + if (!isset($data['transport-options']['http']['header']) || $data['transport-options']['http']['header'] !== $this->findHttpHeaders($package)) { + return $data; + } + + unset($data['transport-options']['http']['header']); + if (\count($data['transport-options']['http']) === 0) { + unset($data['transport-options']['http']); + } + if (\count($data['transport-options']) === 0) { + unset($data['transport-options']); + } + + return $data; + } + + /** + * @return mixed the http headers of the most specific composer repository the package's dist URL belongs to + */ + private function findHttpHeaders(PackageInterface $package) + { + if ($this->headerRepositories === null) { + $this->headerRepositories = []; + $hasHeaders = false; + foreach ($this->repositories as $repo) { + if ($repo instanceof FilterRepository) { + $repo = $repo->getRepository(); + } + if ($repo instanceof ComposerRepository) { + $this->headerRepositories[] = $repo; + $hasHeaders = $hasHeaders || isset($repo->getRepoConfig()['options']['http']['header']); + } + } + if (!$hasHeaders) { + $this->headerRepositories = []; + } + } + + $match = null; + foreach ($this->headerRepositories as $repo) { + if (($match === null || \strlen($repo->getBaseUrl()) > \strlen($match->getBaseUrl())) && $repo->isOwnDistUrl($package)) { + $match = $repo; + } + } + + return $match !== null ? $match->getRepoConfig()['options']['http']['header'] ?? null : null; + } + /** * @internal */ diff --git a/core/vendor/composer/composer/src/Composer/Repository/RepositorySet.php b/core/vendor/composer/composer/src/Composer/Repository/RepositorySet.php index cb38749a2b..f7c8f31005 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/RepositorySet.php +++ b/core/vendor/composer/composer/src/Composer/Repository/RepositorySet.php @@ -18,6 +18,7 @@ use Composer\DependencyResolver\Request; use Composer\DependencyResolver\FilterListPoolFilter; use Composer\DependencyResolver\SecurityAdvisoryPoolFilter; +use Composer\DependencyResolver\CooldownPoolFilter; use Composer\EventDispatcher\EventDispatcher; use Composer\Advisory\SecurityAdvisory; use Composer\Advisory\PartialSecurityAdvisory; @@ -31,6 +32,7 @@ use Composer\Semver\Constraint\Constraint; use Composer\Semver\Constraint\ConstraintInterface; use Composer\Package\Version\StabilityFilter; +use Composer\Package\Version\VersionParser; use Composer\Semver\Constraint\MatchAllConstraint; use Composer\Semver\Constraint\MultiConstraint; @@ -227,9 +229,31 @@ public function findPackages(string $name, ?ConstraintInterface $constraint = nu return $result; } + /** + * Loads packages from all repositories in one batch per repository, to warm up caches before many findPackages() calls + * + * @param string[] $names + */ + public function preloadPackages(array $names): void + { + $nameMap = array_fill_keys($names, null); + foreach ($this->repositories as $repository) { + if ($nameMap === []) { + break; + } + + $result = $repository->loadPackages($nameMap, $this->acceptableStabilities, $this->stabilityFlags); + foreach ($result['namesFound'] as $name) { + // like findPackages, do not query lower priority repositories for names already found + unset($nameMap[$name]); + } + } + } + /** * @param string[] $packageNames - * @return ($allowPartialAdvisories is true ? array{advisories: array>, unreachableRepos: array} : array{advisories: array>, unreachableRepos: array}) + * @param bool $allowPartialAdvisories If true, loading full advisories is skipped when partial ones are available. If false, full advisories are loaded where the repository can provide them, but partial ones may still be returned + * @return array{advisories: array>, unreachableRepos: array} */ public function getSecurityAdvisories(array $packageNames, bool $allowPartialAdvisories, bool $ignoreUnreachable = false): array { @@ -246,7 +270,8 @@ public function getSecurityAdvisories(array $packageNames, bool $allowPartialAdv /** * @param PackageInterface[] $packages - * @return ($allowPartialAdvisories is true ? array{advisories: array>, unreachableRepos: array} : array{advisories: array>, unreachableRepos: array}) + * @param bool $allowPartialAdvisories If true, loading full advisories is skipped when partial ones are available. If false, full advisories are loaded where the repository can provide them, but partial ones may still be returned + * @return array{advisories: array>, unreachableRepos: array} */ public function getMatchingSecurityAdvisories(array $packages, bool $allowPartialAdvisories = false, bool $ignoreUnreachable = false): array { @@ -256,6 +281,9 @@ public function getMatchingSecurityAdvisories(array $packages, bool $allowPartia if ($package instanceof AliasPackage && $package->isRootPackageAlias()) { continue; } + if (self::isUnversionedBranch($package)) { + continue; + } // key by version so duplicate versions collapse and the resulting OR constraint stays flat // (nesting one MultiConstraint per version produces trees deep enough to blow the stack, see composer/semver#177) $constraintsByName[$package->getName()][$package->getVersion()] = new Constraint('=', $package->getVersion()); @@ -275,7 +303,7 @@ public function getMatchingSecurityAdvisories(array $packages, bool $allowPartia /** * @param array $packageConstraintMap * @param array &$unreachableRepos Array to store messages about unreachable repositories - * @return ($allowPartialAdvisories is true ? array> : array>) + * @return array> */ private function getSecurityAdvisoriesForConstraints(array $packageConstraintMap, bool $allowPartialAdvisories, bool $ignoreUnreachable = false, array &$unreachableRepos = []): array { @@ -334,9 +362,9 @@ public function isPackageAcceptable(array $names, string $stability): bool * @param list $ignoredTypes Packages of those types are ignored * @param list|null $allowedTypes Only packages of those types are allowed if set to non-null */ - public function createPool(Request $request, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $ignoredTypes = [], ?array $allowedTypes = null, ?SecurityAdvisoryPoolFilter $securityAdvisoryPoolFilter = null, ?FilterListPoolFilter $filterListPoolFilter = null): Pool + public function createPool(Request $request, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $ignoredTypes = [], ?array $allowedTypes = null, ?SecurityAdvisoryPoolFilter $securityAdvisoryPoolFilter = null, ?FilterListPoolFilter $filterListPoolFilter = null, ?CooldownPoolFilter $cooldownPoolFilter = null): Pool { - $poolBuilder = new PoolBuilder($this->acceptableStabilities, $this->stabilityFlags, $this->rootAliases, $this->rootReferences, $io, $eventDispatcher, $poolOptimizer, $this->temporaryConstraints, $securityAdvisoryPoolFilter, $filterListPoolFilter); + $poolBuilder = new PoolBuilder($this->acceptableStabilities, $this->stabilityFlags, $this->rootAliases, $this->rootReferences, $io, $eventDispatcher, $poolOptimizer, $this->temporaryConstraints, $securityAdvisoryPoolFilter, $filterListPoolFilter, $cooldownPoolFilter); $poolBuilder->setIgnoredTypes($ignoredTypes); $poolBuilder->setAllowedTypes($allowedTypes); @@ -437,4 +465,14 @@ private static function getRootAliasesPerPackage(array $aliases): array return $normalizedAliases; } + + /** + * dev-* branches and the default branch alias have no version ordering, so advisory ranges cannot meaningfully match them, unlike numeric branches such as 3.3.x-dev + * + * @internal + */ + public static function isUnversionedBranch(PackageInterface $package): bool + { + return str_starts_with($package->getVersion(), 'dev-') || $package->getVersion() === VersionParser::DEFAULT_BRANCH_ALIAS; + } } diff --git a/core/vendor/composer/composer/src/Composer/Repository/Vcs/FossilDriver.php b/core/vendor/composer/composer/src/Composer/Repository/Vcs/FossilDriver.php index f87ccc90e7..077e023066 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/Vcs/FossilDriver.php +++ b/core/vendor/composer/composer/src/Composer/Repository/Vcs/FossilDriver.php @@ -56,7 +56,7 @@ public function initialize(): void throw new \RuntimeException('FossilDriver requires a usable cache directory, and it looks like you set it to be disabled'); } - $localName = Preg::replace('{[^a-z0-9]}i', '-', $this->url); + $localName = Preg::replace('{[^a-z0-9]}i', '-', Url::sanitize($this->url)); $this->repoFile = $this->config->get('cache-repo-dir') . '/' . $localName . '.fossil'; $this->checkoutDir = $this->config->get('cache-vcs-dir') . '/' . $localName . '/'; @@ -94,7 +94,7 @@ protected function updateLocalRepo(): void // update the repo if it is a valid fossil repository if (is_file($this->repoFile) && is_dir($this->checkoutDir) && 0 === $this->process->execute(['fossil', 'info'], $output, $this->checkoutDir)) { if (0 !== $this->process->execute(['fossil', 'pull'], $output, $this->checkoutDir)) { - $this->io->writeError('Failed to update '.Url::sanitize($this->url).', package information from this repository may be outdated ('.$this->process->getErrorOutput().')'); + $this->io->writeError('Failed to update '.Url::sanitize($this->url).', package information from this repository may be outdated ('.Url::sanitize($this->process->getErrorOutput()).')'); } } else { // clean up directory and do a fresh clone into it @@ -104,13 +104,13 @@ protected function updateLocalRepo(): void $fs->ensureDirectoryExists($this->checkoutDir); if (0 !== $this->process->execute(['fossil', 'clone', '--', $this->url, $this->repoFile], $output)) { - $output = $this->process->getErrorOutput(); + $output = Url::sanitize($this->process->getErrorOutput()); throw new \RuntimeException('Failed to clone '.Url::sanitize($this->url).' to repository ' . $this->repoFile . "\n\n" .$output); } if (0 !== $this->process->execute(['fossil', 'open', '--nested', '--', $this->repoFile], $output, $this->checkoutDir)) { - $output = $this->process->getErrorOutput(); + $output = Url::sanitize($this->process->getErrorOutput()); throw new \RuntimeException('Failed to open repository '.$this->repoFile.' in ' . $this->checkoutDir . "\n\n" .$output); } diff --git a/core/vendor/composer/composer/src/Composer/Repository/Vcs/GitBitbucketDriver.php b/core/vendor/composer/composer/src/Composer/Repository/Vcs/GitBitbucketDriver.php index fa4d30d629..7661e81b26 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/Vcs/GitBitbucketDriver.php +++ b/core/vendor/composer/composer/src/Composer/Repository/Vcs/GitBitbucketDriver.php @@ -419,7 +419,7 @@ protected function fetchWithOAuthCredentials(string $url, bool $fetchingRepoData */ protected function generateSshUrl(): string { - return 'git@' . $this->originUrl . ':' . $this->owner.'/'.$this->repository.'.git'; + return 'git@ssh.' . $this->originUrl . ':' . $this->owner.'/'.$this->repository.'.git'; } /** diff --git a/core/vendor/composer/composer/src/Composer/Repository/Vcs/GitHubDriver.php b/core/vendor/composer/composer/src/Composer/Repository/Vcs/GitHubDriver.php index 32189c11b1..956fd5b3ee 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/Vcs/GitHubDriver.php +++ b/core/vendor/composer/composer/src/Composer/Repository/Vcs/GitHubDriver.php @@ -522,17 +522,28 @@ protected function getContents(string $url, bool $fetchingRepoData = false): Res return parent::getContents($url); } + $rateLimited = $gitHubUtil->isRateLimited((array) $e->getHeaders()); + if (!$this->io->isInteractive() && $fetchingRepoData) { - $this->attemptCloneFallback($e); + try { + $this->attemptCloneFallback($e); + } catch (\RuntimeException $cloneException) { + // The clone was the last way to get the metadata, so the rate limit is + // the reason nothing could be fetched at all and has to be reported. + // Otherwise it stays hidden behind whatever made the clone fail. + if ($rateLimited) { + $this->writeRateLimitError($gitHubUtil, $e); + } + + throw $cloneException; + } return new Response(['url' => 'dummy'], 200, [], 'null'); } - $rateLimited = $gitHubUtil->isRateLimited((array) $e->getHeaders()); - if (!$this->io->hasAuthentication($this->originUrl)) { if (!$this->io->isInteractive()) { - $this->io->writeError('GitHub API limit exhausted. Failed to get metadata for the '.Url::sanitize($this->url).' repository, try running in interactive mode so that you can enter your GitHub credentials to increase the API limit'); + $this->writeRateLimitError($gitHubUtil, $e); throw $e; } @@ -542,12 +553,7 @@ protected function getContents(string $url, bool $fetchingRepoData = false): Res } if ($rateLimited) { - $rateLimit = $gitHubUtil->getRateLimit($e->getHeaders()); - $this->io->writeError(sprintf( - 'GitHub API limit (%d calls/hr) is exhausted. You are already authorized so you have to wait until %s before doing more requests', - $rateLimit['limit'], - $rateLimit['reset'] - )); + $this->writeRateLimitError($gitHubUtil, $e); } throw $e; @@ -599,6 +605,25 @@ protected function fetchRootIdentifier(): void $this->isArchived = !empty($this->repoData['archived']); } + /** + * Tells the user that the GitHub API limit is what stopped the request. + */ + private function writeRateLimitError(GitHub $gitHubUtil, TransportException $e): void + { + if (!$this->io->hasAuthentication($this->originUrl)) { + $this->io->writeError('GitHub API limit exhausted. Failed to get metadata for the '.Url::sanitize($this->url).' repository, try running in interactive mode so that you can enter your GitHub credentials to increase the API limit'); + + return; + } + + $rateLimit = $gitHubUtil->getRateLimit($e->getHeaders()); + $this->io->writeError(sprintf( + 'GitHub API limit (%d calls/hr) is exhausted. You are already authorized so you have to wait until %s before doing more requests', + $rateLimit['limit'], + $rateLimit['reset'] + )); + } + /** * @phpstan-impure * diff --git a/core/vendor/composer/composer/src/Composer/Repository/Vcs/HgDriver.php b/core/vendor/composer/composer/src/Composer/Repository/Vcs/HgDriver.php index 5e2bbe7b6e..69628f79e6 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/Vcs/HgDriver.php +++ b/core/vendor/composer/composer/src/Composer/Repository/Vcs/HgDriver.php @@ -65,7 +65,7 @@ public function initialize(): void // update the repo if it is a valid hg repository if (is_dir($this->repoDir) && 0 === $this->process->execute(['hg', 'summary'], $output, $this->repoDir)) { if (0 !== $this->process->execute(['hg', 'pull'], $output, $this->repoDir)) { - $this->io->writeError('Failed to update '.Url::sanitize($this->url).', package information from this repository may be outdated ('.$this->process->getErrorOutput().')'); + $this->io->writeError('Failed to update '.Url::sanitize($this->url).', package information from this repository may be outdated ('.Url::sanitize($this->process->getErrorOutput()).')'); } } else { // clean up directory and do a fresh clone into it diff --git a/core/vendor/composer/composer/src/Composer/Repository/VcsRepository.php b/core/vendor/composer/composer/src/Composer/Repository/VcsRepository.php index a142bf960e..758254284d 100644 --- a/core/vendor/composer/composer/src/Composer/Repository/VcsRepository.php +++ b/core/vendor/composer/composer/src/Composer/Repository/VcsRepository.php @@ -13,6 +13,8 @@ namespace Composer\Repository; use Composer\Downloader\TransportException; +use Composer\Exception\SecurityException; +use Composer\Package\BasePackage; use Composer\Pcre\Preg; use Composer\Repository\Vcs\VcsDriverInterface; use Composer\Package\Version\VersionParser; @@ -70,6 +72,8 @@ class VcsRepository extends ArrayRepository implements ConfigurableRepositoryInt private $emptyReferences = []; /** @var array<'tags'|'branches', array> */ private $versionTransportExceptions = []; + /** @var ?non-empty-string */ + private $onlyBranchesRegex = null; /** * @param array{url: string, type?: string}&array $repoConfig @@ -102,6 +106,14 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config, $this->versionCache = $versionCache; $this->httpDownloader = $httpDownloader; $this->processExecutor = $process ?? new ProcessExecutor($io); + + if (isset($repoConfig['only-branches'])) { + if (!is_array($repoConfig['only-branches']) || count(array_filter($repoConfig['only-branches'], 'is_string')) !== count($repoConfig['only-branches'])) { + throw new \InvalidArgumentException('"only-branches" key for repository '.Url::sanitize($this->url).' should be an array of strings'); + } + // branch names are case-sensitive, unlike package names + $this->onlyBranchesRegex = BasePackage::packageNamesToRegexp($repoConfig['only-branches'], '{^(?:%s)$}D'); + } } public function getRepoName() @@ -206,7 +218,7 @@ protected function initialize() $this->packageName = !empty($data['name']) ? $data['name'] : null; } } catch (\Exception $e) { - if ($e instanceof TransportException && $this->shouldRethrowTransportException($e)) { + if ($e instanceof SecurityException || ($e instanceof TransportException && $this->shouldRethrowTransportException($e))) { throw $e; } @@ -299,6 +311,9 @@ protected function initialize() $this->addPackage($this->loader->load($this->preProcess($driver, $data, $identifier))); } catch (\Exception $e) { + if ($e instanceof SecurityException) { + throw $e; + } if ($e instanceof TransportException) { $this->versionTransportExceptions['tags'][$tag] = $e; if ($e->getCode() === 404) { @@ -324,6 +339,7 @@ protected function initialize() if ($hasRootIdentifierComposerJson && isset($branches[$driver->getRootIdentifier()])) { $branches = [$driver->getRootIdentifier() => $branches[$driver->getRootIdentifier()]] + $branches; } + $branches = $this->filterBranches($branches, $driver->getRootIdentifier()); foreach ($branches as $branch => $identifier) { $branch = (string) $branch; @@ -404,6 +420,9 @@ protected function initialize() } continue; } catch (\Exception $e) { + if ($e instanceof SecurityException) { + throw $e; + } if (!$isVeryVerbose) { $this->io->writeError(''); } @@ -425,7 +444,7 @@ protected function initialize() } /** - * @param array{name?: string, dist?: array{type: string, url: string, reference: string, shasum: string}, source?: array{type: string, url: string, reference: string}} $data + * @param array{name?: string, published-time?: string, dist?: array{type: string, url: string, reference: string, shasum: string}, source?: array{type: string, url: string, reference: string}} $data * * @return array{name: string|null, dist: array{type: string, url: string, reference: string, shasum: string}|null, source: array{type: string, url: string, reference: string}} */ @@ -437,6 +456,9 @@ protected function preProcess(VcsDriverInterface $driver, array $data, string $i $dataPackageName = $data['name'] ?? null; $data['name'] = $this->packageName ?: $dataPackageName; + // only a Composer repository can vouch for a publication date, the package's own composer.json cannot + unset($data['published-time']); + if (!isset($data['dist'])) { $data['dist'] = $driver->getDist($identifier); } @@ -452,6 +474,35 @@ protected function preProcess(VcsDriverInterface $driver, array $data, string $i return $data; } + /** + * Restricts the branches to the ones matching the "only-branches" repo config patterns, the default branch is always kept + * + * @param array $branches + * @return array + */ + private function filterBranches(array $branches, string $rootIdentifier): array + { + if (null === $this->onlyBranchesRegex) { + return $branches; + } + + foreach ($branches as $branch => $identifier) { + $branch = (string) $branch; + // svn's root identifier is the trunk identifier (e.g. /trunk/@123) and not a branch name + if ($branch === $rootIdentifier || $identifier === $rootIdentifier) { + continue; + } + if (!Preg::isMatch($this->onlyBranchesRegex, $branch)) { + if ($this->isVeryVerbose) { + $this->io->writeError('Skipped branch '.$branch.', not matching the only-branches filter'); + } + unset($branches[$branch]); + } + } + + return $branches; + } + /** * @return string|false */ diff --git a/core/vendor/composer/composer/src/Composer/Util/AuthHelper.php b/core/vendor/composer/composer/src/Composer/Util/AuthHelper.php index ab3440a848..aad9e29c6d 100644 --- a/core/vendor/composer/composer/src/Composer/Util/AuthHelper.php +++ b/core/vendor/composer/composer/src/Composer/Util/AuthHelper.php @@ -76,7 +76,7 @@ static function ($value): string { * @param int $statusCode HTTP status code that triggered this call * @param string|null $reason a message/description explaining why this was called * @param string[] $headers - * @param int $retryCount the amount of retries already done on this URL + * @param int $retryCount the amount of authentication retries already done on this URL, network retries do not count * @return array containing retry (bool) and storeAuth (string|bool) keys, if retry is true the request should be * retried, if storeAuth is true then on a successful retry the authentication should be persisted to auth.json * @phpstan-return array{retry: bool, storeAuth: 'prompt'|bool} @@ -158,7 +158,9 @@ public function promptAuthIfNeeded(string $url, string $origin, int $statusCode, $auth = null; if ($this->io->hasAuthentication($origin)) { $auth = $this->io->getAuthentication($origin); - if (in_array($auth['password'], ['gitlab-ci-token', 'private-token', 'oauth2'], true)) { + if (in_array($auth['password'], ['gitlab-ci-token', 'private-token', 'oauth2'], true) + || $auth['username'] === 'gitlab-ci-token' + ) { throw new TransportException("Invalid credentials for '" . Url::sanitize($url) . "', aborting.", $statusCode); } } @@ -303,7 +305,14 @@ public function addAuthenticationOptions(array $options, string $origin, string $authenticationDisplayMessage = 'Using GitHub token authentication'; } } elseif ( - in_array($auth['password'], ['oauth2', 'private-token', 'gitlab-ci-token'], true) + ($auth['username'] === 'gitlab-ci-token' || $auth['password'] === 'gitlab-ci-token') + && in_array($origin, $this->config->get('gitlab-domains'), true) + ) { + // the marker ends up in either slot depending on whether GitLab::authorizeOAuth normalized the credentials + $headers[] = 'JOB-TOKEN: '.($auth['password'] === 'gitlab-ci-token' ? $auth['username'] : $auth['password']); + $authenticationDisplayMessage = 'Using GitLab CI job token authentication'; + } elseif ( + in_array($auth['password'], ['oauth2', 'private-token'], true) && in_array($origin, $this->config->get('gitlab-domains'), true) ) { if ($auth['password'] === 'oauth2') { diff --git a/core/vendor/composer/composer/src/Composer/Util/Filesystem.php b/core/vendor/composer/composer/src/Composer/Util/Filesystem.php index 018cc8d060..90ff779fc9 100644 --- a/core/vendor/composer/composer/src/Composer/Util/Filesystem.php +++ b/core/vendor/composer/composer/src/Composer/Util/Filesystem.php @@ -918,29 +918,117 @@ public function filePutContentsIfModified(string $path, string $content) { $currentContent = Silencer::call('file_get_contents', $path); if (false === $currentContent || $currentContent !== $content) { - return file_put_contents($path, $content); + return self::safeFilePutContents($path, $content); } return 0; } + /** + * Writes a file by renaming a temp file onto it, instead of rewriting it in place + * + * This way readers never see a half-written file, and paths hardlinked to the previous inode + * (e.g. a vendor dir copied with `cp -al`) keep their content. Falls back to a direct write + * when the file cannot be replaced. + * + * @return int|false + */ + public static function safeFilePutContents(string $path, string $content) + { + $replaced = self::replaceFile($path, static function ($handle) use ($content): bool { + return \strlen($content) === fwrite($handle, $content); + }); + + return $replaced ? \strlen($content) : file_put_contents($path, $content); + } + /** * Copy file using stream_copy_to_stream to work around https://bugs.php.net/bug.php?id=6463 */ public function safeCopy(string $source, string $target): void { - if (!file_exists($target) || !file_exists($source) || !$this->filesAreEqual($source, $target)) { - $sourceHandle = fopen($source, 'r'); - assert($sourceHandle !== false, 'Could not open "'.$source.'" for reading.'); + if (file_exists($target) && file_exists($source) && $this->filesAreEqual($source, $target)) { + return; + } + + $sourceHandle = fopen($source, 'r'); + assert($sourceHandle !== false, 'Could not open "'.$source.'" for reading.'); + + $replaced = self::replaceFile($target, static function ($targetHandle) use ($sourceHandle): bool { + return false !== stream_copy_to_stream($sourceHandle, $targetHandle); + }); + + if (!$replaced) { + rewind($sourceHandle); $targetHandle = fopen($target, 'w+'); assert($targetHandle !== false, 'Could not open "'.$target.'" for writing.'); - stream_copy_to_stream($sourceHandle, $targetHandle); - fclose($sourceHandle); fclose($targetHandle); + } + fclose($sourceHandle); + + touch($target, (int) filemtime($source), (int) fileatime($source)); + } + + /** + * Fills a temp file using $write and renames it onto $path, or returns false if $path should be written directly + * + * @param callable(resource): bool $write + */ + private static function replaceFile(string $path, callable $write): bool + { + // stream wrappers (php://memory, ...) do not support renaming + if (false !== strpos($path, '://')) { + return false; + } + + // write through symlinks instead of replacing them + if (is_link($path)) { + $path = realpath($path); + if (false === $path) { + return false; + } + } + + // rename() only needs the dir to be writable, so read-only files are left to the direct write which fails as before, + // and so are is_writable false negatives, see https://github.com/composer/composer/issues/8231 + if (file_exists($path) && !is_writable($path)) { + return false; + } - touch($target, (int) filemtime($source), (int) fileatime($source)); + $tempPath = $path.'~'.bin2hex(random_bytes(5)).'.tmp'; + $handle = Silencer::call('fopen', $tempPath, 'x'); + if (false === $handle) { + return false; } + + // restrict permissions before writing as the content may be sensitive, e.g. auth.json + $written = true === Silencer::call('chmod', $tempPath, 0600); + + $stat = Silencer::call('stat', $path); + if ($written && \is_array($stat)) { + // the renamed file would be ours, so only replace it if its owner and group can be kept + $tempStat = fstat($handle); + $written = false !== $tempStat + && ($stat['uid'] === $tempStat['uid'] || true === Silencer::call('chown', $tempPath, $stat['uid'])) + && ($stat['gid'] === $tempStat['gid'] || true === Silencer::call('chgrp', $tempPath, $stat['gid'])); + } + + $written = $written && $write($handle); + $written = fclose($handle) && $written; + + if ($written) { + Silencer::call('chmod', $tempPath, \is_array($stat) ? $stat['mode'] & 0777 : 0666 & ~umask()); + + // not using Filesystem::rename() as its fallbacks copy onto the target, rewriting it in place + if (true === Silencer::call('rename', $tempPath, $path)) { + return true; + } + } + + Silencer::call('unlink', $tempPath); + + return false; } /** diff --git a/core/vendor/composer/composer/src/Composer/Util/Git.php b/core/vendor/composer/composer/src/Composer/Util/Git.php index 9b0f644d22..0046fd877d 100644 --- a/core/vendor/composer/composer/src/Composer/Util/Git.php +++ b/core/vendor/composer/composer/src/Composer/Util/Git.php @@ -215,7 +215,8 @@ public function runCommand($commandCallable, string $url, ?string $cwd, bool $in // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups } elseif ( Preg::isMatchStrictGroups('{^(https?)://(bitbucket\.org)/(.*?)(?:\.git)?$}i', $url, $match) - || Preg::isMatchStrictGroups('{^(git)@(bitbucket\.org):(.+?\.git)$}i', $url, $match) + || Preg::isMatchStrictGroups('{^(git)@(?:ssh\.)?(bitbucket\.org):(.+?\.git)$}i', $url, $match) + || Preg::isMatchStrictGroups('{^(ssh)://git@(?:ssh\.)?(bitbucket\.org)/(.*?)(?:\.git)?$}i', $url, $match) ) { //bitbucket either through oauth or app password, with fallback to ssh. $bitbucketUtil = new Bitbucket($this->io, $this->config, $this->process, $this->httpDownloader); @@ -272,14 +273,16 @@ public function runCommand($commandCallable, string $url, ?string $cwd, bool $in $credentials = [rawurlencode($auth['username']), rawurlencode($auth['password'])]; } - //Falling back to ssh - $sshUrl = 'git@bitbucket.org:' . $repo_with_git_part; - $this->io->writeError(' No bitbucket authentication configured. Falling back to ssh.'); - if (0 === $runCommands($sshUrl)) { - return; - } + //Falling back to ssh, unless that is the URL which already failed above + $sshUrl = 'git@ssh.bitbucket.org:' . $repo_with_git_part; + if ($sshUrl !== $url) { + $this->io->writeError(' No bitbucket authentication configured. Falling back to ssh.'); + if (0 === $runCommands($sshUrl)) { + return; + } - $errorMsg = $this->process->getErrorOutput(); + $errorMsg = $this->process->getErrorOutput(); + } } elseif ( // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups Preg::isMatchStrictGroups('{^(git)@' . self::getGitLabDomainsRegex($this->config) . ':(.+?\.git)$}i', $url, $match) diff --git a/core/vendor/composer/composer/src/Composer/Util/GitHub.php b/core/vendor/composer/composer/src/Composer/Util/GitHub.php index b97085baaa..768eb5b597 100644 --- a/core/vendor/composer/composer/src/Composer/Util/GitHub.php +++ b/core/vendor/composer/composer/src/Composer/Util/GitHub.php @@ -105,7 +105,7 @@ public function authorizeOAuthInteractively(string $originUrl, ?string $message ]); $this->io->writeError([ - '1. When you don\'t use \'vcs\' type \'repositories\' in composer.json and do not need to clone source or download dist files', + '1. When you don\'t use \'vcs\' type \'repositories\' in composer.json and do not need to clone source or download dist files', 'from private GitHub repositories over HTTPS, use a fine-grained token with read-only access to public information.', 'Use the following URL to create such a token:', 'https://'.$originUrl.'/settings/personal-access-tokens/new?name=' . str_replace('%20', '+', rawurlencode($note)), diff --git a/core/vendor/composer/composer/src/Composer/Util/Http/CurlDownloader.php b/core/vendor/composer/composer/src/Composer/Util/Http/CurlDownloader.php index f4ac8bbd47..2fab93c034 100644 --- a/core/vendor/composer/composer/src/Composer/Util/Http/CurlDownloader.php +++ b/core/vendor/composer/composer/src/Composer/Util/Http/CurlDownloader.php @@ -21,15 +21,16 @@ use Composer\Util\StreamContextFactory; use Composer\Util\AuthHelper; use Composer\Util\Url; -use Composer\Util\HttpDownloader; use React\Promise\Promise; /** * @internal * @author Jordi Boggiano * @author Nicolas Grekas - * @phpstan-type Attributes array{retryAuthFailure: bool, redirects: int<0, max>, retries: int<0, max>, storeAuth: 'prompt'|bool, ipResolve: 4|6|null} - * @phpstan-type Job array{url: non-empty-string, origin: string, attributes: Attributes, options: mixed[], progress: mixed[], curlHandle: \CurlHandle, filename: string|null, headerHandle: resource, bodyHandle: resource, resolve: callable, reject: callable, primaryIp: string} + * @phpstan-type Attributes array{retryAuthFailure: bool, redirects: int<0, max>, retries: int<0, max>, authRetries: int<0, max>, storeAuth: 'prompt'|bool, ipResolve: 4|6|null} + * @phpstan-type Job array{url: non-empty-string, origin: string, attributes: Attributes, options: mixed[], progress: mixed[], curlHandle: \CurlHandle, filename: string|null, headerHandle: resource, bodyHandle: resource, resolve: callable, reject: callable, primaryIp: string, hostKey: string|null} + * @phpstan-type RetryAttributes array{retryAuthFailure?: bool, redirects?: int<0, max>, storeAuth?: 'prompt'|bool, retries: int<1, max>, ipResolve?: 4|6} + * @phpstan-type DelayedJob array{job: Job, url: non-empty-string, attributes: RetryAttributes, at: float} */ class CurlDownloader { @@ -41,12 +42,39 @@ class CurlDownloader */ private const BAD_MULTIPLEXING_CURL_VERSIONS = ['7.87.0', '7.88.0', '7.88.1']; + /** + * Longest Retry-After interval which is waited out, in seconds + * + * Without a ceiling a server could park an install for as long as it likes, see isStatusCodeRetryNeeded() + */ + private const MAX_RETRY_AFTER = 60; + /** @var \CurlMultiHandle */ private $multiHandle; /** @var \CurlShareHandle */ private $shareHandle; /** @var Job[] */ private $jobs = []; + /** + * Retries which are waiting for their delay to elapse, see restartJobWithDelay() + * + * @var array + */ + private $delayedJobs = []; + /** + * Origins which asked for requests to be retried later, keyed by origin, see restartJobWithDelay() + * + * until is the latest point in time the origin asked to be left alone for, see getDueAt() + * + * @var array + */ + private $retryAfterOrigins = []; + /** + * Hosts which presented a distrusted TLS chain, keyed by host:port, see checkCertificateChain() + * + * @var array + */ + private $distrustedHosts = []; /** @var IOInterface */ private $io; /** @var Config */ @@ -87,13 +115,15 @@ class CurlDownloader ]; /** @var array */ - private static $timeInfo = [ + private static $ignoredProgressInfo = [ 'total_time' => true, 'namelookup_time' => true, 'connect_time' => true, 'pretransfer_time' => true, 'starttransfer_time' => true, 'redirect_time' => true, + // large once CURLOPT_CERTINFO is set, and the chain is checked separately + 'certinfo' => true, ]; /** @@ -153,7 +183,7 @@ public function download(callable $resolve, callable $reject, string $origin, st /** * @param mixed[] $options * - * @param array{retryAuthFailure?: bool, redirects?: int<0, max>, retries?: int<0, max>, storeAuth?: 'prompt'|bool, ipResolve?: 4|6|null} $attributes + * @param array{retryAuthFailure?: bool, redirects?: int<0, max>, retries?: int<0, max>, authRetries?: int<0, max>, storeAuth?: 'prompt'|bool, ipResolve?: 4|6|null} $attributes * @param non-empty-string $url * * @return int internal job id @@ -164,6 +194,7 @@ private function initDownload(callable $resolve, callable $reject, string $origi 'retryAuthFailure' => true, 'redirects' => 0, 'retries' => 0, + 'authRetries' => 0, 'storeAuth' => false, 'ipResolve' => null, ], $attributes); @@ -189,6 +220,11 @@ private function initDownload(callable $resolve, callable $reject, string $origi throw new TransportException('Access to "'.Url::sanitize($url).'" is blocked.'); } + $hostKey = TlsChainDenylist::getHostKey($url); + if (null !== $hostKey && isset($this->distrustedHosts[$hostKey])) { + throw TlsChainDenylist::createException($url, $this->distrustedHosts[$hostKey], false); + } + $curlHandle = curl_init(); $headerHandle = fopen('php://temp/maxmemory:32768', 'w+b'); if (false === $headerHandle) { @@ -225,6 +261,16 @@ private function initDownload(callable $resolve, callable $reject, string $origi curl_setopt($curlHandle, CURLOPT_ENCODING, ""); // let cURL set the Accept-Encoding header to what it supports curl_setopt($curlHandle, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); + if (null !== $hostKey) { + curl_setopt($curlHandle, CURLOPT_CERTINFO, true); + if (\defined('CURLOPT_PREREQFUNCTION')) { + // runs after the TLS handshake but before the request and its credentials are sent + curl_setopt($curlHandle, CURLOPT_PREREQFUNCTION, function ($handle) use ($hostKey): int { + return null === $this->checkCertificateChain($hostKey, curl_getinfo($handle, CURLINFO_CERTINFO)) ? 0 : 1; // CURL_PREREQFUNC_OK : CURL_PREREQFUNC_ABORT, literals as the constants only exist on PHP 8.4+ + }); + } + } + if ($attributes['ipResolve'] === 4) { curl_setopt($curlHandle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); } elseif ($attributes['ipResolve'] === 6) { @@ -252,14 +298,12 @@ private function initDownload(callable $resolve, callable $reject, string $origi $proxy = ProxyManager::getInstance()->getProxyForRequest($url); - if (0 === strpos($url, 'https://')) { - $willUseProxy = $proxy->getStatus() !== '' && !$proxy->isExcludedByNoProxy(); - - if (!$willUseProxy && \defined('CURL_VERSION_HTTP3') && \defined('CURL_HTTP_VERSION_3') && (CURL_VERSION_HTTP3 & $features) !== 0) { - curl_setopt($curlHandle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_3); - } elseif (\defined('CURL_VERSION_HTTP2') && \defined('CURL_HTTP_VERSION_2_0') && (CURL_VERSION_HTTP2 & $features) !== 0) { - curl_setopt($curlHandle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0); - } + // HTTP/3 is deliberately not requested: packagist.org, GitHub, GitLab and Bitbucket do not support it yet, and + // CURL_HTTP_VERSION_3 makes curl wait for a QUIC handshake before falling back to TCP on every new connection. + // Worth revisiting once HTTP/3 is more widespread, ideally via CURLOPT_ALTSVC so only advertising hosts use it, + // see https://github.com/composer/composer/issues/12987 + if (0 === strpos($url, 'https://') && \defined('CURL_VERSION_HTTP2') && \defined('CURL_HTTP_VERSION_2_0') && (CURL_VERSION_HTTP2 & $features) !== 0) { + curl_setopt($curlHandle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0); } // curl 8.7.0 - 8.7.1 has a bug whereas automatic accept-encoding header results in an error when reading the response @@ -285,7 +329,7 @@ private function initDownload(callable $resolve, callable $reject, string $origi curl_setopt_array($curlHandle, $proxy->getCurlOptions($options['ssl'] ?? [])); - $progress = array_diff_key(curl_getinfo($curlHandle), self::$timeInfo); + $progress = array_diff_key(curl_getinfo($curlHandle), self::$ignoredProgressInfo); $this->jobs[(int) $curlHandle] = [ 'url' => $url, @@ -300,6 +344,7 @@ private function initDownload(callable $resolve, callable $reject, string $origi 'resolve' => $resolve, 'reject' => $reject, 'primaryIp' => '', + 'hostKey' => $hostKey, ]; $usingProxy = $proxy->getStatus(' using proxy (%s)'); @@ -333,17 +378,53 @@ public function abortRequest(int $id): void } unset($this->jobs[$id]); } + + // a retry waiting for its delay is not in flight but must not be started either + foreach ($this->delayedJobs as $i => $delayedJob) { + if ((int) $delayedJob['job']['curlHandle'] === $id) { + if (null !== $delayedJob['job']['filename']) { + @unlink($delayedJob['job']['filename'].'~'); + } + unset($this->delayedJobs[$i]); + } + } + } + + /** + * @return int number of retries waiting for their delay, which hold no transfer open + */ + public function countDelayedJobs(): int + { + return count($this->delayedJobs); + } + + /** + * @return bool whether the origin asked for requests to be held back until later, see restartJobWithDelay() + */ + public function isOriginOnHold(string $origin): bool + { + return ($this->retryAfterOrigins[$origin]['until'] ?? 0.0) > microtime(true); } public function tick(): void { + if (count($this->jobs) === 0 && count($this->delayedJobs) === 0) { + return; + } + + $this->restartDueJobs(); + if (count($this->jobs) === 0) { + // only delayed retries are left, so there is no transfer this could hold up and the + // short sleep merely avoids spinning the CPU until the next one comes due + usleep(10000); + return; } $active = true; $this->checkCurlResult(curl_multi_exec($this->multiHandle, $active)); - if (-1 === curl_multi_select($this->multiHandle, $this->selectTimeout)) { + if (-1 === curl_multi_select($this->multiHandle, $this->getSelectTimeout())) { // sleep in case select returns -1 as it can happen on old php versions or some platforms where curl does not manage to do the select usleep(150); } @@ -373,6 +454,16 @@ public function tick(): void $statusCode = null; $response = null; try { + if (null !== $job['hostKey'] && null !== ($distrusted = $this->checkCertificateChain($job['hostKey'], $progress['certinfo'] ?? []))) { + $requestSent = !\defined('CURLOPT_PREREQFUNCTION'); + $e = TlsChainDenylist::createException($job['url'], $distrusted, $requestSent); + if ($requestSent) { + // callers may swallow transport errors, but possibly leaked credentials must be reported + $this->io->writeError(''.$e->getMessage().''); + } + throw $e; + } + // TODO progress if (CURLE_OK !== $errno || $error || $result !== CURLE_OK) { $errno = $errno ?: $result; @@ -444,13 +535,13 @@ public function tick(): void fclose($job['bodyHandle']); $warningsOutput = false; - if ($response->getStatusCode() >= 300 && $response->getHeader('content-type') === 'application/json') { - $warningsOutput = HttpDownloader::outputWarnings($this->io, $job['origin'], json_decode($response->getBody(), true)); + if ($response->getStatusCode() >= 300 && self::isJsonResponse($response)) { + $warningsOutput = ResponseWarnings::output($this->io, $job['origin'], json_decode($response->getBody(), true)); } $result = $this->isAuthenticatedRetryNeeded($job, $response); if ($result['retry']) { - $this->restartJob($job, $job['url'], ['storeAuth' => $result['storeAuth'], 'retries' => $job['attributes']['retries'] + 1]); + $this->restartJob($job, $job['url'], ['storeAuth' => $result['storeAuth'], 'retries' => $job['attributes']['retries'] + 1, 'authRetries' => $job['attributes']['authRetries'] + 1]); continue; } @@ -465,20 +556,14 @@ public function tick(): void // fail 4xx and 5xx responses and capture the response if ($statusCode >= 400 && $statusCode <= 599) { - $retryableStatusCode = in_array($statusCode, [423, 425, 500, 502, 503, 504, 507, 510], true) - // codeload.github.com intermittently returns 400 on reused connections, retry those specifically, see #12958 - || ($statusCode === 400 && parse_url($job['url'], PHP_URL_HOST) === 'codeload.github.com'); - if ( - (!isset($job['options']['http']['method']) || $job['options']['http']['method'] === 'GET') - && $retryableStatusCode - && $job['attributes']['retries'] < $this->maxRetries - ) { - $this->io->writeError('Retrying ('.($job['attributes']['retries'] + 1).') ' . Url::sanitize($job['url']) . ' due to status code '. $statusCode, true, IOInterface::DEBUG); - $this->restartJobWithDelay($job, $job['url'], ['retries' => $job['attributes']['retries'] + 1]); + $statusRetry = $this->isStatusCodeRetryNeeded($job, $response); + if ($statusRetry['retry']) { + $this->io->writeError('Retrying ('.($job['attributes']['retries'] + 1).') ' . Url::sanitize($job['url']) . ' due to status code '. $statusCode . (null !== $statusRetry['delay'] ? ' in '.$statusRetry['delay'].'s as requested by Retry-After' : ''), true, IOInterface::DEBUG); + $this->restartJobWithDelay($job, $job['url'], ['retries' => $job['attributes']['retries'] + 1], $statusRetry['delay'], $statusCode); continue; } - throw $this->failResponse($job, $response, $response->getStatusMessage(), $warningsOutput); + throw $this->failResponse($job, $response, self::getStatusFailureMessage($response), $warningsOutput); } if ($job['attributes']['storeAuth'] !== false) { @@ -508,9 +593,11 @@ public function tick(): void } } + $this->announceRetryAfterWaits(); + foreach ($this->jobs as $i => $curlHandle) { $curlHandle = $this->jobs[$i]['curlHandle']; - $progress = array_diff_key(curl_getinfo($curlHandle), self::$timeInfo); + $progress = array_diff_key(curl_getinfo($curlHandle), self::$ignoredProgressInfo); if ($this->jobs[$i]['progress'] !== $progress) { $this->jobs[$i]['progress'] = $progress; @@ -544,6 +631,21 @@ public function tick(): void } } + /** + * @param array $certInfo + * + * @return string|null name of the distrusted certificate if the host presented one + */ + private function checkCertificateChain(string $hostKey, array $certInfo): ?string + { + // certinfo is only filled for the request which opened the connection, reused ones rely on the earlier verdict + if (count($certInfo) > 0 && null !== ($distrusted = TlsChainDenylist::findDistrustedCertificateInCurlInfo($certInfo))) { + $this->distrustedHosts[$hostKey] = $distrusted; + } + + return $this->distrustedHosts[$hostKey] ?? null; + } + /** * @param Job $job */ @@ -589,7 +691,7 @@ private function handleRedirect(array $job, Response $response): string private function isAuthenticatedRetryNeeded(array $job, Response $response): array { if (in_array($response->getStatusCode(), [401, 403]) && $job['attributes']['retryAuthFailure']) { - $result = $this->authHelper->promptAuthIfNeeded($job['url'], $job['origin'], $response->getStatusCode(), $response->getStatusMessage(), $response->getHeaders(), $job['attributes']['retries'], $response->getBody()); + $result = $this->authHelper->promptAuthIfNeeded($job['url'], $job['origin'], $response->getStatusCode(), $response->getStatusMessage(), $response->getHeaders(), $job['attributes']['authRetries'], $response->getBody()); if ($result['retry']) { return $result; @@ -621,7 +723,7 @@ private function isAuthenticatedRetryNeeded(array $job, Response $response): arr if ($needsAuthRetry) { if ($job['attributes']['retryAuthFailure']) { - $result = $this->authHelper->promptAuthIfNeeded($job['url'], $job['origin'], 401, null, [], $job['attributes']['retries']); + $result = $this->authHelper->promptAuthIfNeeded($job['url'], $job['origin'], 401, null, [], $job['attributes']['authRetries']); if ($result['retry']) { return $result; } @@ -633,11 +735,134 @@ private function isAuthenticatedRetryNeeded(array $job, Response $response): arr return ['retry' => false, 'storeAuth' => false]; } + /** + * Decides whether a 4xx/5xx response should be retried, and how long to wait beforehand + * + * @param Job $job + * @return array{retry: bool, delay: float|null} delay is null when the default backoff applies + */ + private function isStatusCodeRetryNeeded(array $job, Response $response): array + { + $noRetry = ['retry' => false, 'delay' => null]; + + if (isset($job['options']['http']['method']) && $job['options']['http']['method'] !== 'GET') { + return $noRetry; + } + + if ($job['attributes']['retries'] >= $this->maxRetries) { + return $noRetry; + } + + $statusCode = $response->getStatusCode(); + $retryAfter = self::getRetryAfterDelay($response); + + $retryableByStatus = in_array($statusCode, [423, 425, 500, 502, 503, 504, 507, 510], true) + // codeload.github.com intermittently returns 400 on reused connections, retry those specifically, see #12958 + || ($statusCode === 400 && parse_url($job['url'], PHP_URL_HOST) === 'codeload.github.com'); + + // a rate limit is only retried when the server said when to come back + $retryableByHeader = $statusCode === 429 && null !== $retryAfter; + + if (!$retryableByStatus && !$retryableByHeader) { + return $noRetry; + } + + // an interval beyond the cap is not waited for: a status which is retryable anyway falls + // back to the usual backoff, a rate limit has nothing else to go on and fails + if (null !== $retryAfter && $retryAfter > self::MAX_RETRY_AFTER) { + if (!$retryableByStatus) { + return $noRetry; + } + + $retryAfter = null; + } + + // a wait of 0s is no wait, so the usual backoff spaces such retries out instead + return ['retry' => true, 'delay' => null !== $retryAfter && $retryAfter > 0 ? (float) $retryAfter : null]; + } + + /** + * Reads the Retry-After header, in either of the forms RFC 9110 allows, as a number of seconds to wait + * + * @return int|null seconds to wait, or null if there is no usable Retry-After header + */ + private static function getRetryAfterDelay(Response $response): ?int + { + $retryAfter = $response->getHeader('retry-after'); + if (null === $retryAfter) { + return null; + } + + // more digits would overflow an int, and are no interval anyone can mean + if (Preg::isMatch('{^\d{1,9}$}', $retryAfter)) { + return (int) $retryAfter; + } + + // the three date formats RFC 9110 requires recipients to accept, with the leading day name + // skipped as it is redundant and DateTime would otherwise move the date to match it + foreach (['!*, d M Y H:i:s \G\M\T', '!*, d-M-y H:i:s \G\M\T', '!* M j H:i:s Y'] as $format) { + $retryAt = \DateTime::createFromFormat($format, $retryAfter, new \DateTimeZone('UTC')); + if (false !== $retryAt) { + // a date which has already passed means the wait is over and we can retry straight away + return max(0, $retryAt->getTimestamp() - time()); + } + } + + return null; + } + + /** + * Starts the delayed retries whose delay has elapsed + */ + private function restartDueJobs(): void + { + $now = microtime(true); + foreach ($this->delayedJobs as $i => $delayedJob) { + if ($this->getDueAt($delayedJob) > $now) { + continue; + } + + unset($this->delayedJobs[$i]); + try { + $this->restartJob($delayedJob['job'], $delayedJob['url'], $delayedJob['attributes']); + } catch (\Exception $e) { + // an immediate restart is covered by tick()'s try/catch, a deferred one is not + $this->rejectJob($delayedJob['job'], $e); + } + } + } + + /** + * @return float how long curl_multi_select may block for, in seconds + */ + private function getSelectTimeout(): float + { + $timeout = $this->selectTimeout; + $now = microtime(true); + foreach ($this->delayedJobs as $delayedJob) { + // never sleep past the point where a delayed retry comes due + $timeout = min($timeout, max(0.0, $this->getDueAt($delayedJob) - $now)); + } + + return $timeout; + } + + /** + * A Retry-After speaks for the whole origin, so a retry also waits for a later hold the origin asked for + * + * @param DelayedJob $delayedJob + * @return float when the retry may be started, as a timestamp + */ + private function getDueAt(array $delayedJob): float + { + return max($delayedJob['at'], $this->retryAfterOrigins[$delayedJob['job']['origin']]['until'] ?? 0.0); + } + /** * @param Job $job * @param non-empty-string $url * - * @param array{retryAuthFailure?: bool, redirects?: int<0, max>, storeAuth?: 'prompt'|bool, retries?: int<1, max>, ipResolve?: 4|6} $attributes + * @param array{retryAuthFailure?: bool, redirects?: int<0, max>, storeAuth?: 'prompt'|bool, retries?: int<1, max>, authRetries?: int<1, max>, ipResolve?: 4|6} $attributes */ private function restartJob(array $job, string $url, array $attributes = []): void { @@ -655,17 +880,72 @@ private function restartJob(array $job, string $url, array $attributes = []): vo * @param Job $job * @param non-empty-string $url * - * @param array{retryAuthFailure?: bool, redirects?: int<0, max>, storeAuth?: 'prompt'|bool, retries: int<1, max>, ipResolve?: 4|6} $attributes + * @param RetryAttributes $attributes + * @param float|null $retryAfter seconds the response asked to wait before retrying, null to use the default backoff + * @param int $statusCode status code of the response which asked for the wait, only used with $retryAfter */ - private function restartJobWithDelay(array $job, string $url, array $attributes): void + private function restartJobWithDelay(array $job, string $url, array $attributes, ?float $retryAfter = null, int $statusCode = 0): void { - if ($attributes['retries'] >= 3) { - usleep(500000); // half a second delay for 3rd retry and beyond + $now = microtime(true); + if (null !== $retryAfter) { + $delay = $retryAfter; + $until = $now + $retryAfter; + $origin = $this->retryAfterOrigins[$job['origin']] ?? null; + // a hold which has already run out is over, so the origin is announced anew rather than extended + if (null === $origin || $origin['until'] <= $now) { + $this->retryAfterOrigins[$job['origin']] = ['statusCode' => $statusCode, 'announced' => false, 'until' => $until]; + } else { + $this->retryAfterOrigins[$job['origin']]['until'] = max($origin['until'], $until); + } + } elseif ($attributes['retries'] >= 3) { + $delay = 0.5; // half a second delay for 3rd retry and beyond } elseif ($attributes['retries'] >= 2) { - usleep(100000); // 100ms delay for 2nd retry - } // no sleep for the first retry + $delay = 0.1; // 100ms delay for 2nd retry + } else { + $delay = 0.0; // no delay for the first retry + } + + if ($delay <= 0.0 && ($this->retryAfterOrigins[$job['origin']]['until'] ?? 0.0) <= $now) { + $this->restartJob($job, $url, $attributes); + + return; + } - $this->restartJob($job, $url, $attributes); + // sleeping here would stall every other transfer tick() drives, so the retry is scheduled instead + $this->delayedJobs[] = ['job' => $job, 'url' => $url, 'attributes' => $attributes, 'at' => $now + $delay]; + } + + /** + * Tells the user when an origin asked for requests to be retried later, once per origin and episode + * + * The wait can last up to MAX_RETRY_AFTER seconds, which without a word would look like a hang + */ + private function announceRetryAfterWaits(): void + { + $now = microtime(true); + foreach ($this->retryAfterOrigins as $origin => $retryAfterOrigin) { + $pending = 0; + $lastDue = $now; + foreach ($this->delayedJobs as $delayedJob) { + if ($delayedJob['job']['origin'] === $origin) { + $pending++; + $lastDue = max($lastDue, $this->getDueAt($delayedJob)); + } + } + + if (0 === $pending) { + unset($this->retryAfterOrigins[$origin]); + continue; + } + + if ($retryAfterOrigin['announced']) { + continue; + } + + $reason = 429 === $retryAfterOrigin['statusCode'] ? 'is rate limiting requests' : 'responded with status code '.$retryAfterOrigin['statusCode']; + $this->io->writeError(''.$origin.' '.$reason.' ('.$pending.' pending), waiting '.(int) ceil($lastDue - $now).'s before retrying'); + $this->retryAfterOrigins[$origin]['announced'] = true; + } } /** @@ -678,14 +958,36 @@ private function failResponse(array $job, Response $response, string $errorMessa } $details = ''; - // skip dumping the raw JSON body when outputWarnings already presented it cleanly, to avoid duplicate/messy output - if (!$warningsOutput && in_array(strtolower((string) $response->getHeader('content-type')), ['application/json', 'application/json; charset=utf-8'], true)) { + // skip dumping the raw JSON body when ResponseWarnings::output() already presented it cleanly, to avoid duplicate/messy output + if (!$warningsOutput && self::isJsonResponse($response)) { $details = ':'.PHP_EOL.substr($response->getBody(), 0, 200).(strlen($response->getBody()) > 200 ? '...' : ''); } return new TransportException('The "'.Url::sanitize($job['url']).'" file could not be downloaded ('.$errorMessage.')' . $details, $response->getStatusCode()); } + /** + * Describes why a 4xx/5xx response failed the request + * + * A Retry-After which is longer than we wait for is spelled out, as it tells the user whether + * trying again straight away can help or whether to come back later. + */ + private static function getStatusFailureMessage(Response $response): string + { + $message = (string) $response->getStatusMessage(); + $retryAfter = self::getRetryAfterDelay($response); + if (null !== $retryAfter && $retryAfter > self::MAX_RETRY_AFTER) { + $message .= ', the server asked to retry in '.$retryAfter.'s which is longer than the '.self::MAX_RETRY_AFTER.'s Composer is willing to wait, try again later'; + } + + return $message; + } + + private static function isJsonResponse(Response $response): bool + { + return in_array(strtolower((string) $response->getHeader('content-type')), ['application/json', 'application/json; charset=utf-8'], true); + } + /** * @param Job $job */ diff --git a/core/vendor/composer/composer/src/Composer/Util/Http/ResponseWarnings.php b/core/vendor/composer/composer/src/Composer/Util/Http/ResponseWarnings.php new file mode 100644 index 0000000000..064f1cde55 --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Util/Http/ResponseWarnings.php @@ -0,0 +1,127 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Util\Http; + +use Composer\Composer; +use Composer\IO\IOInterface; +use Composer\Package\Version\VersionParser; +use Composer\Pcre\Preg; +use Composer\Semver\Constraint\Constraint; +use Composer\Util\Url; + +/** + * Shows the warnings/infos which repositories can send in their responses + * + * @internal + */ +final class ResponseWarnings +{ + /** + * Warnings which were already shown, keyed by host and warning, see output() + * + * @var array + */ + private static $shownWarnings = []; + + /** + * Shows the warnings/infos of a response, unless the same host already showed the same ones + * + * @param mixed $data the decoded response body, expected to be array{warning?: string, info?: string, warning-versions?: string, info-versions?: string, warnings?: array, infos?: array} + * + * @return bool whether any warning/info was shown, now or before + */ + public static function output(IOInterface $io, string $url, $data): bool + { + if (!is_array($data)) { + return false; + } + + // the parts of the body which are not shown do not make a warning a different one + // key by host so callers passing an origin or a repository url share the same entry, see Url::getOrigin + $host = (string) parse_url($url, PHP_URL_HOST); + if ($host === '') { + $host = $url; + } elseif (is_int($port = parse_url($url, PHP_URL_PORT))) { + $host .= ':'.$port; + } + if ($host === 'repo.packagist.org') { + $host = 'packagist.org'; + } + $shownKey = $host.':'.json_encode(array_intersect_key($data, array_flip(['warning', 'warning-versions', 'info', 'info-versions', 'warnings', 'infos']))); + if (isset(self::$shownWarnings[$shownKey])) { + return true; + } + + $wrote = false; + $cleanMessage = static function ($msg) use ($io) { + if (!$io->isDecorated()) { + $msg = Preg::replace('{'.chr(27).'\\[[;\d]*m}u', '', $msg); + } + + return $msg; + }; + + // legacy warning/info keys + foreach (['warning', 'info'] as $type) { + if (empty($data[$type])) { + continue; + } + + if (!empty($data[$type . '-versions'])) { + $versionParser = new VersionParser(); + $constraint = $versionParser->parseConstraints($data[$type . '-versions']); + $composer = new Constraint('==', $versionParser->normalize(Composer::getVersion())); + if (!$constraint->matches($composer)) { + continue; + } + } + + $io->writeError('<'.$type.'>'.ucfirst($type).' from '.Url::sanitize($url).': '.$cleanMessage($data[$type]).''); + $wrote = true; + } + + // modern Composer 2.2+ format with support for multiple warning/info messages + foreach (['warnings', 'infos'] as $key) { + if (empty($data[$key])) { + continue; + } + + $versionParser = new VersionParser(); + foreach ($data[$key] as $spec) { + $type = substr($key, 0, -1); + $constraint = $versionParser->parseConstraints($spec['versions']); + $composer = new Constraint('==', $versionParser->normalize(Composer::getVersion())); + if (!$constraint->matches($composer)) { + continue; + } + + $io->writeError('<'.$type.'>'.ucfirst($type).' from '.Url::sanitize($url).': '.$cleanMessage($spec['message']).''); + $wrote = true; + } + } + + if ($wrote) { + self::$shownWarnings[$shownKey] = true; + } + + return $wrote; + } + + /** + * Forgets which warnings were shown, so long-running processes can show them again in their next job + */ + public static function reset(): void + { + self::$shownWarnings = []; + } +} diff --git a/core/vendor/composer/composer/src/Composer/Util/Http/TlsChainDenylist.php b/core/vendor/composer/composer/src/Composer/Util/Http/TlsChainDenylist.php new file mode 100644 index 0000000000..6afb940b3d --- /dev/null +++ b/core/vendor/composer/composer/src/Composer/Util/Http/TlsChainDenylist.php @@ -0,0 +1,71 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Util\Http; + +use Composer\CaBundle\CaBundle; +use Composer\Exception\SecurityException; +use Composer\Util\Url; + +/** + * Rejects TLS connections whose presented chain contains a distrusted (e.g. compromised) intermediate + * + * The system CA store may still trust the roots above them, so this cannot be left to the cafile alone. + * + * @internal + */ +final class TlsChainDenylist +{ + /** + * @param array $certInfo as returned by curl_getinfo($handle, CURLINFO_CERTINFO) + * + * @return string|null name of the first distrusted certificate found in the chain + */ + public static function findDistrustedCertificateInCurlInfo(array $certInfo): ?string + { + return CaBundle::findDistrustedCertificate(array_column($certInfo, 'Cert')); + } + + /** + * @return string|null host:port for https URLs, as that is what curl reuses connections by + */ + public static function getHostKey(string $url): ?string + { + if (0 !== stripos($url, 'https://')) { + return null; + } + + $host = parse_url($url, PHP_URL_HOST); + if (!is_string($host)) { + return null; + } + + return strtolower($host).':'.(parse_url($url, PHP_URL_PORT) ?? 443); + } + + /** + * Not a TransportException so that callers falling back on transport errors do not swallow it + * + * @param bool $requestSent whether the request (and any credentials in it) already went out before the chain could be checked + */ + public static function createException(string $url, string $certName, bool $requestSent): SecurityException + { + $message = 'The TLS certificate chain presented for "'.Url::sanitize($url).'" contains the distrusted certificate "'.$certName.'", this may be a man-in-the-middle attack.'; + if ($requestSent) { + $message .= ' The request was already sent before this could be detected (upgrade to PHP 8.4+ with the curl extension to detect it earlier), if it contained authentication credentials they may have been intercepted by an attacker and should be rotated.'; + } else { + $message .= ' The request was aborted before it was sent.'; + } + + return new SecurityException($message); + } +} diff --git a/core/vendor/composer/composer/src/Composer/Util/HttpDownloader.php b/core/vendor/composer/composer/src/Composer/Util/HttpDownloader.php index eae49f40c9..e0757b8b82 100644 --- a/core/vendor/composer/composer/src/Composer/Util/HttpDownloader.php +++ b/core/vendor/composer/composer/src/Composer/Util/HttpDownloader.php @@ -18,9 +18,7 @@ use Composer\Pcre\Preg; use Composer\Util\Http\Response; use Composer\Util\Http\CurlDownloader; -use Composer\Composer; -use Composer\Package\Version\VersionParser; -use Composer\Semver\Constraint\Constraint; +use Composer\Util\Http\ResponseWarnings; use Composer\Exception\IrrecoverableDownloadException; use React\Promise\Promise; use React\Promise\PromiseInterface; @@ -298,7 +296,7 @@ private function addJob(array $request, bool $sync = false): array }); $this->jobs[$job['id']] = &$job; - if ($this->runningJobs < $this->maxJobs) { + if ($this->canStartJob($job)) { $this->startJob($job['id']); } @@ -348,6 +346,21 @@ private function startJob(int $id): void } } + /** + * @param Job $job + */ + private function canStartJob(array $job): bool + { + // a retry waiting for its Retry-After holds no transfer open, so its slot is free for another request meanwhile + $maxJobs = $this->maxJobs + (null !== $this->curl ? $this->curl->countDelayedJobs() : 0); + if ($this->runningJobs >= $maxJobs) { + return false; + } + + // an origin which asked to be left alone gets no new request either, not just no retry + return null === $this->curl || !$this->curl->isOriginOnHold($job['origin']); + } + private function markJobDone(): void { $this->runningJobs--; @@ -383,11 +396,9 @@ public function enableAsync(): void */ public function countActiveJobs(?int $index = null): int { - if ($this->runningJobs < $this->maxJobs) { - foreach ($this->jobs as $job) { - if ($job['status'] === self::STATUS_QUEUED && $this->runningJobs < $this->maxJobs) { - $this->startJob($job['id']); - } + foreach ($this->jobs as $job) { + if ($job['status'] === self::STATUS_QUEUED && $this->canStartJob($job)) { + $this->startJob($job['id']); } } @@ -438,62 +449,13 @@ private function getResponse(int $index): Response /** * @internal + * @deprecated use ResponseWarnings::output() instead * - * @param array{warning?: string, info?: string, warning-versions?: string, info-versions?: string, warnings?: array, infos?: array} $data - * - * @return bool whether any warning/info was actually written to the output + * @param mixed $data */ public static function outputWarnings(IOInterface $io, string $url, $data): bool { - $wrote = false; - $cleanMessage = static function ($msg) use ($io) { - if (!$io->isDecorated()) { - $msg = Preg::replace('{'.chr(27).'\\[[;\d]*m}u', '', $msg); - } - - return $msg; - }; - - // legacy warning/info keys - foreach (['warning', 'info'] as $type) { - if (empty($data[$type])) { - continue; - } - - if (!empty($data[$type . '-versions'])) { - $versionParser = new VersionParser(); - $constraint = $versionParser->parseConstraints($data[$type . '-versions']); - $composer = new Constraint('==', $versionParser->normalize(Composer::getVersion())); - if (!$constraint->matches($composer)) { - continue; - } - } - - $io->writeError('<'.$type.'>'.ucfirst($type).' from '.Url::sanitize($url).': '.$cleanMessage($data[$type]).''); - $wrote = true; - } - - // modern Composer 2.2+ format with support for multiple warning/info messages - foreach (['warnings', 'infos'] as $key) { - if (empty($data[$key])) { - continue; - } - - $versionParser = new VersionParser(); - foreach ($data[$key] as $spec) { - $type = substr($key, 0, -1); - $constraint = $versionParser->parseConstraints($spec['versions']); - $composer = new Constraint('==', $versionParser->normalize(Composer::getVersion())); - if (!$constraint->matches($composer)) { - continue; - } - - $io->writeError('<'.$type.'>'.ucfirst($type).' from '.Url::sanitize($url).': '.$cleanMessage($spec['message']).''); - $wrote = true; - } - } - - return $wrote; + return ResponseWarnings::output($io, $url, $data); } /** diff --git a/core/vendor/composer/composer/src/Composer/Util/Loop.php b/core/vendor/composer/composer/src/Composer/Util/Loop.php index 7cee5c8e66..58917d32f4 100644 --- a/core/vendor/composer/composer/src/Composer/Util/Loop.php +++ b/core/vendor/composer/composer/src/Composer/Util/Loop.php @@ -52,8 +52,9 @@ public function getProcessExecutor(): ?ProcessExecutor /** * @param array> $promises + * @param (callable(int): void)|null $onProgress */ - public function wait(array $promises, ?ProgressBar $progress = null): void + public function wait(array $promises, ?ProgressBar $progress = null, ?callable $onProgress = null): void { $uncaught = null; @@ -70,6 +71,22 @@ static function (\Throwable $e) use (&$uncaught): void { $waitIndex = $this->waitIndex++; $this->currentPromises[$waitIndex] = $promises; + try { + $this->runJobs($progress, $onProgress); + } finally { + unset($this->currentPromises[$waitIndex]); + } + + if (null !== $uncaught) { + throw $uncaught; + } + } + + /** + * @param (callable(int): void)|null $onProgress + */ + private function runJobs(?ProgressBar $progress, ?callable $onProgress): void + { if ($progress) { $totalJobs = 0; $totalJobs += $this->httpDownloader->countActiveJobs(); @@ -91,6 +108,9 @@ static function (\Throwable $e) use (&$uncaught): void { if ($progress && microtime(true) - $lastUpdate > 0.1) { $lastUpdate = microtime(true); $progress->setProgress($progress->getMaxSteps() - $activeJobs); + if ($onProgress !== null && $progress->getMaxSteps() > 0) { + $onProgress((int) floor(100 * $progress->getProgress() / $progress->getMaxSteps())); + } } if (!$activeJobs) { @@ -101,11 +121,9 @@ static function (\Throwable $e) use (&$uncaught): void { // as we skip progress updates if they are too quick, make sure we do one last one here at 100% if ($progress) { $progress->finish(); - } - - unset($this->currentPromises[$waitIndex]); - if (null !== $uncaught) { - throw $uncaught; + if ($onProgress !== null) { + $onProgress(100); + } } } diff --git a/core/vendor/composer/composer/src/Composer/Util/Platform.php b/core/vendor/composer/composer/src/Composer/Util/Platform.php index 6606531596..4e38fdb8ee 100644 --- a/core/vendor/composer/composer/src/Composer/Util/Platform.php +++ b/core/vendor/composer/composer/src/Composer/Util/Platform.php @@ -21,12 +21,138 @@ */ class Platform { + /** + * Matches OS metadata file names, Windows ones case-insensitively + * + * @internal + */ + public const METADATA_NAME_REGEX = '{^(?:\._.*|\.DS_Store|__MACOSX|\.AppleDouble|\.fseventsd|\.Spotlight-V100|\.Trashes|(?i:thumbs\.db|desktop\.ini))$}'; + + /** + * Env vars which indicate that Composer is being run by an AI coding agent, mapped to the agent name + * + * Order matters as the first match wins. AI_AGENT is handled separately, see getAiAgent() + * + * Initially copied from https://github.com/laravel/agent-detector. Thanks! + * + * @internal + */ + public const CODING_AGENT_ENV_VARS = [ + // Amp, CodeBuddy and Cowork set/mirror Claude Code vars, so they must precede them + 'AMP_CURRENT_THREAD_ID' => 'amp', + 'CODEBUDDY' => 'codebuddy', + 'CLAUDE_CODE_IS_COWORK' => 'cowork', + 'CLAUDECODE' => 'claude-code', + 'CLAUDE_CODE' => 'claude-code', + 'CURSOR_AGENT' => 'cursor', + 'CURSOR_SANDBOX' => 'cursor-cli', + // Gemini CLI forks, must precede GEMINI_CLI + 'QWEN_CODE' => 'qwen-code', + 'VECLI_DIR' => 'vecli', + 'GEMINI_CLI' => 'gemini-cli', + 'CODEX_SANDBOX' => 'codex', + 'CODEX_CI' => 'codex', + 'CODEX_THREAD_ID' => 'codex', + 'AUGMENT_AGENT' => 'augment', + 'KILO' => 'kilo-code', // must precede OPENCODE* as Kilo inherits them + 'OPENCODE_CLIENT' => 'opencode', + 'OPENCODE' => 'opencode', + 'CLINE_ACTIVE' => 'cline', + 'CLINE_TASK_ID' => 'cline', + 'ROO_CODE_TASK_ID' => 'roo-code', + 'CRUSH' => 'crush', + 'IFLOW_CLI' => 'iflow-cli', + 'TRAE_AI_SHELL_ID' => 'trae', + 'GOOSE_TERMINAL' => 'goose', + 'OZ_RUN_ID' => 'warp', + 'FIREBENDER_TERMINAL' => 'firebender', + 'GROK_SESSION_ID' => 'grok-build', + 'JUNIE_SHIM_PATH' => 'junie', + 'REPL_ID' => 'replit', + 'COPILOT_MODEL' => 'github-copilot', + 'COPILOT_ALLOW_ALL' => 'github-copilot', + 'COPILOT_GITHUB_TOKEN' => 'github-copilot', + 'COPILOT_CLI' => 'github-copilot', + 'ANTIGRAVITY_AGENT' => 'antigravity', + 'PI_CODING_AGENT' => 'pi', + 'MATTERHORN_SESSION_ID' => 'junie', + 'KIRO_AGENT_PATH' => 'kiro', + ]; + + /** + * Known AI_AGENT values and aliases mapped to agent names + * + * Inspired by https://docs.rs/is-ai-agent + */ + private const KNOWN_AI_AGENT_NAMES = [ + 'amp' => 'amp', + 'amazonq' => 'amazon-q', + 'amazon-q' => 'amazon-q', + 'amazon-q-cli' => 'amazon-q', + 'antigravity' => 'antigravity', + 'augment' => 'augment', + 'augment-cli' => 'augment', + 'claude' => 'claude-code', + 'claude-code' => 'claude-code', + 'claudecode' => 'claude-code', + 'cline' => 'cline', + 'codebuddy' => 'codebuddy', + 'codex' => 'codex', + 'cowork' => 'cowork', + 'crush' => 'crush', + 'cursor' => 'cursor', + 'cursor-cli' => 'cursor-cli', + 'deepseek-harness' => 'deepseek-harness', + 'dsh' => 'deepseek-harness', + 'devin' => 'devin', + 'firebender' => 'firebender', + 'gemini' => 'gemini-cli', + 'gemini-cli' => 'gemini-cli', + 'github-copilot' => 'github-copilot', + 'github-copilot-cli' => 'github-copilot', + 'github_copilot_vscode_agent' => 'github-copilot', + 'github_copilot_app_agent' => 'github-copilot', + 'goose' => 'goose', + 'grok' => 'grok-cli', + 'grok-cli' => 'grok-cli', + 'grok-build' => 'grok-build', + 'hermes' => 'hermes', + 'hermes-agent' => 'hermes', + 'iflow' => 'iflow-cli', + 'iflow-cli' => 'iflow-cli', + 'junie' => 'junie', + 'kilo' => 'kilo-code', + 'kilo-code' => 'kilo-code', + 'kilocode' => 'kilo-code', + 'kiro' => 'kiro', + 'kiro-cli' => 'kiro', + 'openclaw' => 'openclaw', + 'opencode' => 'opencode', + 'openhands' => 'openhands', + 'pi' => 'pi', + 'qwen' => 'qwen-code', + 'qwen-code' => 'qwen-code', + 'qwencode' => 'qwen-code', + 'replit' => 'replit', + 'roo' => 'roo-code', + 'roo-code' => 'roo-code', + 'roocode' => 'roo-code', + 'trae' => 'trae', + 'v0' => 'v0', + 'vecli' => 'vecli', + 'vtcode' => 'vtcode', + 'warp' => 'warp', + 'oz' => 'warp', + ]; + /** @var ?bool */ private static $isVirtualBoxGuest = null; /** @var ?bool */ private static $isWindowsSubsystemForLinux = null; /** @var ?bool */ private static $isDocker = null; + /** @var non-empty-string|false|null */ + private static $aiAgent = null; /** * getcwd() equivalent which always returns a string @@ -141,6 +267,10 @@ public static function putEnv(string $name, string $value): void { putenv($name . '=' . $value); $_SERVER[$name] = $_ENV[$name] = $value; + + if ($name === 'AI_AGENT' || isset(self::CODING_AGENT_ENV_VARS[$name])) { + self::$aiAgent = null; + } } /** @@ -150,6 +280,10 @@ public static function clearEnv(string $name): void { putenv($name); unset($_SERVER[$name], $_ENV[$name]); + + if ($name === 'AI_AGENT' || isset(self::CODING_AGENT_ENV_VARS[$name])) { + self::$aiAgent = null; + } } /** @@ -234,6 +368,57 @@ public static function isWindows(): bool return \defined('PHP_WINDOWS_VERSION_BUILD'); } + /** + * @return ?non-empty-string The name of the AI coding agent running Composer, 'unknown' if it cannot be identified, or null if run by a human + */ + public static function getAiAgent(): ?string + { + // the cache is reset by putEnv/clearEnv when AI_AGENT or one of the CODING_AGENT_ENV_VARS is modified + if (null !== self::$aiAgent) { + return self::$aiAgent === false ? null : self::$aiAgent; + } + + // a known AI_AGENT name wins over other markers, an unknown one (sanitized) is only used as fallback + $fallback = false; + $value = strtolower(trim((string) self::getEnv('AI_AGENT'))); + if (!in_array($value, ['', '0', 'false', 'no', 'off'], true)) { + $name = self::KNOWN_AI_AGENT_NAMES[$value] ?? null; + if ($name === null) { + // strip anything after a separator (e.g. "codex@1.2" or "claude-code v2") and a trailing version + $stripVersion = static function (string $value): string { + return Preg::replace('{-?v?\d+(\.\d+)*$}', '', $value); + }; + $value = Preg::replace('{[\s@/:].*$}s', '', $value); + // also try the part before an underscore for values like "claude-code_2-1-201_agent" + $name = self::KNOWN_AI_AGENT_NAMES[$stripVersion(strtr($value, '_', '-'))] + ?? self::KNOWN_AI_AGENT_NAMES[$stripVersion(explode('_', $value)[0])] + ?? null; + $value = $stripVersion(strtr($value, '_', '-')); + } + if ($name !== null) { + return self::$aiAgent = $name; + } + $value = substr(trim(Preg::replace(['{[^a-z-]+}', '{-{2,}}'], ['', '-'], $value), '-'), 0, 20); + $fallback = $value !== '' ? $value : 'unknown'; + } + + foreach (self::CODING_AGENT_ENV_VARS as $envVar => $name) { + $value = self::getEnv($envVar); + if ($value !== false && $value !== '') { + return self::$aiAgent = $name; + } + } + + // devin does not expose an env var but mounts this directory, cannot check it if open_basedir is set + if (!(bool) ini_get('open_basedir') && file_exists('/opt/.devin')) { + return self::$aiAgent = 'devin'; + } + + self::$aiAgent = $fallback; + + return $fallback === false ? null : $fallback; + } + public static function isDocker(): bool { if (null !== self::$isDocker) { @@ -398,4 +583,14 @@ public static function getDevNull(): string return '/dev/null'; } + + /** + * Whether the top-level segment of an archive entry path is OS metadata (resource forks, thumbnail caches, etc.) + */ + public static function isMetadataPath(string $name): bool + { + $top = explode('/', ltrim(str_replace('\\', '/', $name), '/'))[0]; + + return Preg::isMatch(self::METADATA_NAME_REGEX, $top); + } } diff --git a/core/vendor/composer/composer/src/Composer/Util/ProcessExecutor.php b/core/vendor/composer/composer/src/Composer/Util/ProcessExecutor.php index acd2b44163..85a7dd6bf9 100644 --- a/core/vendor/composer/composer/src/Composer/Util/ProcessExecutor.php +++ b/core/vendor/composer/composer/src/Composer/Util/ProcessExecutor.php @@ -12,6 +12,8 @@ namespace Composer\Util; +use Composer\Console\ProgramStatus; +use Composer\IO\ConsoleIO; use Composer\IO\IOInterface; use Composer\Pcre\Preg; use Seld\Signal\SignalHandler; @@ -175,6 +177,9 @@ function (string $signal) { } catch (ProcessSignaledException $e) { if ($signalHandler->isTriggered()) { // exiting as we were signaled and the child process exited too due to the signal + if ($this->io instanceof ConsoleIO) { + $this->io->writeProgramStatus(ProgramStatus::idle()); + } $signalHandler->exitWithLastSignal(); } } finally { @@ -481,8 +486,19 @@ private function outputCommandRun($command, ?string $cwd, bool $async): void return; } + if (is_array($command)) { + foreach ($command as $index => $arg) { + if ($arg === '--password' && isset($command[$index + 1])) { + $command[$index + 1] = '***'; + } elseif (str_starts_with($arg, '--password=')) { + $command[$index] = '--password=***'; + } + } + } + $commandString = is_string($command) ? $command : implode(' ', array_map(self::class.'::escape', $command)); $safeCommand = Url::sanitize($commandString); + // array commands are redacted above, this covers string commands e.g. from plugins $safeCommand = Preg::replace("{--password (.*[^\\\\]\') }", '--password \'***\' ', $safeCommand); $this->io->writeError('Executing'.($async ? ' async' : '').' command ('.($cwd ?: 'CWD').'): '.$safeCommand); } diff --git a/core/vendor/composer/composer/src/Composer/Util/RemoteFilesystem.php b/core/vendor/composer/composer/src/Composer/Util/RemoteFilesystem.php index d7996ef042..3e83cf38e5 100644 --- a/core/vendor/composer/composer/src/Composer/Util/RemoteFilesystem.php +++ b/core/vendor/composer/composer/src/Composer/Util/RemoteFilesystem.php @@ -13,13 +13,16 @@ namespace Composer\Util; use Closure; +use Composer\CaBundle\CaBundle; use Composer\Config; use Composer\Downloader\MaxFileSizeExceededException; use Composer\IO\IOInterface; use Composer\Downloader\TransportException; use Composer\Pcre\Preg; use Composer\Util\Http\Response; +use Composer\Util\Http\TlsChainDenylist; use Composer\Util\Http\ProxyManager; +use Composer\Util\Http\ResponseWarnings; /** * @internal @@ -65,6 +68,8 @@ class RemoteFilesystem private $redirects; /** @var int */ private $maxRedirects = 20; + /** @var array hosts which presented a distrusted TLS chain, keyed by host:port */ + private $distrustedHosts = []; /** * Constructor. @@ -275,6 +280,14 @@ protected function get(string $originUrl, string $fileUrl, array $additionalOpti unset($options['max_file_size']); } + $hostKey = TlsChainDenylist::getHostKey($fileUrl); + if (null !== $hostKey && isset($this->distrustedHosts[$hostKey])) { + throw TlsChainDenylist::createException($fileUrl, $this->distrustedHosts[$hostKey], false); + } + if (null !== $hostKey) { + $options['ssl']['capture_peer_cert_chain'] = true; + } + $ctx = StreamContextFactory::getContext($fileUrl, $options, ['notification' => Closure::fromCallable([$this, 'callbackGet'])]); $proxy = ProxyManager::getInstance()->getProxyForRequest($fileUrl); @@ -306,10 +319,21 @@ protected function get(string $originUrl, string $fileUrl, array $additionalOpti try { $result = $this->getRemoteContents($originUrl, $fileUrl, $ctx, $http_response_header, $maxFileSize); + // streams offer no hook between the TLS handshake and sending the request, so this can only be checked afterwards + $peerChain = null !== $hostKey ? stream_context_get_options($ctx)['ssl']['peer_certificate_chain'] ?? null : null; + if (is_array($peerChain) && null !== ($distrusted = CaBundle::findDistrustedCertificate($peerChain))) { + $result = false; + $this->distrustedHosts[$hostKey] = $distrusted; + $e = TlsChainDenylist::createException($fileUrl, $distrusted, true); + // callers may swallow transport errors, but possibly leaked credentials must be reported + $this->io->writeError(''.$e->getMessage().''); + throw $e; + } + if (!empty($http_response_header[0])) { $statusCode = self::findStatusCode($http_response_header); if ($statusCode >= 300 && Response::findHeaderValue($http_response_header, 'content-type') === 'application/json') { - HttpDownloader::outputWarnings($this->io, $originUrl, json_decode($result, true)); + ResponseWarnings::output($this->io, $originUrl, json_decode($result, true)); } if (in_array($statusCode, [401, 403]) && $retryAuthFailure) { diff --git a/core/vendor/composer/composer/src/Composer/Util/StreamContextFactory.php b/core/vendor/composer/composer/src/Composer/Util/StreamContextFactory.php index 9f4b59c380..2e7d3c66a0 100644 --- a/core/vendor/composer/composer/src/Composer/Util/StreamContextFactory.php +++ b/core/vendor/composer/composer/src/Composer/Util/StreamContextFactory.php @@ -124,8 +124,9 @@ public static function initOptions(string $url, array $options, bool $forCurl = if ($operation !== null && $operation !== $runningCommand) { $runningCommand = $runningCommand !== null ? $runningCommand.','.$operation : $operation; } + $aiAgent = Platform::getAiAgent(); $options['http']['header'][] = sprintf( - 'User-Agent: Composer/%s (%s; %s; %s; %s%s%s%s)', + 'User-Agent: Composer/%s (%s; %s; %s; %s%s%s%s%s)', Composer::getVersion(), function_exists('php_uname') ? php_uname('s') : 'Unknown', function_exists('php_uname') ? php_uname('r') : 'Unknown', @@ -133,6 +134,7 @@ function_exists('php_uname') ? php_uname('r') : 'Unknown', $httpVersion, $platformPhpVersion ? '; Platform-PHP '.$platformPhpVersion : '', Platform::getEnv('CI') ? '; CI' : '', + $aiAgent !== null ? '; agent:'.$aiAgent : '', $runningCommand !== null ? '; cmd:'.$runningCommand : '' ); } diff --git a/core/vendor/composer/composer/src/Composer/Util/Tar.php b/core/vendor/composer/composer/src/Composer/Util/Tar.php index 8b724a83d0..774fabd750 100644 --- a/core/vendor/composer/composer/src/Composer/Util/Tar.php +++ b/core/vendor/composer/composer/src/Composer/Util/Tar.php @@ -44,6 +44,10 @@ private static function extractComposerJsonFromFolder(\PharData $phar): string foreach ($phar as $folderFile) { $name = $folderFile->getBasename(); + if (Platform::isMetadataPath($name)) { + continue; + } + if ($folderFile->isDir()) { $topLevelPaths[$name] = true; if (\count($topLevelPaths) > 1) { diff --git a/core/vendor/composer/composer/src/Composer/Util/Url.php b/core/vendor/composer/composer/src/Composer/Util/Url.php index c6d0706f29..c166944b3b 100644 --- a/core/vendor/composer/composer/src/Composer/Util/Url.php +++ b/core/vendor/composer/composer/src/Composer/Util/Url.php @@ -135,7 +135,7 @@ public static function sanitize(string $url): string // matches every scheme://user:pass@ in the string as URLs are usually embedded in a longer // message, plus a scheme-less one at the very start for URLs passed in on their own - $url = Preg::replaceCallback('{(?:(?P[a-z0-9][a-z0-9+.-]*://)|\A)(?P[^:/\s?#]*)(?::(?P[^\s/?#]+))?@}i', static function ($m): string { + $url = Preg::replaceCallback('{(?:(?P[a-z0-9][a-z0-9+.-]*://)|\A)(?P[^:/\s?#]*)(?::(?P[^\s/?#]*))?@}i', static function ($m): string { $user = self::sanitizeUsername($m['user']); if (($m['password'] ?? '') !== '') { return $m['prefix'].$user.':***@'; @@ -154,7 +154,13 @@ public static function sanitize(string $url): string */ public static function stripCredentials(string $url): string { - return Preg::replace('{://[^/\s?#]+@}', '://', $url); + return Preg::replaceCallback('{(?P[a-z0-9][a-z0-9+.-]*://)(?P[^:/\s?#]*)(?::(?P[^\s/?#]*))?@}i', static function ($m): string { + if (($m['password'] ?? '') === '' && !Preg::isMatch('{^https?://$}i', $m['prefix'])) { + return $m['prefix'].$m['user'].'@'; + } + + return $m['prefix']; + }, $url); } /** diff --git a/core/vendor/composer/composer/src/Composer/Util/Zip.php b/core/vendor/composer/composer/src/Composer/Util/Zip.php index 9fd8f07857..08c6f58f06 100644 --- a/core/vendor/composer/composer/src/Composer/Util/Zip.php +++ b/core/vendor/composer/composer/src/Composer/Util/Zip.php @@ -67,10 +67,13 @@ private static function locateFile(\ZipArchive $zip, string $filename): int $topLevelPaths = []; for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); + if (false === $name) { + continue; + } $dirname = dirname($name); - // ignore OSX specific resource fork folder - if (strpos($name, '__MACOSX') !== false) { + // ignore well-known OS/editor metadata entries (resource forks, thumbnail caches, etc.) + if (Platform::isMetadataPath($name)) { continue; } diff --git a/core/vendor/composer/installed.json b/core/vendor/composer/installed.json index 4edf62d6cc..87f3e93136 100644 --- a/core/vendor/composer/installed.json +++ b/core/vendor/composer/installed.json @@ -226,17 +226,17 @@ }, { "name": "composer/ca-bundle", - "version": "1.6.0", - "version_normalized": "1.6.0.0", + "version": "1.6.1", + "version_normalized": "1.6.1.0", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "2c99cf2e92516985dea4914812b93d9daf01cdd6" + "reference": "0c64479fae6952ba4cf021a72c0ac530394c91d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/2c99cf2e92516985dea4914812b93d9daf01cdd6", - "reference": "2c99cf2e92516985dea4914812b93d9daf01cdd6", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/0c64479fae6952ba4cf021a72c0ac530394c91d4", + "reference": "0c64479fae6952ba4cf021a72c0ac530394c91d4", "shasum": "" }, "require": { @@ -250,7 +250,7 @@ "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0" }, - "time": "2026-10-20T20:14:05+00:00", + "time": "2026-10-29T14:10:42+00:00", "type": "library", "extra": { "branch-alias": { @@ -285,7 +285,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.6.0" + "source": "https://github.com/composer/ca-bundle/tree/1.6.1" }, "funding": [ { @@ -373,22 +373,22 @@ }, { "name": "composer/composer", - "version": "2.10.3", - "version_normalized": "2.10.3.0", + "version": "2.11.0", + "version_normalized": "2.11.0.0", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "f0de0bf90226853b841672f086d8b58b02332504" + "reference": "e52c65bed1de6aa272cdde4b7725ca6653d7b06a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/f0de0bf90226853b841672f086d8b58b02332504", - "reference": "f0de0bf90226853b841672f086d8b58b02332504", + "url": "https://api.github.com/repos/composer/composer/zipball/e52c65bed1de6aa272cdde4b7725ca6653d7b06a", + "reference": "e52c65bed1de6aa272cdde4b7725ca6653d7b06a", "shasum": "" }, "require": { - "composer/ca-bundle": "^1.5", - "composer/class-map-generator": "^1.4.0", + "composer/ca-bundle": "^1.6", + "composer/class-map-generator": "^1.8.0", "composer/metadata-minifier": "^1.0", "composer/pcre": "^2.3 || ^3.3", "composer/semver": "^3.3", @@ -397,7 +397,7 @@ "ext-filter": "*", "ext-hash": "*", "ext-json": "*", - "justinrainbow/json-schema": "^6.5.1", + "justinrainbow/json-schema": "^6.6.3", "php": "^7.2.5 || ^8.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "react/promise": "^3.3", @@ -414,7 +414,7 @@ "symfony/process": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.11.8", + "phpstan/phpstan": "1.12.33", "phpstan/phpstan-deprecation-rules": "^1.2.0", "phpstan/phpstan-phpunit": "^1.4.0", "phpstan/phpstan-strict-rules": "^1.6.0", @@ -427,7 +427,7 @@ "ext-zip": "Allows direct extraction of ZIP archives (unzip/7z binaries will be used instead if available)", "ext-zlib": "Enables gzip for HTTP requests" }, - "time": "2026-08-27T11:34:23+00:00", + "time": "2026-10-29T09:23:20+00:00", "bin": [ "bin/composer" ], @@ -439,7 +439,7 @@ ] }, "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" } }, "installation-source": "dist", @@ -475,7 +475,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.10.3" + "source": "https://github.com/composer/composer/tree/2.11.0" }, "funding": [ { @@ -638,17 +638,17 @@ }, { "name": "composer/semver", - "version": "3.5.1", - "version_normalized": "3.5.1.0", + "version": "3.6.0", + "version_normalized": "3.6.0.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "528948210cc46f967a4b98e83b19e76f2b504993" + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", - "reference": "528948210cc46f967a4b98e83b19e76f2b504993", + "url": "https://api.github.com/repos/composer/semver/zipball/d92543b7ede9c4ec6b59853cefb061e39866d2cf", + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf", "shasum": "" }, "require": { @@ -658,7 +658,7 @@ "phpstan/phpstan": "^1.11", "symfony/phpunit-bridge": "^3 || ^7" }, - "time": "2026-10-05T13:12:12+00:00", + "time": "2026-10-29T09:13:34+00:00", "type": "library", "extra": { "branch-alias": { @@ -702,7 +702,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.5.1" + "source": "https://github.com/composer/semver/tree/3.6.0" }, "funding": [ { @@ -1120,30 +1120,30 @@ }, { "name": "doctrine/inflector", - "version": "2.1.0", - "version_normalized": "2.1.0.0", + "version": "2.1.1", + "version_normalized": "2.1.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + "reference": "46351b6812b7b6d52b82c3efa5dfb786a198ec88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", - "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/46351b6812b7b6d52b82c3efa5dfb786a198ec88", + "reference": "46351b6812b7b6d52b82c3efa5dfb786a198ec88", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^12.0 || ^13.0", + "doctrine/coding-standard": "^12.0 || ^14.0", "phpstan/phpstan": "^1.12 || ^2.0", "phpstan/phpstan-phpunit": "^1.4 || ^2.0", "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", "phpunit/phpunit": "^8.5 || ^12.2" }, - "time": "2025-08-10T19:31:58+00:00", + "time": "2026-10-26T08:14:14+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -1193,7 +1193,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.1.0" + "source": "https://github.com/doctrine/inflector/tree/2.1.1" }, "funding": [ { @@ -1645,17 +1645,17 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.16.0", - "version_normalized": "7.16.0.0", + "version": "7.16.1", + "version_normalized": "7.16.1.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "ffb62ec787050ae4fa9449cdd6b80335db69bda2" + "reference": "81755d989b3007ca7f88a069857553cd08e7dc2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ffb62ec787050ae4fa9449cdd6b80335db69bda2", - "reference": "ffb62ec787050ae4fa9449cdd6b80335db69bda2", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/81755d989b3007ca7f88a069857553cd08e7dc2e", + "reference": "81755d989b3007ca7f88a069857553cd08e7dc2e", "shasum": "" }, "require": { @@ -1684,7 +1684,7 @@ "ext-intl": "Required for Internationalized Domain Name (IDN) support", "psr/log": "Required for using the Log middleware" }, - "time": "2026-10-18T13:38:36+00:00", + "time": "2026-10-25T21:35:21+00:00", "type": "library", "extra": { "bamarni-bin": { @@ -1756,7 +1756,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.16.0" + "source": "https://github.com/guzzle/guzzle/tree/7.16.1" }, "funding": [ { @@ -2074,8 +2074,8 @@ }, { "name": "illuminate/bus", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/bus.git", @@ -2130,8 +2130,8 @@ }, { "name": "illuminate/cache", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/cache.git", @@ -2195,8 +2195,8 @@ }, { "name": "illuminate/collections", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", @@ -2258,8 +2258,8 @@ }, { "name": "illuminate/conditionable", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -2307,8 +2307,8 @@ }, { "name": "illuminate/config", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/config.git", @@ -2358,8 +2358,8 @@ }, { "name": "illuminate/console", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/console.git", @@ -2427,8 +2427,8 @@ }, { "name": "illuminate/container", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/container.git", @@ -2492,8 +2492,8 @@ }, { "name": "illuminate/contracts", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", @@ -2543,8 +2543,8 @@ }, { "name": "illuminate/database", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/database.git", @@ -2619,8 +2619,8 @@ }, { "name": "illuminate/events", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/events.git", @@ -2677,8 +2677,8 @@ }, { "name": "illuminate/filesystem", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/filesystem.git", @@ -2747,8 +2747,8 @@ }, { "name": "illuminate/http", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/http.git", @@ -2812,8 +2812,8 @@ }, { "name": "illuminate/log", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/log.git", @@ -2868,8 +2868,8 @@ }, { "name": "illuminate/macroable", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -2917,8 +2917,8 @@ }, { "name": "illuminate/pagination", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/pagination.git", @@ -2970,8 +2970,8 @@ }, { "name": "illuminate/pipeline", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/pipeline.git", @@ -3025,8 +3025,8 @@ }, { "name": "illuminate/queue", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/queue.git", @@ -3095,8 +3095,8 @@ }, { "name": "illuminate/redis", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/redis.git", @@ -3152,8 +3152,8 @@ }, { "name": "illuminate/reflection", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/reflection.git", @@ -3206,8 +3206,8 @@ }, { "name": "illuminate/routing", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/routing.git", @@ -3275,8 +3275,8 @@ }, { "name": "illuminate/session", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/session.git", @@ -3335,17 +3335,17 @@ }, { "name": "illuminate/support", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "9e9ff99fc4405664faa02f231a25aaf968c6f02b" + "reference": "6427b436b592d05050b506930566f47f85ecebf0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/9e9ff99fc4405664faa02f231a25aaf968c6f02b", - "reference": "9e9ff99fc4405664faa02f231a25aaf968c6f02b", + "url": "https://api.github.com/repos/illuminate/support/zipball/6427b436b592d05050b506930566f47f85ecebf0", + "reference": "6427b436b592d05050b506930566f47f85ecebf0", "shasum": "" }, "require": { @@ -3381,7 +3381,7 @@ "symfony/var-dumper": "Required to use the dd function (^7.2).", "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." }, - "time": "2026-10-06T13:17:38+00:00", + "time": "2026-10-24T14:38:23+00:00", "type": "library", "extra": { "branch-alias": { @@ -3418,8 +3418,8 @@ }, { "name": "illuminate/translation", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/translation.git", @@ -3472,8 +3472,8 @@ }, { "name": "illuminate/validation", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/validation.git", @@ -3538,8 +3538,8 @@ }, { "name": "illuminate/view", - "version": "v12.70.0", - "version_normalized": "12.70.0.0", + "version": "v12.70.2", + "version_normalized": "12.70.2.0", "source": { "type": "git", "url": "https://github.com/illuminate/view.git", @@ -3655,17 +3655,17 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.13.1", - "version_normalized": "6.13.1.0", + "version": "6.14.0", + "version_normalized": "6.14.0.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "e54aacc4c0657b3a4af4f628428d2702146828c6" + "reference": "bae415e135dd68260c2d020ba392850231e9b1ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/e54aacc4c0657b3a4af4f628428d2702146828c6", - "reference": "e54aacc4c0657b3a4af4f628428d2702146828c6", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bae415e135dd68260c2d020ba392850231e9b1ab", + "reference": "bae415e135dd68260c2d020ba392850231e9b1ab", "shasum": "" }, "require": { @@ -3682,7 +3682,7 @@ "phpstan/phpstan": "^1.12", "phpunit/phpunit": "^8.5" }, - "time": "2026-09-30T07:42:28+00:00", + "time": "2026-10-24T08:53:34+00:00", "bin": [ "bin/validate-json" ], @@ -3717,7 +3717,7 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.13.1" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.14.0" }, "install-path": "../justinrainbow/json-schema" }, @@ -4229,17 +4229,17 @@ }, { "name": "nesbot/carbon", - "version": "3.15.0", - "version_normalized": "3.15.0.0", + "version": "3.15.1", + "version_normalized": "3.15.1.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "217d38553209bdb44e01590d9e9a2e4df8354cce" + "reference": "ce0e5636fae7d1e99856eb70cae4c719a284aa08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/217d38553209bdb44e01590d9e9a2e4df8354cce", - "reference": "217d38553209bdb44e01590d9e9a2e4df8354cce", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ce0e5636fae7d1e99856eb70cae4c719a284aa08", + "reference": "ce0e5636fae7d1e99856eb70cae4c719a284aa08", "shasum": "" }, "require": { @@ -4266,7 +4266,7 @@ "phpunit/phpunit": "^10.5.53", "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, - "time": "2026-10-18T18:27:53+00:00", + "time": "2026-10-25T14:32:48+00:00", "bin": [ "bin/carbon" ], @@ -6237,17 +6237,17 @@ }, { "name": "symfony/console", - "version": "v7.4.21", - "version_normalized": "7.4.21.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820" + "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/21f1bc2296f7ff6043241a8dcbc09559b3732820", - "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820", + "url": "https://api.github.com/repos/symfony/console/zipball/8012f842110b414fd1cc641adb0ed8423353b0bb", + "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb", "shasum": "" }, "require": { @@ -6280,7 +6280,7 @@ "symfony/stopwatch": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0" }, - "time": "2026-10-12T18:45:38+00:00", + "time": "2026-10-28T11:08:27+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -6314,7 +6314,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.21" + "source": "https://github.com/symfony/console/tree/v7.4.22" }, "funding": [ { @@ -6412,17 +6412,17 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.20", - "version_normalized": "7.4.20.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0" + "reference": "545852cfe9c4979c82389155b4d8d05f8864e4f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", - "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/545852cfe9c4979c82389155b4d8d05f8864e4f1", + "reference": "545852cfe9c4979c82389155b4d8d05f8864e4f1", "shasum": "" }, "require": { @@ -6442,7 +6442,7 @@ "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, - "time": "2026-09-22T18:56:40+00:00", + "time": "2026-10-25T14:40:16+00:00", "bin": [ "Resources/bin/patch-type-declarations" ], @@ -6473,7 +6473,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.20" + "source": "https://github.com/symfony/error-handler/tree/v7.4.22" }, "funding": [ { @@ -6741,17 +6741,17 @@ }, { "name": "symfony/finder", - "version": "v7.4.20", - "version_normalized": "7.4.20.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" + "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", - "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "url": "https://api.github.com/repos/symfony/finder/zipball/e13420a8e0a76bdde1e7be09f923e62ad13739f2", + "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2", "shasum": "" }, "require": { @@ -6760,7 +6760,7 @@ "require-dev": { "symfony/filesystem": "^6.4|^7.0|^8.0" }, - "time": "2026-09-26T06:31:10+00:00", + "time": "2026-10-19T16:56:54+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -6788,7 +6788,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.20" + "source": "https://github.com/symfony/finder/tree/v7.4.22" }, "funding": [ { @@ -6812,17 +6812,17 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.21", - "version_normalized": "7.4.21.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31" + "reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", - "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/63eda1d0c4de229d6b3e825709e49dd5c149699a", + "reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a", "shasum": "" }, "require": { @@ -6845,7 +6845,7 @@ "symfony/mime": "^6.4|^7.0|^8.0", "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, - "time": "2026-10-08T16:56:12+00:00", + "time": "2026-10-27T13:26:03+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -6873,7 +6873,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.21" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.22" }, "funding": [ { @@ -6897,17 +6897,17 @@ }, { "name": "symfony/http-kernel", - "version": "v7.4.21", - "version_normalized": "7.4.21.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "4779fce7dedacb8801b2b61c703ef99159502331" + "reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/4779fce7dedacb8801b2b61c703ef99159502331", - "reference": "4779fce7dedacb8801b2b61c703ef99159502331", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/87e727b33983238d3f2ea50f9f62265499ca1a6d", + "reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d", "shasum": "" }, "require": { @@ -6967,7 +6967,7 @@ "symfony/var-exporter": "^6.4|^7.0|^8.0", "twig/twig": "^3.12|^4.0" }, - "time": "2026-10-14T06:44:08+00:00", + "time": "2026-10-28T18:18:28+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -6995,7 +6995,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.21" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.22" }, "funding": [ { @@ -7019,17 +7019,17 @@ }, { "name": "symfony/mime", - "version": "v7.4.19", - "version_normalized": "7.4.19.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693" + "reference": "94a4c017435bc515f5ac80421a2c555fb9ca3676" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", - "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "url": "https://api.github.com/repos/symfony/mime/zipball/94a4c017435bc515f5ac80421a2c555fb9ca3676", + "reference": "94a4c017435bc515f5ac80421a2c555fb9ca3676", "shasum": "" }, "require": { @@ -7055,7 +7055,7 @@ "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" }, - "time": "2026-09-04T10:45:44+00:00", + "time": "2026-10-28T17:57:58+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -7087,7 +7087,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.19" + "source": "https://github.com/symfony/mime/tree/v7.4.22" }, "funding": [ { @@ -7197,17 +7197,17 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.43.0", - "version_normalized": "1.43.0.0", + "version": "v1.43.3", + "version_normalized": "1.43.3.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", - "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5a8f33c87aeecffce24e721b5064fab05f4becf8", + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8", "shasum": "" }, "require": { @@ -7216,7 +7216,7 @@ "suggest": { "ext-intl": "For best performance" }, - "time": "2026-09-25T15:46:47+00:00", + "time": "2026-10-15T09:18:53+00:00", "type": "library", "extra": { "thanks": { @@ -7258,7 +7258,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.3" }, "funding": [ { @@ -7460,17 +7460,17 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.43.0", - "version_normalized": "1.43.0.0", + "version": "v1.43.3", + "version_normalized": "1.43.3.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", - "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a", "shasum": "" }, "require": { @@ -7483,7 +7483,7 @@ "suggest": { "ext-mbstring": "For best performance" }, - "time": "2026-09-24T15:19:04+00:00", + "time": "2026-10-28T09:35:49+00:00", "type": "library", "extra": { "thanks": { @@ -7524,7 +7524,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.3" }, "funding": [ { @@ -7800,23 +7800,23 @@ }, { "name": "symfony/process", - "version": "v7.4.21", - "version_normalized": "7.4.21.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83" + "reference": "a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/e5247f4a5e0797d232ad8e8e6803aeb341843c83", - "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "url": "https://api.github.com/repos/symfony/process/zipball/a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a", + "reference": "a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a", "shasum": "" }, "require": { "php": ">=8.2" }, - "time": "2026-10-08T16:56:12+00:00", + "time": "2026-10-25T14:40:16+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -7844,7 +7844,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.21" + "source": "https://github.com/symfony/process/tree/v7.4.22" }, "funding": [ { @@ -7868,17 +7868,17 @@ }, { "name": "symfony/routing", - "version": "v7.4.21", - "version_normalized": "7.4.21.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b" + "reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/7a344cf871532d081875bfdd18f3dff8dca36c5b", - "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b", + "url": "https://api.github.com/repos/symfony/routing/zipball/f79ed6fbf60d9e32d358054f1915295f3b8a6f7a", + "reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a", "shasum": "" }, "require": { @@ -7898,7 +7898,7 @@ "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/yaml": "^6.4|^7.0|^8.0" }, - "time": "2026-10-11T18:40:59+00:00", + "time": "2026-10-26T21:52:50+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -7932,7 +7932,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.21" + "source": "https://github.com/symfony/routing/tree/v7.4.22" }, "funding": [ { @@ -8046,17 +8046,17 @@ }, { "name": "symfony/string", - "version": "v7.4.21", - "version_normalized": "7.4.21.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981" + "reference": "8dff7da3a56638f1d189c5368183294102de4ced" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", - "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "url": "https://api.github.com/repos/symfony/string/zipball/8dff7da3a56638f1d189c5368183294102de4ced", + "reference": "8dff7da3a56638f1d189c5368183294102de4ced", "shasum": "" }, "require": { @@ -8077,7 +8077,7 @@ "symfony/translation-contracts": "^2.5|^3.0", "symfony/var-exporter": "^6.4|^7.0|^8.0" }, - "time": "2026-10-03T15:15:30+00:00", + "time": "2026-10-23T16:51:52+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -8116,7 +8116,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.21" + "source": "https://github.com/symfony/string/tree/v7.4.22" }, "funding": [ { @@ -8140,17 +8140,17 @@ }, { "name": "symfony/translation", - "version": "v7.4.17", - "version_normalized": "7.4.17.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" + "reference": "a892cd1807bfaf0a10690359c47fdf95b162ef22" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", - "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "url": "https://api.github.com/repos/symfony/translation/zipball/a892cd1807bfaf0a10690359c47fdf95b162ef22", + "reference": "a892cd1807bfaf0a10690359c47fdf95b162ef22", "shasum": "" }, "require": { @@ -8188,7 +8188,7 @@ "symfony/service-contracts": "^2.5|^3", "symfony/yaml": "^6.4|^7.0|^8.0" }, - "time": "2026-08-21T17:40:08+00:00", + "time": "2026-10-25T14:40:16+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -8219,7 +8219,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.17" + "source": "https://github.com/symfony/translation/tree/v7.4.22" }, "funding": [ { @@ -8328,17 +8328,17 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.21", - "version_normalized": "7.4.21.0", + "version": "v7.4.22", + "version_normalized": "7.4.22.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6" + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/55d06b02f49eca9bff61828548988e6f8763e8a6", - "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", "shasum": "" }, "require": { @@ -8356,7 +8356,7 @@ "symfony/uid": "^6.4|^7.0|^8.0", "twig/twig": "^3.12|^4.0" }, - "time": "2026-10-13T05:48:33+00:00", + "time": "2026-10-25T21:04:04+00:00", "bin": [ "Resources/bin/var-dump-server" ], @@ -8394,7 +8394,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.21" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.22" }, "funding": [ { @@ -8418,23 +8418,23 @@ }, { "name": "tracy/tracy", - "version": "v2.12.1", - "version_normalized": "2.12.1.0", + "version": "v2.12.2", + "version_normalized": "2.12.2.0", "source": { "type": "git", "url": "https://github.com/nette/tracy.git", - "reference": "90d24872cd97202f79281aac7eb09c27ec48bf69" + "reference": "2664bd9456cb745ff1fb0b31efa79fe2f411bef3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/tracy/zipball/90d24872cd97202f79281aac7eb09c27ec48bf69", - "reference": "90d24872cd97202f79281aac7eb09c27ec48bf69", + "url": "https://api.github.com/repos/nette/tracy/zipball/2664bd9456cb745ff1fb0b31efa79fe2f411bef3", + "reference": "2664bd9456cb745ff1fb0b31efa79fe2f411bef3", "shasum": "" }, "require": { "ext-json": "*", "ext-session": "*", - "php": "8.2 - 8.5" + "php": "8.2 - 8.6" }, "conflict": { "nette/di": "<3.0" @@ -8448,10 +8448,10 @@ "nette/tester": "^2.6", "nette/utils": "^3.0 || ^4.0", "phpstan/extension-installer": "^1.4@stable", - "phpstan/phpstan": "^2.1@stable", + "phpstan/phpstan": "^2.3@stable", "psr/log": "^1.0 || ^2.0 || ^3.0" }, - "time": "2026-08-18T10:56:11+00:00", + "time": "2026-10-21T05:14:18+00:00", "type": "library", "extra": { "branch-alias": { @@ -8478,40 +8478,41 @@ { "name": "David Grudl", "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" } ], - "description": "😎 Tracy: the addictive tool to ease debugging PHP code for cool developers. Friendly design, logging, profiler, advanced features like debugging AJAX calls or CLI support. You will love it.", + "description": "😎 Tracy: the addictive PHP debugger. Clear error pages with full context, a debug bar, readable dumps, AJAX and CLI support, and logging with e-mail alerts in production.", "homepage": "https://tracy.nette.org", "keywords": [ - "Xdebug", - "debug", + "debug bar", "debugger", + "debugging", + "dump", + "error handler", + "logging", "nette", - "profiler" + "tracy", + "var_dump", + "whoops" ], "support": { "issues": "https://github.com/nette/tracy/issues", - "source": "https://github.com/nette/tracy/tree/v2.12.1" + "source": "https://github.com/nette/tracy/tree/v2.12.2" }, "install-path": "../tracy/tracy" }, { "name": "vlucas/phpdotenv", - "version": "v5.7.0", - "version_normalized": "5.7.0.0", + "version": "v5.8.0", + "version_normalized": "5.8.0.0", "source": { "type": "git", - "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + "url": "https://github.com/phpdotenv/phpdotenv.git", + "reference": "96149103e62ac67e915f6d2ef59b72dee2e011e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", - "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/96149103e62ac67e915f6d2ef59b72dee2e011e0", + "reference": "96149103e62ac67e915f6d2ef59b72dee2e011e0", "shasum": "" }, "require": { @@ -8531,7 +8532,7 @@ "suggest": { "ext-filter": "Required to use the boolean validator." }, - "time": "2026-08-24T18:07:49+00:00", + "time": "2026-10-25T16:26:19+00:00", "type": "library", "extra": { "bamarni-bin": { @@ -8539,7 +8540,7 @@ "forward-command": false }, "branch-alias": { - "dev-master": "5.6-dev" + "dev-master": "5.8-dev" } }, "installation-source": "dist", @@ -8571,8 +8572,8 @@ "environment" ], "support": { - "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + "issues": "https://github.com/phpdotenv/phpdotenv/issues", + "source": "https://github.com/phpdotenv/phpdotenv/tree/v5.8.0" }, "funding": [ { diff --git a/core/vendor/composer/installed.php b/core/vendor/composer/installed.php index b5f6f7beeb..69b8a6b785 100644 --- a/core/vendor/composer/installed.php +++ b/core/vendor/composer/installed.php @@ -38,9 +38,9 @@ 'dev_requirement' => false, ), 'composer/ca-bundle' => array( - 'pretty_version' => '1.6.0', - 'version' => '1.6.0.0', - 'reference' => '2c99cf2e92516985dea4914812b93d9daf01cdd6', + 'pretty_version' => '1.6.1', + 'version' => '1.6.1.0', + 'reference' => '0c64479fae6952ba4cf021a72c0ac530394c91d4', 'type' => 'library', 'install_path' => __DIR__ . '/./ca-bundle', 'aliases' => array(), @@ -56,9 +56,9 @@ 'dev_requirement' => false, ), 'composer/composer' => array( - 'pretty_version' => '2.10.3', - 'version' => '2.10.3.0', - 'reference' => 'f0de0bf90226853b841672f086d8b58b02332504', + 'pretty_version' => '2.11.0', + 'version' => '2.11.0.0', + 'reference' => 'e52c65bed1de6aa272cdde4b7725ca6653d7b06a', 'type' => 'library', 'install_path' => __DIR__ . '/./composer', 'aliases' => array(), @@ -83,9 +83,9 @@ 'dev_requirement' => false, ), 'composer/semver' => array( - 'pretty_version' => '3.5.1', - 'version' => '3.5.1.0', - 'reference' => '528948210cc46f967a4b98e83b19e76f2b504993', + 'pretty_version' => '3.6.0', + 'version' => '3.6.0.0', + 'reference' => 'd92543b7ede9c4ec6b59853cefb061e39866d2cf', 'type' => 'library', 'install_path' => __DIR__ . '/./semver', 'aliases' => array(), @@ -146,9 +146,9 @@ 'dev_requirement' => false, ), 'doctrine/inflector' => array( - 'pretty_version' => '2.1.0', - 'version' => '2.1.0.0', - 'reference' => '6d6c96277ea252fc1304627204c3d5e6e15faa3b', + 'pretty_version' => '2.1.1', + 'version' => '2.1.1.0', + 'reference' => '46351b6812b7b6d52b82c3efa5dfb786a198ec88', 'type' => 'library', 'install_path' => __DIR__ . '/../doctrine/inflector', 'aliases' => array(), @@ -227,9 +227,9 @@ 'dev_requirement' => false, ), 'guzzlehttp/guzzle' => array( - 'pretty_version' => '7.16.0', - 'version' => '7.16.0.0', - 'reference' => 'ffb62ec787050ae4fa9449cdd6b80335db69bda2', + 'pretty_version' => '7.16.1', + 'version' => '7.16.1.0', + 'reference' => '81755d989b3007ca7f88a069857553cd08e7dc2e', 'type' => 'library', 'install_path' => __DIR__ . '/../guzzlehttp/guzzle', 'aliases' => array(), @@ -263,8 +263,8 @@ 'dev_requirement' => false, ), 'illuminate/bus' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '9e70f409c744e0ba6b301fa608bb5b3c8c38669a', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/bus', @@ -272,8 +272,8 @@ 'dev_requirement' => false, ), 'illuminate/cache' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '2689356ab45bc53ffe796d1b1241babae59c69f6', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/cache', @@ -281,8 +281,8 @@ 'dev_requirement' => false, ), 'illuminate/collections' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '83313b009c4afb6f02dbc090bdb67809756eefa2', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/collections', @@ -290,8 +290,8 @@ 'dev_requirement' => false, ), 'illuminate/conditionable' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => 'ec677967c1f2faf90b8428919124d2184a4c9b49', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/conditionable', @@ -299,8 +299,8 @@ 'dev_requirement' => false, ), 'illuminate/config' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '7adbf5cc27081d4613e1fa2f4f53e2a4fc91ad53', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/config', @@ -308,8 +308,8 @@ 'dev_requirement' => false, ), 'illuminate/console' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '29f41bd7e07c14c04cb5acfaf9dc103adf2084aa', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/console', @@ -317,8 +317,8 @@ 'dev_requirement' => false, ), 'illuminate/container' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '9abe9dba6b3f5220205ce143069c975a601e4294', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/container', @@ -326,8 +326,8 @@ 'dev_requirement' => false, ), 'illuminate/contracts' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => 'c16fd7ba7d8e6b8f336639ceb6b7e1ff6ed0efb5', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/contracts', @@ -335,8 +335,8 @@ 'dev_requirement' => false, ), 'illuminate/database' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '24033c9216144db0d05470015007535f035688ce', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/database', @@ -344,8 +344,8 @@ 'dev_requirement' => false, ), 'illuminate/events' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => 'ff22aa6017bdbd90ace660f50638ed9c580ba555', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/events', @@ -353,8 +353,8 @@ 'dev_requirement' => false, ), 'illuminate/filesystem' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '4e2bff4d2ac439db7a2903b8a99bc8dc5c8d5de4', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/filesystem', @@ -362,8 +362,8 @@ 'dev_requirement' => false, ), 'illuminate/http' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => 'a91f70083082bd6d466ea229dbbdfc48c553621c', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/http', @@ -371,8 +371,8 @@ 'dev_requirement' => false, ), 'illuminate/log' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '0309c15e63d07c05218b622caa514f3ed6b226a6', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/log', @@ -380,8 +380,8 @@ 'dev_requirement' => false, ), 'illuminate/macroable' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => 'e295d62d89dcdb87e2b1bd70dd14d074a0ed73cc', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/macroable', @@ -389,8 +389,8 @@ 'dev_requirement' => false, ), 'illuminate/pagination' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '8327d828676654053906a771abf5eea5426354ec', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/pagination', @@ -398,8 +398,8 @@ 'dev_requirement' => false, ), 'illuminate/pipeline' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => 'b6a14c20d69a44bf0a6fba664a00d23ca71770ee', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/pipeline', @@ -407,8 +407,8 @@ 'dev_requirement' => false, ), 'illuminate/queue' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '6d6d4da3c6a1bb02f6ac16e64eb3a22bb58c1c32', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/queue', @@ -416,8 +416,8 @@ 'dev_requirement' => false, ), 'illuminate/redis' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => 'e46d4c3806d78d4d7733cd9b9bfb2fef30f4c8cb', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/redis', @@ -425,8 +425,8 @@ 'dev_requirement' => false, ), 'illuminate/reflection' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '348cf5da9de89b596d7723be6425fb048e2bf4bb', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/reflection', @@ -434,8 +434,8 @@ 'dev_requirement' => false, ), 'illuminate/routing' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '56b1a062bfd5153627e0d3bd328e7c8eafc37472', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/routing', @@ -443,8 +443,8 @@ 'dev_requirement' => false, ), 'illuminate/session' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '98802e67dd5e059c0b978b3fe8f5f0a3ac17ec4e', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/session', @@ -452,17 +452,17 @@ 'dev_requirement' => false, ), 'illuminate/support' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', - 'reference' => '9e9ff99fc4405664faa02f231a25aaf968c6f02b', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', + 'reference' => '6427b436b592d05050b506930566f47f85ecebf0', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/support', 'aliases' => array(), 'dev_requirement' => false, ), 'illuminate/translation' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => '431ac000adf17a024d6ef3a8131c3c1fa950459f', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/translation', @@ -470,8 +470,8 @@ 'dev_requirement' => false, ), 'illuminate/validation' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => 'ce2bb8d019e2510d1892b0c87f9086ea802c28e4', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/validation', @@ -479,8 +479,8 @@ 'dev_requirement' => false, ), 'illuminate/view' => array( - 'pretty_version' => 'v12.70.0', - 'version' => '12.70.0.0', + 'pretty_version' => 'v12.70.2', + 'version' => '12.70.2.0', 'reference' => 'ea300ac93680690580927fda447fdd883034f263', 'type' => 'library', 'install_path' => __DIR__ . '/../illuminate/view', @@ -497,9 +497,9 @@ 'dev_requirement' => false, ), 'justinrainbow/json-schema' => array( - 'pretty_version' => '6.13.1', - 'version' => '6.13.1.0', - 'reference' => 'e54aacc4c0657b3a4af4f628428d2702146828c6', + 'pretty_version' => '6.14.0', + 'version' => '6.14.0.0', + 'reference' => 'bae415e135dd68260c2d020ba392850231e9b1ab', 'type' => 'library', 'install_path' => __DIR__ . '/../justinrainbow/json-schema', 'aliases' => array(), @@ -575,9 +575,9 @@ ), ), 'nesbot/carbon' => array( - 'pretty_version' => '3.15.0', - 'version' => '3.15.0.0', - 'reference' => '217d38553209bdb44e01590d9e9a2e4df8354cce', + 'pretty_version' => '3.15.1', + 'version' => '3.15.1.0', + 'reference' => 'ce0e5636fae7d1e99856eb70cae4c719a284aa08', 'type' => 'library', 'install_path' => __DIR__ . '/../nesbot/carbon', 'aliases' => array(), @@ -897,9 +897,9 @@ 'dev_requirement' => false, ), 'symfony/console' => array( - 'pretty_version' => 'v7.4.21', - 'version' => '7.4.21.0', - 'reference' => '21f1bc2296f7ff6043241a8dcbc09559b3732820', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => '8012f842110b414fd1cc641adb0ed8423353b0bb', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/console', 'aliases' => array(), @@ -915,9 +915,9 @@ 'dev_requirement' => false, ), 'symfony/error-handler' => array( - 'pretty_version' => 'v7.4.20', - 'version' => '7.4.20.0', - 'reference' => '82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => '545852cfe9c4979c82389155b4d8d05f8864e4f1', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/error-handler', 'aliases' => array(), @@ -957,36 +957,36 @@ 'dev_requirement' => false, ), 'symfony/finder' => array( - 'pretty_version' => 'v7.4.20', - 'version' => '7.4.20.0', - 'reference' => '2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => 'e13420a8e0a76bdde1e7be09f923e62ad13739f2', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/finder', 'aliases' => array(), 'dev_requirement' => false, ), 'symfony/http-foundation' => array( - 'pretty_version' => 'v7.4.21', - 'version' => '7.4.21.0', - 'reference' => '58c89144c7a36dd89c3d01917e9fa3a72d8aaf31', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => '63eda1d0c4de229d6b3e825709e49dd5c149699a', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/http-foundation', 'aliases' => array(), 'dev_requirement' => false, ), 'symfony/http-kernel' => array( - 'pretty_version' => 'v7.4.21', - 'version' => '7.4.21.0', - 'reference' => '4779fce7dedacb8801b2b61c703ef99159502331', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => '87e727b33983238d3f2ea50f9f62265499ca1a6d', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/http-kernel', 'aliases' => array(), 'dev_requirement' => false, ), 'symfony/mime' => array( - 'pretty_version' => 'v7.4.19', - 'version' => '7.4.19.0', - 'reference' => 'f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => '94a4c017435bc515f5ac80421a2c555fb9ca3676', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/mime', 'aliases' => array(), @@ -1002,9 +1002,9 @@ 'dev_requirement' => false, ), 'symfony/polyfill-intl-grapheme' => array( - 'pretty_version' => 'v1.43.0', - 'version' => '1.43.0.0', - 'reference' => '5cac8f4db97208823aa353f59a01faad2da52c75', + 'pretty_version' => 'v1.43.3', + 'version' => '1.43.3.0', + 'reference' => '5a8f33c87aeecffce24e721b5064fab05f4becf8', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-intl-grapheme', 'aliases' => array(), @@ -1029,9 +1029,9 @@ 'dev_requirement' => false, ), 'symfony/polyfill-mbstring' => array( - 'pretty_version' => 'v1.43.0', - 'version' => '1.43.0.0', - 'reference' => '4d5d331bf731e0aedbc6b2f5e61135b7b7996a72', + 'pretty_version' => 'v1.43.3', + 'version' => '1.43.3.0', + 'reference' => '122889ed24dc000165bcbfd77ec412f15cbdfc6a', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-mbstring', 'aliases' => array(), @@ -1095,18 +1095,18 @@ 'dev_requirement' => false, ), 'symfony/process' => array( - 'pretty_version' => 'v7.4.21', - 'version' => '7.4.21.0', - 'reference' => 'e5247f4a5e0797d232ad8e8e6803aeb341843c83', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => 'a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/process', 'aliases' => array(), 'dev_requirement' => false, ), 'symfony/routing' => array( - 'pretty_version' => 'v7.4.21', - 'version' => '7.4.21.0', - 'reference' => '7a344cf871532d081875bfdd18f3dff8dca36c5b', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => 'f79ed6fbf60d9e32d358054f1915295f3b8a6f7a', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/routing', 'aliases' => array(), @@ -1122,18 +1122,18 @@ 'dev_requirement' => false, ), 'symfony/string' => array( - 'pretty_version' => 'v7.4.21', - 'version' => '7.4.21.0', - 'reference' => '9bc35d074ad16a58c21cd52b18c5cacc4f4c3981', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => '8dff7da3a56638f1d189c5368183294102de4ced', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/string', 'aliases' => array(), 'dev_requirement' => false, ), 'symfony/translation' => array( - 'pretty_version' => 'v7.4.17', - 'version' => '7.4.17.0', - 'reference' => '2ee1e4a3b32a528a642babe041ff7c440b213b4b', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => 'a892cd1807bfaf0a10690359c47fdf95b162ef22', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/translation', 'aliases' => array(), @@ -1155,27 +1155,27 @@ ), ), 'symfony/var-dumper' => array( - 'pretty_version' => 'v7.4.21', - 'version' => '7.4.21.0', - 'reference' => '55d06b02f49eca9bff61828548988e6f8763e8a6', + 'pretty_version' => 'v7.4.22', + 'version' => '7.4.22.0', + 'reference' => '6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/var-dumper', 'aliases' => array(), 'dev_requirement' => false, ), 'tracy/tracy' => array( - 'pretty_version' => 'v2.12.1', - 'version' => '2.12.1.0', - 'reference' => '90d24872cd97202f79281aac7eb09c27ec48bf69', + 'pretty_version' => 'v2.12.2', + 'version' => '2.12.2.0', + 'reference' => '2664bd9456cb745ff1fb0b31efa79fe2f411bef3', 'type' => 'library', 'install_path' => __DIR__ . '/../tracy/tracy', 'aliases' => array(), 'dev_requirement' => false, ), 'vlucas/phpdotenv' => array( - 'pretty_version' => 'v5.7.0', - 'version' => '5.7.0.0', - 'reference' => '301c07936b16d88628b126b01d082ba153cf4c40', + 'pretty_version' => 'v5.8.0', + 'version' => '5.8.0.0', + 'reference' => '96149103e62ac67e915f6d2ef59b72dee2e011e0', 'type' => 'library', 'install_path' => __DIR__ . '/../vlucas/phpdotenv', 'aliases' => array(), diff --git a/core/vendor/composer/semver/CHANGELOG.md b/core/vendor/composer/semver/CHANGELOG.md index 09526fb31e..2def9cc82a 100644 --- a/core/vendor/composer/semver/CHANGELOG.md +++ b/core/vendor/composer/semver/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +### [3.6.0] 2026-10-29 + + * Improved `Intervals::compactConstraint()` performance by caching results per constraint string, which speeds up callers that compact the same constraints repeatedly like Composer's pool optimizer, `Intervals::clear()` frees the cache (#194) + +### [3.5.2] 2026-10-22 + + * Fixed `MultiConstraint::getUpperBound()` picking the wrong inclusivity when two upper bounds share a version, e.g. `< 1.0 || == 1.0` reported an exclusive upper bound that excluded `1.0` (#193) + ### [3.5.1] 2026-10-05 * Fixed `Intervals::isSubsetOf()` returning false for `< 0.0.0`, which now correctly matches nothing and so is a subset of any constraint (#192) @@ -206,6 +214,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Namespace: `Composer\Test\Package\LinkConstraint` -> `Composer\Test\Semver\Constraint` * Changed: code style using php-cs-fixer. +[3.6.0]: https://github.com/composer/semver/compare/3.5.2...3.6.0 +[3.5.2]: https://github.com/composer/semver/compare/3.5.1...3.5.2 [3.5.1]: https://github.com/composer/semver/compare/3.5.0...3.5.1 [3.5.0]: https://github.com/composer/semver/compare/3.4.4...3.5.0 [3.4.4]: https://github.com/composer/semver/compare/3.4.3...3.4.4 diff --git a/core/vendor/composer/semver/src/Constraint/MultiConstraint.php b/core/vendor/composer/semver/src/Constraint/MultiConstraint.php index 1f4c006168..16e47ce439 100644 --- a/core/vendor/composer/semver/src/Constraint/MultiConstraint.php +++ b/core/vendor/composer/semver/src/Constraint/MultiConstraint.php @@ -317,8 +317,15 @@ private function extractBounds() $this->lowerBound = $constraint->getLowerBound(); } - if ($constraint->getUpperBound()->compareTo($this->upperBound, $this->isConjunctive() ? '<' : '>')) { - $this->upperBound = $constraint->getUpperBound(); + $upperBound = $constraint->getUpperBound(); + $cmp = version_compare($upperBound->getVersion(), $this->upperBound->getVersion()); + if (0 === $cmp) { + // same version: for upper bounds inclusive is higher (compareTo() assumes lower bounds) + if ($upperBound->isInclusive() === $this->isDisjunctive()) { + $this->upperBound = $upperBound; + } + } elseif ($cmp === ($this->isConjunctive() ? -1 : 1)) { + $this->upperBound = $upperBound; } } } diff --git a/core/vendor/composer/semver/src/Intervals.php b/core/vendor/composer/semver/src/Intervals.php index 1329800dfd..80c6d07518 100644 --- a/core/vendor/composer/semver/src/Intervals.php +++ b/core/vendor/composer/semver/src/Intervals.php @@ -36,6 +36,11 @@ class Intervals */ private static $intervalsCache = array(); + /** + * @phpstan-var array + */ + private static $compactedCache = array(); + /** * @phpstan-var array */ @@ -54,6 +59,7 @@ class Intervals public static function clear() { self::$intervalsCache = array(); + self::$compactedCache = array(); } /** @@ -142,6 +148,19 @@ public static function compactConstraint(ConstraintInterface $constraint) return $constraint; } + $key = (string) $constraint; + if (!isset(self::$compactedCache[$key])) { + self::$compactedCache[$key] = self::doCompactConstraint($constraint); + } + + return self::$compactedCache[$key]; + } + + /** + * @return ConstraintInterface + */ + private static function doCompactConstraint(MultiConstraint $constraint) + { $intervals = self::generateIntervals($constraint); $constraints = array(); $hasNumericMatchAll = false; diff --git a/core/vendor/doctrine/inflector/SECURITY.md b/core/vendor/doctrine/inflector/SECURITY.md new file mode 100644 index 0000000000..4b4deb1137 --- /dev/null +++ b/core/vendor/doctrine/inflector/SECURITY.md @@ -0,0 +1,7 @@ +# Security + +If you find a Security bug in Doctrine, please follow our +[Security reporting guidelines](https://www.doctrine-project.org/policies/security.html#reporting). + +Security vulnerabilities should be reported to security@doctrine-project.org +and not posted on the GitHub issue tracker of the project. diff --git a/core/vendor/doctrine/inflector/composer.json b/core/vendor/doctrine/inflector/composer.json index 6102926f50..960111dc69 100644 --- a/core/vendor/doctrine/inflector/composer.json +++ b/core/vendor/doctrine/inflector/composer.json @@ -42,7 +42,7 @@ "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^12.0 || ^13.0", + "doctrine/coding-standard": "^12.0 || ^14.0", "phpstan/phpstan": "^1.12 || ^2.0", "phpstan/phpstan-phpunit": "^1.4 || ^2.0", "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", diff --git a/core/vendor/doctrine/inflector/docs/en/index.rst b/core/vendor/doctrine/inflector/docs/en/index.rst index 1e094c8ecb..09ea90e87d 100644 --- a/core/vendor/doctrine/inflector/docs/en/index.rst +++ b/core/vendor/doctrine/inflector/docs/en/index.rst @@ -41,6 +41,7 @@ The supported languages are as follows: - ``Language::ENGLISH`` - ``Language::ESPERANTO`` - ``Language::FRENCH`` +- ``Language::ITALIAN`` - ``Language::NORWEGIAN_BOKMAL`` - ``Language::PORTUGUESE`` - ``Language::SPANISH`` @@ -50,6 +51,7 @@ If you want to manually construct the inflector instead of using a factory, you .. code-block:: php + use Doctrine\Inflector\Inflector; use Doctrine\Inflector\CachedWordInflector; use Doctrine\Inflector\RulesetInflector; use Doctrine\Inflector\Rules\English; diff --git a/core/vendor/doctrine/inflector/src/Inflector.php b/core/vendor/doctrine/inflector/src/Inflector.php index 610a4cf4d0..01d82706b8 100644 --- a/core/vendor/doctrine/inflector/src/Inflector.php +++ b/core/vendor/doctrine/inflector/src/Inflector.php @@ -210,6 +210,10 @@ class Inflector 'Ž' => 'Z', 'ž' => 'z', 'ſ' => 's', + 'Ё' => 'Е', + 'Ї' => 'І', + 'ё' => 'е', + 'ї' => 'і', '€' => 'E', '£' => '', ]; diff --git a/core/vendor/doctrine/inflector/src/Rules/Portuguese/Inflectible.php b/core/vendor/doctrine/inflector/src/Rules/Portuguese/Inflectible.php index 0d41fe7e16..042cd48c51 100644 --- a/core/vendor/doctrine/inflector/src/Rules/Portuguese/Inflectible.php +++ b/core/vendor/doctrine/inflector/src/Rules/Portuguese/Inflectible.php @@ -71,7 +71,7 @@ public static function getIrregular(): iterable yield new Substitution(new Word('chão'), new Word('chãos')); yield new Substitution(new Word('charlatão'), new Word('charlatães')); yield new Substitution(new Word('cidadão'), new Word('cidadãos')); - yield new Substitution(new Word('consul'), new Word('consules')); + yield new Substitution(new Word('cônsul'), new Word('cônsules')); yield new Substitution(new Word('cristão'), new Word('cristãos')); yield new Substitution(new Word('difícil'), new Word('difíceis')); yield new Substitution(new Word('email'), new Word('emails')); @@ -85,6 +85,7 @@ public static function getIrregular(): iterable yield new Substitution(new Word('liquens'), new Word('liquen')); yield new Substitution(new Word('mal'), new Word('males')); yield new Substitution(new Word('mão'), new Word('mãos')); + yield new Substitution(new Word('mês'), new Word('meses')); yield new Substitution(new Word('orfão'), new Word('orfãos')); yield new Substitution(new Word('país'), new Word('países')); yield new Substitution(new Word('pai'), new Word('pais')); @@ -94,5 +95,6 @@ public static function getIrregular(): iterable yield new Substitution(new Word('sacristão'), new Word('sacristães')); yield new Substitution(new Word('sotão'), new Word('sotãos')); yield new Substitution(new Word('tabelião'), new Word('tabeliães')); + yield new Substitution(new Word('útil'), new Word('úteis')); } } diff --git a/core/vendor/doctrine/inflector/src/Rules/Portuguese/Uninflected.php b/core/vendor/doctrine/inflector/src/Rules/Portuguese/Uninflected.php index b8e988f8c6..5eb101fe69 100644 --- a/core/vendor/doctrine/inflector/src/Rules/Portuguese/Uninflected.php +++ b/core/vendor/doctrine/inflector/src/Rules/Portuguese/Uninflected.php @@ -23,10 +23,24 @@ public static function getPlural(): iterable /** @return Pattern[] */ private static function getDefault(): iterable { - yield new Pattern('tórax'); - yield new Pattern('tênis'); - yield new Pattern('ônibus'); - yield new Pattern('lápis'); + yield new Pattern('atlas'); + yield new Pattern('bate-papo'); + yield new Pattern('cais'); yield new Pattern('fênix'); + yield new Pattern('guarda-chuva'); + yield new Pattern('guarda-roupa'); + yield new Pattern('lápis'); + yield new Pattern('oásis'); + yield new Pattern('ônibus'); + yield new Pattern('ônus'); + yield new Pattern('pára-brisa'); + yield new Pattern('pára-choque'); + yield new Pattern('pires'); + yield new Pattern('porta-malas'); + yield new Pattern('porta-voz'); + yield new Pattern('sem-terra'); + yield new Pattern('tênis'); + yield new Pattern('tórax'); + yield new Pattern('vírus'); } } diff --git a/core/vendor/guzzlehttp/guzzle/CHANGELOG.md b/core/vendor/guzzlehttp/guzzle/CHANGELOG.md index 8b55e29e26..fbfccd9228 100644 --- a/core/vendor/guzzlehttp/guzzle/CHANGELOG.md +++ b/core/vendor/guzzlehttp/guzzle/CHANGELOG.md @@ -3,6 +3,14 @@ Please refer to [UPGRADING](UPGRADING.md) guide for upgrading to a major version. +## 7.16.1 - 2026-10-25 + +### Fixed + +- Fix `CookieJar` storing already expired cookies instead of removing the stored cookie +- Fix `CookieJar` ignoring a same-value cookie that changes its expiry or its `Discard`, `Secure`, or `HttpOnly` flag + + ## 7.16.0 - 2026-10-18 ### Changed diff --git a/core/vendor/guzzlehttp/guzzle/src/Cookie/CookieJar.php b/core/vendor/guzzlehttp/guzzle/src/Cookie/CookieJar.php index ef11cc7d78..423eb34a70 100644 --- a/core/vendor/guzzlehttp/guzzle/src/Cookie/CookieJar.php +++ b/core/vendor/guzzlehttp/guzzle/src/Cookie/CookieJar.php @@ -175,7 +175,7 @@ public function setCookie(SetCookie $cookie): bool } $maxAge = $cookie->getMaxAge(); - if ($maxAge !== null && $maxAge <= 0) { + if (($maxAge !== null && $maxAge <= 0) || $cookie->isExpired()) { if ($cookie->getDomain() !== null) { $this->removeCookie($cookie); } @@ -195,22 +195,13 @@ public function setCookie(SetCookie $cookie): bool continue; } - // The previously set cookie is a discard cookie and this one is - // not so allow the new cookie to be set - if (!$cookie->getDiscard() && $c->getDiscard()) { - unset($this->cookies[$i]); - continue; - } - - // If the new cookie's expiration is further into the future, then - // replace the old cookie - if ($cookie->getExpires() > $c->getExpires()) { - unset($this->cookies[$i]); - continue; - } - - // If the value has changed, we better change it - if ($cookie->getValue() !== $c->getValue()) { + // Replace the old cookie when its value, expiry or flags changed + if ($cookie->getValue() !== $c->getValue() + || $cookie->getExpires() !== $c->getExpires() + || $cookie->getDiscard() !== $c->getDiscard() + || $cookie->getSecure() !== $c->getSecure() + || $cookie->getHttpOnly() !== $c->getHttpOnly() + ) { unset($this->cookies[$i]); continue; } diff --git a/core/vendor/illuminate/support/Facades/Artisan.php b/core/vendor/illuminate/support/Facades/Artisan.php index 842cc7cfc7..a73ae795ce 100644 --- a/core/vendor/illuminate/support/Facades/Artisan.php +++ b/core/vendor/illuminate/support/Facades/Artisan.php @@ -14,6 +14,7 @@ * @method static void registerCommand(\Symfony\Component\Console\Command\Command $command) * @method static int call(\Symfony\Component\Console\Command\Command|string $command, array $parameters = [], \Symfony\Component\Console\Output\OutputInterface|null $outputBuffer = null) * @method static \Illuminate\Foundation\Bus\PendingDispatch queue(string $command, array $parameters = []) + * @method static \Symfony\Component\Console\Command\Command|null findCommand(string $name) * @method static array all() * @method static string output() * @method static void bootstrap() diff --git a/core/vendor/illuminate/support/Facades/Exceptions.php b/core/vendor/illuminate/support/Facades/Exceptions.php index 263b95bd04..1b6827706e 100644 --- a/core/vendor/illuminate/support/Facades/Exceptions.php +++ b/core/vendor/illuminate/support/Facades/Exceptions.php @@ -20,6 +20,7 @@ * @method static bool shouldReport(\Throwable $e) * @method static \Illuminate\Foundation\Exceptions\Handler throttleUsing(callable $throttleUsing) * @method static \Illuminate\Foundation\Exceptions\Handler stopIgnoring(array|string $exceptions) + * @method static array contextForException(\Throwable $e) * @method static \Illuminate\Foundation\Exceptions\Handler buildContextUsing(\Closure $contextCallback) * @method static \Symfony\Component\HttpFoundation\Response render(\Illuminate\Http\Request $request, \Throwable $e) * @method static \Illuminate\Foundation\Exceptions\Handler respondUsing(callable $callback) diff --git a/core/vendor/justinrainbow/json-schema/CHANGELOG.md b/core/vendor/justinrainbow/json-schema/CHANGELOG.md index a62cb51673..1254730ddc 100644 --- a/core/vendor/justinrainbow/json-schema/CHANGELOG.md +++ b/core/vendor/justinrainbow/json-schema/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.14.0] - 2026-10-24 + +### Added +- test: Remove JSON Schema Test Suite skips for cases that already pass ([#985](https://github.com/jsonrainbow/json-schema/pull/985)) +- chore: Enforce LF line endings via .gitattributes and .editorconfig ([#980](https://github.com/jsonrainbow/json-schema/pull/980)) +- test: Clean up JSON Schema Test Suite skip list ([#978](https://github.com/jsonrainbow/json-schema/pull/978)) +- feat: support unevaluatedProperties in Draft 2019-09 ([#931](https://github.com/jsonrainbow/json-schema/pull/931)) +- Allow bin/run-test-case to run a complete test file ([#966](https://github.com/jsonrainbow/json-schema/pull/966)) + +### Fixed +- Throw on an unresolvable $ref instead of passing silently ([#944](https://github.com/jsonrainbow/json-schema/pull/944)) +- test: Pass file URLs to the Curl retriever tests ([#989](https://github.com/jsonrainbow/json-schema/pull/989)) +- fix: Reject a trailing newline in idn-hostname ([#990](https://github.com/jsonrainbow/json-schema/pull/990)) +- fix: Report Draft-06/07 sub-schema errors at the property and item path ([#967](https://github.com/jsonrainbow/json-schema/pull/967)) +- fix: Report float limits in numeric error messages without casting to int ([#982](https://github.com/jsonrainbow/json-schema/pull/982)) +- fix: Respect CHECK_MODE_DISABLE_FORMAT in Draft-06, Draft-07 and Draft 2019-09 format constraints ([#972](https://github.com/jsonrainbow/json-schema/pull/972)) +- fix: Show the regex instead of the value in the pattern error message ([#974](https://github.com/jsonrainbow/json-schema/pull/974)) +- Apply the same ECMA to PCRE pattern replacements everywhere ([#943](https://github.com/jsonrainbow/json-schema/pull/943)) +- fix: Keep backslash sequences of the install path in package:// translation ([#958](https://github.com/jsonrainbow/json-schema/pull/958)) +- fix: Ignore sibling keywords of $ref in Draft-06 and Draft-07 ([#968](https://github.com/jsonrainbow/json-schema/pull/968)) +- fix: Handle invalid regex patterns from user input ([#969](https://github.com/jsonrainbow/json-schema/pull/969)) + +### Removed +- chore: Remove legacy Sphinx docs folder ([#981](https://github.com/jsonrainbow/json-schema/pull/981)) + + ## [6.13.1] - 2026-09-30 ### Fixed diff --git a/core/vendor/justinrainbow/json-schema/README.md b/core/vendor/justinrainbow/json-schema/README.md index 5f400b309b..938b77d9f8 100644 --- a/core/vendor/justinrainbow/json-schema/README.md +++ b/core/vendor/justinrainbow/json-schema/README.md @@ -11,6 +11,8 @@ Draft-4, Draft-6,Draft-7 or Draft 2019-09. Features of newer Drafts might not be supported. See [Table of All Versions of Everything](https://json-schema.org/specification-links.html#table-of-all-versions-of-everything) to get an overview of all existing Drafts. See [json-schema](http://json-schema.org/) for more details about the JSON Schema specification +The documentation for this library is available at [jsonrainbow.github.io/docs](https://jsonrainbow.github.io/docs/). + # Compliance ![Draft 3](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft3.json) ![Draft 4](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft4.json) diff --git a/core/vendor/justinrainbow/json-schema/bin/run-test-case b/core/vendor/justinrainbow/json-schema/bin/run-test-case index 86b6dc38f6..1e50fa6bad 100644 --- a/core/vendor/justinrainbow/json-schema/bin/run-test-case +++ b/core/vendor/justinrainbow/json-schema/bin/run-test-case @@ -34,10 +34,10 @@ foreach ($argv as $arg) { } } -if (count($arArgs) < 2 || isset($arOptions['--help']) || isset($arOptions['-h'])) { +if (count($arArgs) < 1 || isset($arOptions['--help']) || isset($arOptions['-h'])) { echo << "[/]" +Usage: run-test-case ["[/]"] Arguments: test-file Path to a test JSON file. Resolved in order: @@ -46,6 +46,7 @@ Arguments: description "" or "/" — split on the first "/" only. Exact match is tried first, then case-insensitive substring. Omit the test part to run all tests in the group. + Omit the description to run every group in the file. Options: -v --verbose Also print the schema @@ -53,7 +54,8 @@ Options: Exit codes: 0 All matched tests passed - 1 At least one test failed + 1 At least one test failed (or errored during validation when running + more than a single test) 2 Usage error, file not found, or group/test not found HLP; @@ -81,9 +83,14 @@ if (preg_match('/(draft[^\/]+)/i', str_replace('\\', '/', $filePath), $m)) { } // Parse "/" — split on first "/" only so slashes in test descriptions are preserved -$descParts = explode('/', $arArgs[1], 2); -$groupDesc = $descParts[0]; -$testDesc = $descParts[1] ?? null; +$description = $arArgs[1] ?? null; +$groupDesc = null; +$testDesc = null; +if ($description !== null) { + $descParts = explode('/', $description, 2); + $groupDesc = $descParts[0]; + $testDesc = $descParts[1] ?? null; +} // Load test file $contents = json_decode((string) file_get_contents($filePath), false); @@ -98,55 +105,57 @@ $foundGroup = null; $foundTest = null; $matchedGroup = null; -foreach ([true, false] as $exact) { - foreach ($contents as $group) { - $groupMatch = $exact - ? $group->description === $groupDesc - : stripos($group->description, $groupDesc) !== false; +if ($groupDesc !== null) { + foreach ([true, false] as $exact) { + foreach ($contents as $group) { + $groupMatch = $exact + ? $group->description === $groupDesc + : stripos($group->description, $groupDesc) !== false; - if (!$groupMatch) { - continue; - } + if (!$groupMatch) { + continue; + } - if ($testDesc === null) { - $foundGroup = $group; - break 2; - } + if ($testDesc === null) { + $foundGroup = $group; + break 2; + } - $matchedGroup = $group; + $matchedGroup = $group; - foreach ($group->tests as $test) { - $testMatch = $exact - ? $test->description === $testDesc - : stripos($test->description, $testDesc) !== false; + foreach ($group->tests as $test) { + $testMatch = $exact + ? $test->description === $testDesc + : stripos($test->description, $testDesc) !== false; - if ($testMatch) { - $foundGroup = $group; - $foundTest = $test; - break 3; + if ($testMatch) { + $foundGroup = $group; + $foundTest = $test; + break 3; + } } } } -} -if ($matchedGroup === null && $foundGroup === null) { - echo "Error: no matching group found for: {$groupDesc}\n"; - echo "Available groups:\n"; - foreach ($contents as $group) { - echo " - {$group->description}\n"; + if ($matchedGroup === null && $foundGroup === null) { + echo "Error: no matching group found for: {$groupDesc}\n"; + echo "Available groups:\n"; + foreach ($contents as $group) { + echo " - {$group->description}\n"; + } + exit(2); } - exit(2); -} -if ($testDesc !== null && $foundTest === null) { - echo "Error: no matching test found for: {$testDesc}\n"; - $listGroup = $matchedGroup ?? $foundGroup; - echo "Available tests in \"{$listGroup->description}\":\n"; - foreach ($listGroup->tests as $test) { - $expect = $test->valid ? 'valid' : 'invalid'; - echo " [{$expect}] {$test->description}\n"; + if ($testDesc !== null && $foundTest === null) { + echo "Error: no matching test found for: {$testDesc}\n"; + $listGroup = $matchedGroup ?? $foundGroup; + echo "Available tests in \"{$listGroup->description}\":\n"; + foreach ($listGroup->tests as $test) { + $expect = $test->valid ? 'valid' : 'invalid'; + echo " [{$expect}] {$test->description}\n"; + } + exit(2); } - exit(2); } // Determine check mode (mirrors JsonSchemaTestSuiteTest::getCheckModeForDraft) @@ -160,15 +169,8 @@ try { $draftIdentifier = DraftIdentifiers::DRAFT_4(); } -// Set up validator (mirrors JsonSchemaTestSuiteTest::testTestCaseValidatesCorrectly) -$schema = $foundGroup->schema; -$schemaId = is_object($schema) && property_exists($schema, 'id') - ? $schema->id - : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI; - -$schemaStorage = new SchemaStorage(); -$schemaStorage->addSchema($schemaId, $schema); - +// Load remote schemas once; they are registered for every group +$remotes = []; if (is_dir($remotesDir)) { $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($remotesDir)); foreach ($iterator as $info) { @@ -176,15 +178,29 @@ if (is_dir($remotesDir)) { continue; } $remoteId = str_replace($remotesDir, 'http://localhost:1234', $info->getPathname()); - $schemaStorage->addSchema($remoteId, json_decode((string) file_get_contents($info->getPathname()), false)); + $remotes[$remoteId] = json_decode((string) file_get_contents($info->getPathname()), false); } } -$factory = new Factory($schemaStorage); -$factory->setDefaultDialect($draftIdentifier->getValue()); +// Set up a factory for a group's schema (mirrors JsonSchemaTestSuiteTest::testTestCaseValidatesCorrectly) +$createFactory = static function ($schema) use ($remotes, $draftIdentifier): Factory { + $schemaId = is_object($schema) && property_exists($schema, 'id') + ? $schema->id + : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI; -/** @param object $test */ -$runTest = static function (object $test) use ($factory, $schema, $checkMode): array { + $schemaStorage = new SchemaStorage(); + $schemaStorage->addSchema($schemaId, $schema); + foreach ($remotes as $remoteId => $remote) { + $schemaStorage->addSchema($remoteId, $remote); + } + + $factory = new Factory($schemaStorage); + $factory->setDefaultDialect($draftIdentifier->getValue()); + + return $factory; +}; + +$runTest = static function (Factory $factory, $schema, object $test) use ($checkMode): array { $validator = new Validator($factory); try { $validator->validate($test->data, $schema, $checkMode); @@ -201,16 +217,17 @@ $runTest = static function (object $test) use ($factory, $schema, $checkMode): a $verbose = isset($arOptions['--verbose']) || isset($arOptions['-v']); -// Run all tests in the group when no specific test was requested -if ($foundTest === null) { - echo "Group: {$foundGroup->description}\n"; +// Run all tests in a group, returning [passCount, failCount] +$runGroup = static function (object $group) use ($createFactory, $runTest, $verbose): array { + echo "Group: {$group->description}\n"; if ($verbose) { - echo "Schema: " . json_encode($schema, JSON_PRETTY_PRINT) . "\n"; + echo "Schema: " . json_encode($group->schema, JSON_PRETTY_PRINT) . "\n"; } + $factory = $createFactory($group->schema); $passCount = 0; $failCount = 0; - foreach ($foundGroup->tests as $test) { - $result = $runTest($test); + foreach ($group->tests as $test) { + $result = $runTest($factory, $group->schema, $test); if (isset($result['error'])) { echo sprintf("[ERROR] %s\n", $test->description); echo " Error during validation: {$result['error']}\n"; @@ -233,11 +250,35 @@ if ($foundTest === null) { $passCount++; } } + + return [$passCount, $failCount]; +}; + +// Run every group in the file when no description was given +if ($groupDesc === null) { + $passCount = 0; + $failCount = 0; + foreach ($contents as $index => $group) { + if ($index > 0) { + echo "\n"; + } + [$groupPass, $groupFail] = $runGroup($group); + $passCount += $groupPass; + $failCount += $groupFail; + } + echo sprintf("\n%d groups: %d passed, %d failed.\n", count($contents), $passCount, $failCount); + exit($failCount > 0 ? 1 : 0); +} + +// Run all tests in the group when no specific test was requested +if ($foundTest === null) { + [$passCount, $failCount] = $runGroup($foundGroup); echo "\n{$passCount} passed, {$failCount} failed.\n"; exit($failCount > 0 ? 1 : 0); } -$result = $runTest($foundTest); +$schema = $foundGroup->schema; +$result = $runTest($createFactory($schema), $schema, $foundTest); if (isset($result['error'])) { echo "Error during validation: {$result['error']}\n"; diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/ConstraintError.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/ConstraintError.php index 0934292c35..95145617fb 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/ConstraintError.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/ConstraintError.php @@ -62,6 +62,7 @@ class ConstraintError extends Enum public const PROPERTY_NAMES = 'propertyNames'; public const TYPE = 'type'; public const UNIQUE_ITEMS = 'uniqueItems'; + public const UNEVALUATED_PROPERTIES = 'unevaluatedProperties'; public const CONTENT_MEDIA_TYPE = 'contentMediaType'; public const CONTENT_ENCODING = 'contentEncoding'; @@ -78,12 +79,12 @@ public function getMessage() self::ANY_OF => 'Failed to match at least one schema', self::DEPENDENCIES => '%s depends on %s, which is missing', self::DISALLOW => 'Disallowed value was matched', - self::DIVISIBLE_BY => 'Is not divisible by %d', + self::DIVISIBLE_BY => 'Is not divisible by %s', self::ENUM => 'Does not have a value in the enumeration %s', self::CONSTANT => 'Does not have a value equal to %s', self::CONTAINS => 'Does not have a value valid to contains schema', - self::EXCLUSIVE_MINIMUM => 'Must have a minimum value greater than %d', - self::EXCLUSIVE_MAXIMUM => 'Must have a maximum value less than %d', + self::EXCLUSIVE_MINIMUM => 'Must have a minimum value greater than %s', + self::EXCLUSIVE_MAXIMUM => 'Must have a maximum value less than %s', self::FALSE => 'Boolean schema false', self::FORMAT_COLOR => 'Invalid color', self::FORMAT_DATE => 'Invalid date %s, expected format YYYY-MM-DD', @@ -107,10 +108,10 @@ public function getMessage() self::LENGTH_MIN => 'Must be at least %d characters long', self::MAX_CONTAINS => 'There must be a maximum of %d valid items in the array, %d found', self::MAX_ITEMS => 'There must be a maximum of %d items in the array, %d found', - self::MAXIMUM => 'Must have a maximum value less than or equal to %d', + self::MAXIMUM => 'Must have a maximum value less than or equal to %s', self::MIN_CONTAINS => 'There must be a minimum of %d valid items in the array, %d found', self::MIN_ITEMS => 'There must be a minimum of %d items in the array, %d found', - self::MINIMUM => 'Must have a minimum value greater than or equal to %d', + self::MINIMUM => 'Must have a minimum value greater than or equal to %s', self::MISSING_MAXIMUM => 'Use of exclusiveMaximum requires presence of maximum', self::MISSING_MINIMUM => 'Use of exclusiveMinimum requires presence of minimum', /*self::MISSING_ERROR => 'Used for tests; this error is deliberately commented out',*/ @@ -126,6 +127,7 @@ public function getMessage() self::PROPERTY_NAMES => 'Property name %s is invalid', self::TYPE => '%s value found, but %s is required', self::UNIQUE_ITEMS => 'There are no duplicates allowed in the array', + self::UNEVALUATED_PROPERTIES => 'The property %s is not evaluated and the definition does not allow unevaluated properties', self::CONTENT_MEDIA_TYPE => 'Value is not valid with content media type', self::CONTENT_ENCODING => 'Value is not valid with content encoding', ]; diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php index d1adbce8f5..8b7cc69920 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php @@ -46,16 +46,19 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } $additionalItems = array_diff_key($value, property_exists($schema, 'items') ? $schema->items : []); + $basePath = $path ?? new JsonPointer(''); foreach ($additionalItems as $propertyName => $propertyValue) { + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + $schemaConstraint = $this->factory->createInstanceFor('schema'); - $schemaConstraint->check($propertyValue, $schema->additionalItems, $path, $i); + $schemaConstraint->check($propertyValue, $schema->additionalItems, $incrementedPath, $i); if ($schemaConstraint->isValid()) { continue; } - $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $path, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]); + $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $incrementedPath, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]); } } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php index 0f78b32b5a..05c7dd4e90 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php @@ -8,6 +8,7 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Tool\EcmaPatternConverter; class AdditionalPropertiesConstraint implements ConstraintInterface { @@ -47,7 +48,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n foreach ($additionalProperties as $key => $_) { foreach ($patterns as $pattern) { - if (preg_match($this->createPregMatchPattern($pattern), (string) $key)) { + if (preg_match(EcmaPatternConverter::toPcre($pattern), (string) $key)) { unset($additionalProperties[$key]); break; } @@ -74,25 +75,4 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), ($path ?? new JsonPointer(''))->withAppendedPath($key), ['found' => $key]); } } - - private function createPregMatchPattern(string $pattern): string - { - $replacements = [ -// '\D' => '[^0-9]', -// '\d' => '[0-9]', - '\p{digit}' => '\p{Nd}', -// '\w' => '[A-Za-z0-9_]', -// '\W' => '[^A-Za-z0-9_]', -// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space, - '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations - ]; - - $pattern = str_replace( - array_keys($replacements), - array_values($replacements), - $pattern - ); - - return '/' . str_replace('/', '\/', $pattern) . '/u'; - } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index bd6efac66c..9d8afe8af4 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -33,6 +33,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n // Apply defaults $this->checkForKeyword('ref', $value, $schema, $path, $i); + if (property_exists($schema, '$ref')) { + // All other keywords in a schema containing $ref are ignored + return; + } + $this->checkForKeyword('required', $value, $schema, $path, $i); $this->checkForKeyword('contains', $value, $schema, $path, $i); $this->checkForKeyword('properties', $value, $schema, $path, $i); diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php index d4e74c75fe..b268d640a6 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php @@ -5,6 +5,7 @@ namespace JsonSchema\Constraints\Drafts\Draft06; use JsonSchema\ConstraintError; +use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; @@ -17,14 +18,18 @@ class FormatConstraint implements ConstraintInterface { use ErrorBagProxy; + /** @var Factory */ + private $factory; + public function __construct(?Factory $factory = null) { - $this->initialiseErrorBag($factory ?: new Factory()); + $this->factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); } public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void { - if (!property_exists($schema, 'format')) { + if (!property_exists($schema, 'format') || $this->factory->getConfig(Constraint::CHECK_MODE_DISABLE_FORMAT)) { return; } @@ -140,7 +145,7 @@ private function validateDateTime(string $datetime, string $format): bool private function validateRegex(string $regex): bool { - return preg_match(self::jsonPatternToPhpRegex($regex), '') !== false; + return @preg_match(self::jsonPatternToPhpRegex($regex), '') !== false; } /** diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php index 21259e1a82..3b52465aa7 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php @@ -31,6 +31,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } + $basePath = $path ?? new JsonPointer(''); foreach ($value as $propertyName => $propertyValue) { $itemSchema = $schema->items; if (is_array($itemSchema)) { @@ -40,8 +41,9 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $itemSchema = $itemSchema[$propertyName]; } + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); $schemaConstraint = $this->factory->createInstanceFor('schema'); - $schemaConstraint->check($propertyValue, $itemSchema, $path, $i); + $schemaConstraint->check($propertyValue, $itemSchema, $incrementedPath, $i); if ($schemaConstraint->isValid()) { continue; } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php index 5c705f4ae1..fde48bb342 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php @@ -8,6 +8,7 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Tool\EcmaPatternConverter; class PatternConstraint implements ConstraintInterface { @@ -32,32 +33,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - $matchPattern = $this->createPregMatchPattern($schema->pattern); + $matchPattern = EcmaPatternConverter::toPcre($schema->pattern); if (preg_match($matchPattern, $value) === 1) { return; } - $this->addError(ConstraintError::PATTERN(), $path, ['found' => $value, 'pattern' => $schema->pattern]); - } - - private function createPregMatchPattern(string $pattern): string - { - $replacements = [ - '\D' => '[^0-9]', - '\d' => '[0-9]', - '\p{digit}' => '[0-9]', - '\w' => '[A-Za-z0-9_]', - '\W' => '[^A-Za-z0-9_]', - '\s' => '[\s\x{200B}]', // Explicitly include zero width white space - '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations - ]; - - $pattern = str_replace( - array_keys($replacements), - array_values($replacements), - $pattern - ); - - return '/' . str_replace('/', '\/', $pattern) . '/u'; + $this->addError(ConstraintError::PATTERN(), $path, ['pattern' => $schema->pattern, 'found' => $value]); } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php index 969736bfbe..fab4aa1ea6 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php @@ -7,6 +7,7 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Tool\EcmaPatternConverter; class PatternPropertiesConstraint implements ConstraintInterface { @@ -33,12 +34,15 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $properties = get_object_vars($value); + $basePath = $path ?? new JsonPointer(''); foreach ($properties as $propertyName => $propertyValue) { + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + foreach ($schema->patternProperties as $patternPropertyRegex => $patternPropertySchema) { - $matchPattern = $this->createPregMatchPattern($patternPropertyRegex); + $matchPattern = EcmaPatternConverter::toPcre($patternPropertyRegex); if (preg_match($matchPattern, (string) $propertyName)) { $schemaConstraint = $this->factory->createInstanceFor('schema'); - $schemaConstraint->check($propertyValue, $patternPropertySchema, $path, $i); + $schemaConstraint->check($propertyValue, $patternPropertySchema, $incrementedPath, $i); if ($schemaConstraint->isValid()) { continue; } @@ -48,25 +52,4 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } } } - - private function createPregMatchPattern(string $pattern): string - { - $replacements = [ -// '\D' => '[^0-9]', - '\d' => '[0-9]', - '\p{digit}' => '[0-9]', -// '\w' => '[A-Za-z0-9_]', -// '\W' => '[^A-Za-z0-9_]', -// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space - '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations - ]; - - $pattern = str_replace( - array_keys($replacements), - array_values($replacements), - $pattern - ); - - return '/' . str_replace('/', '\/', $pattern) . '/u'; - } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php index 4a9c480818..403b05b27c 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php @@ -31,13 +31,16 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } + $basePath = $path ?? new JsonPointer(''); foreach ($schema->properties as $propertyName => $propertySchema) { $schemaConstraint = $this->factory->createInstanceFor('schema'); if (!property_exists($value, $propertyName)) { continue; } - $schemaConstraint->check($value->{$propertyName}, $propertySchema, $path, $i); + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + + $schemaConstraint->check($value->{$propertyName}, $propertySchema, $incrementedPath, $i); if ($schemaConstraint->isValid()) { continue; } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php index f52bcda0a2..d3f5ccf1bd 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php @@ -27,11 +27,9 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - try { - $refSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); - } catch (\Exception $e) { - return; - } + // A $ref that cannot be resolved is a broken schema, not invalid data: the exception + // is left to propagate rather than letting the value pass as if the $ref was absent. + $refSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); $schemaConstraint = $this->factory->createInstanceFor('schema'); $schemaConstraint->check($value, $refSchema, $path, $i); diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalItemsConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalItemsConstraint.php index 8444d05359..216681860b 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalItemsConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalItemsConstraint.php @@ -46,16 +46,19 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } $additionalItems = array_diff_key($value, property_exists($schema, 'items') ? $schema->items : []); + $basePath = $path ?? new JsonPointer(''); foreach ($additionalItems as $propertyName => $propertyValue) { + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + $schemaConstraint = $this->factory->createInstanceFor('schema'); - $schemaConstraint->check($propertyValue, $schema->additionalItems, $path, $i); + $schemaConstraint->check($propertyValue, $schema->additionalItems, $incrementedPath, $i); if ($schemaConstraint->isValid()) { continue; } - $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $path, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]); + $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $incrementedPath, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]); } } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php index f94827607f..1cf0f167e6 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php @@ -8,6 +8,7 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Tool\EcmaPatternConverter; class AdditionalPropertiesConstraint implements ConstraintInterface { @@ -47,7 +48,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n foreach ($additionalProperties as $key => $_) { foreach ($patterns as $pattern) { - if (preg_match($this->createPregMatchPattern($pattern), (string) $key)) { + if (preg_match(EcmaPatternConverter::toPcre($pattern), (string) $key)) { unset($additionalProperties[$key]); break; } @@ -74,25 +75,4 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), ($path ?? new JsonPointer(''))->withAppendedPath($key), ['found' => $key]); } } - - private function createPregMatchPattern(string $pattern): string - { - $replacements = [ -// '\D' => '[^0-9]', -// '\d' => '[0-9]', - '\p{digit}' => '\p{Nd}', -// '\w' => '[A-Za-z0-9_]', -// '\W' => '[^A-Za-z0-9_]', -// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space, - '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations - ]; - - $pattern = str_replace( - array_keys($replacements), - array_values($replacements), - $pattern - ); - - return '/' . str_replace('/', '\/', $pattern) . '/u'; - } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php index 084ce96cc5..1e88b6faf9 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php @@ -33,6 +33,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n // Apply defaults $this->checkForKeyword('ref', $value, $schema, $path, $i); + if (property_exists($schema, '$ref')) { + // All other keywords in a schema containing $ref are ignored + return; + } + $this->checkForKeyword('required', $value, $schema, $path, $i); $this->checkForKeyword('contains', $value, $schema, $path, $i); $this->checkForKeyword('properties', $value, $schema, $path, $i); diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php index a79e891ff5..4d26fe5f62 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php @@ -6,6 +6,7 @@ use DateTimeZone; use JsonSchema\ConstraintError; +use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; @@ -18,14 +19,18 @@ class FormatConstraint implements ConstraintInterface { use ErrorBagProxy; + /** @var Factory */ + private $factory; + public function __construct(?Factory $factory = null) { - $this->initialiseErrorBag($factory ?: new Factory()); + $this->factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); } public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void { - if (!property_exists($schema, 'format')) { + if (!property_exists($schema, 'format') || $this->factory->getConfig(Constraint::CHECK_MODE_DISABLE_FORMAT)) { return; } @@ -209,7 +214,7 @@ private function validateDateTime(string $datetime, string $format): bool private function validateRegex(string $regex): bool { - return preg_match(self::jsonPatternToPhpRegex($regex), '') !== false; + return @preg_match(self::jsonPatternToPhpRegex($regex), '') !== false; } /** @@ -351,7 +356,7 @@ private function validateInternationalizedHostname(string $host): bool return false; } // LDH rule (after IDNA) - if (!preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i', $ascii)) { + if (!preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/i', $ascii)) { return false; } $asciiLabels[] = $ascii; diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/ItemsConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/ItemsConstraint.php index 16abae3285..5b94ab4f2f 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/ItemsConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/ItemsConstraint.php @@ -31,6 +31,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } + $basePath = $path ?? new JsonPointer(''); foreach ($value as $propertyName => $propertyValue) { $itemSchema = $schema->items; if (is_array($itemSchema)) { @@ -40,8 +41,9 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $itemSchema = $itemSchema[$propertyName]; } + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); $schemaConstraint = $this->factory->createInstanceFor('schema'); - $schemaConstraint->check($propertyValue, $itemSchema, $path, $i); + $schemaConstraint->check($propertyValue, $itemSchema, $incrementedPath, $i); if ($schemaConstraint->isValid()) { continue; } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PatternConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PatternConstraint.php index 062b61d3f0..9afe22f6aa 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PatternConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PatternConstraint.php @@ -8,6 +8,7 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Tool\EcmaPatternConverter; class PatternConstraint implements ConstraintInterface { @@ -32,32 +33,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - $matchPattern = $this->createPregMatchPattern($schema->pattern); + $matchPattern = EcmaPatternConverter::toPcre($schema->pattern); if (preg_match($matchPattern, $value) === 1) { return; } - $this->addError(ConstraintError::PATTERN(), $path, ['found' => $value, 'pattern' => $schema->pattern]); - } - - private function createPregMatchPattern(string $pattern): string - { - $replacements = [ - '\D' => '[^0-9]', - '\d' => '[0-9]', - '\p{digit}' => '[0-9]', - '\w' => '[A-Za-z0-9_]', - '\W' => '[^A-Za-z0-9_]', - '\s' => '[\s\x{200B}]', // Explicitly include zero width white space - '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations - ]; - - $pattern = str_replace( - array_keys($replacements), - array_values($replacements), - $pattern - ); - - return '/' . str_replace('/', '\/', $pattern) . '/u'; + $this->addError(ConstraintError::PATTERN(), $path, ['pattern' => $schema->pattern, 'found' => $value]); } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PatternPropertiesConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PatternPropertiesConstraint.php index d821abf184..98a7760096 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PatternPropertiesConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PatternPropertiesConstraint.php @@ -7,6 +7,7 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Tool\EcmaPatternConverter; class PatternPropertiesConstraint implements ConstraintInterface { @@ -33,12 +34,15 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $properties = get_object_vars($value); + $basePath = $path ?? new JsonPointer(''); foreach ($properties as $propertyName => $propertyValue) { + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + foreach ($schema->patternProperties as $patternPropertyRegex => $patternPropertySchema) { - $matchPattern = $this->createPregMatchPattern($patternPropertyRegex); + $matchPattern = EcmaPatternConverter::toPcre($patternPropertyRegex); if (preg_match($matchPattern, (string) $propertyName)) { $schemaConstraint = $this->factory->createInstanceFor('schema'); - $schemaConstraint->check($propertyValue, $patternPropertySchema, $path, $i); + $schemaConstraint->check($propertyValue, $patternPropertySchema, $incrementedPath, $i); if ($schemaConstraint->isValid()) { continue; } @@ -48,25 +52,4 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } } } - - private function createPregMatchPattern(string $pattern): string - { - $replacements = [ -// '\D' => '[^0-9]', - '\d' => '[0-9]', - '\p{digit}' => '[0-9]', -// '\w' => '[A-Za-z0-9_]', -// '\W' => '[^A-Za-z0-9_]', -// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space - '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations - ]; - - $pattern = str_replace( - array_keys($replacements), - array_values($replacements), - $pattern - ); - - return '/' . str_replace('/', '\/', $pattern) . '/u'; - } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesConstraint.php index 25c55e6660..2abad178ec 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesConstraint.php @@ -31,13 +31,16 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } + $basePath = $path ?? new JsonPointer(''); foreach ($schema->properties as $propertyName => $propertySchema) { $schemaConstraint = $this->factory->createInstanceFor('schema'); if (!property_exists($value, $propertyName)) { continue; } - $schemaConstraint->check($value->{$propertyName}, $propertySchema, $path, $i); + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + + $schemaConstraint->check($value->{$propertyName}, $propertySchema, $incrementedPath, $i); if ($schemaConstraint->isValid()) { continue; } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/RefConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/RefConstraint.php index 9ec6efdac1..270da3d469 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/RefConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft07/RefConstraint.php @@ -27,11 +27,9 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - try { - $refSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); - } catch (\Exception $e) { - return; - } + // A $ref that cannot be resolved is a broken schema, not invalid data: the exception + // is left to propagate rather than letting the value pass as if the $ref was absent. + $refSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); $schemaConstraint = $this->factory->createInstanceFor('schema'); $schemaConstraint->check($value, $refSchema, $path, $i); diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/AdditionalPropertiesConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/AdditionalPropertiesConstraint.php index cdabc08103..373c0e8e5e 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/AdditionalPropertiesConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/AdditionalPropertiesConstraint.php @@ -8,6 +8,7 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Tool\EcmaPatternConverter; class AdditionalPropertiesConstraint implements ConstraintInterface { @@ -47,7 +48,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n foreach ($additionalProperties as $key => $_) { foreach ($patterns as $pattern) { - if (preg_match($this->createPregMatchPattern($pattern), (string) $key)) { + if (preg_match(EcmaPatternConverter::toPcre($pattern), (string) $key)) { unset($additionalProperties[$key]); break; } @@ -74,25 +75,4 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), ($path ?? new JsonPointer(''))->withAppendedPath($key), ['found' => $key]); } } - - private function createPregMatchPattern(string $pattern): string - { - $replacements = [ -// '\D' => '[^0-9]', -// '\d' => '[0-9]', - '\p{digit}' => '\p{Nd}', -// '\w' => '[A-Za-z0-9_]', -// '\W' => '[^A-Za-z0-9_]', -// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space, - '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations - ]; - - $pattern = str_replace( - array_keys($replacements), - array_values($replacements), - $pattern - ); - - return '/' . str_replace('/', '\/', $pattern) . '/u'; - } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php index 0b003b5461..da459bb7a1 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php @@ -46,6 +46,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('anyOf', $value, $schema, $path, $i); $this->checkForKeyword('oneOf', $value, $schema, $path, $i); $this->checkForKeyword('ifThenElse', $value, $schema, $path, $i); + $this->checkForKeyword('unevaluatedProperties', $value, $schema, $path, $i); $this->checkForKeyword('additionalProperties', $value, $schema, $path, $i); $this->checkForKeyword('items', $value, $schema, $path, $i); diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php index cc490225e7..0c8e9f17f4 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php @@ -12,6 +12,7 @@ class Factory extends \JsonSchema\Constraints\Factory protected $constraintMap = [ 'schema' => Draft2019Constraint::class, 'additionalProperties' => AdditionalPropertiesConstraint::class, + 'unevaluatedProperties' => UnevaluatedPropertiesConstraint::class, 'additionalItems' => AdditionalItemsConstraint::class, 'dependentSchemas' => DependentSchemasConstraint::class, 'dependentRequired' => DependentRequiredConstraint::class, diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/FormatConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/FormatConstraint.php index 75554c4183..94706da812 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/FormatConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/FormatConstraint.php @@ -6,6 +6,7 @@ use DateTimeZone; use JsonSchema\ConstraintError; +use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; @@ -18,14 +19,18 @@ class FormatConstraint implements ConstraintInterface { use ErrorBagProxy; + /** @var Factory */ + private $factory; + public function __construct(?Factory $factory = null) { - $this->initialiseErrorBag($factory ?: new Factory()); + $this->factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); } public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void { - if (!property_exists($schema, 'format')) { + if (!property_exists($schema, 'format') || $this->factory->getConfig(Constraint::CHECK_MODE_DISABLE_FORMAT)) { return; } @@ -218,7 +223,7 @@ private function validateDateTime(string $datetime, string $format): bool private function validateRegex(string $regex): bool { - return preg_match(self::jsonPatternToPhpRegex($regex), '') !== false; + return @preg_match(self::jsonPatternToPhpRegex($regex), '') !== false; } /** @@ -347,7 +352,7 @@ private function validateInternationalizedHostname(string $host): bool return false; } - $ascii = idn_to_ascii($label); + $ascii = idn_to_ascii($label, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); if ($ascii === false) { return false; } @@ -356,7 +361,7 @@ private function validateInternationalizedHostname(string $host): bool return false; } // LDH rule (after IDNA) - if (!preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i', $ascii)) { + if (!preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/i', $ascii)) { return false; } $asciiLabels[] = $ascii; diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/PatternConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/PatternConstraint.php index b36ec026b3..29d3966586 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/PatternConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/PatternConstraint.php @@ -8,6 +8,7 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Tool\EcmaPatternConverter; class PatternConstraint implements ConstraintInterface { @@ -32,32 +33,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - $matchPattern = $this->createPregMatchPattern($schema->pattern); + $matchPattern = EcmaPatternConverter::toPcre($schema->pattern); if (preg_match($matchPattern, $value) === 1) { return; } - $this->addError(ConstraintError::PATTERN(), $path, ['found' => $value, 'pattern' => $schema->pattern]); - } - - private function createPregMatchPattern(string $pattern): string - { - $replacements = [ - '\D' => '[^0-9]', - '\d' => '[0-9]', - '\p{digit}' => '[0-9]', - '\w' => '[A-Za-z0-9_]', - '\W' => '[^A-Za-z0-9_]', - '\s' => '[\s\x{200B}]', // Explicitly include zero width white space - '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations - ]; - - $pattern = str_replace( - array_keys($replacements), - array_values($replacements), - $pattern - ); - - return '/' . str_replace('/', '\/', $pattern) . '/u'; + $this->addError(ConstraintError::PATTERN(), $path, ['pattern' => $schema->pattern, 'found' => $value]); } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/PatternPropertiesConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/PatternPropertiesConstraint.php index 4415615401..8c25011f6f 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/PatternPropertiesConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/PatternPropertiesConstraint.php @@ -7,6 +7,7 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Tool\EcmaPatternConverter; class PatternPropertiesConstraint implements ConstraintInterface { @@ -38,7 +39,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); foreach ($schema->patternProperties as $patternPropertyRegex => $patternPropertySchema) { - $matchPattern = $this->createPregMatchPattern($patternPropertyRegex); + $matchPattern = EcmaPatternConverter::toPcre($patternPropertyRegex); if (preg_match($matchPattern, (string) $propertyName)) { $schemaConstraint = $this->factory->createInstanceFor('schema'); $schemaConstraint->check($propertyValue, $patternPropertySchema, $incrementedPath, $i); @@ -51,25 +52,4 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } } } - - private function createPregMatchPattern(string $pattern): string - { - $replacements = [ -// '\D' => '[^0-9]', - '\d' => '[0-9]', - '\p{digit}' => '[0-9]', -// '\w' => '[A-Za-z0-9_]', -// '\W' => '[^A-Za-z0-9_]', -// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space - '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations - ]; - - $pattern = str_replace( - array_keys($replacements), - array_values($replacements), - $pattern - ); - - return '/' . str_replace('/', '\/', $pattern) . '/u'; - } } diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/RefConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/RefConstraint.php index 989b1f0399..18d9c5f938 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/RefConstraint.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/RefConstraint.php @@ -27,11 +27,9 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - try { - $refSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); - } catch (\Exception $e) { - return; - } + // A $ref that cannot be resolved is a broken schema, not invalid data: the exception + // is left to propagate rather than letting the value pass as if the $ref was absent. + $refSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); $schemaConstraint = $this->factory->createInstanceFor('schema'); $schemaConstraint->check($value, $refSchema, $path, $i); diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php new file mode 100644 index 0000000000..809cbce57e --- /dev/null +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php @@ -0,0 +1,221 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!is_object($schema) || !property_exists($schema, 'unevaluatedProperties') || !is_object($value)) { + return; + } + + if ($schema->unevaluatedProperties === true) { + return; + } + + $evaluated = $this->collectEvaluatedProperties($schema, $value, $path); + $unevaluated = array_diff_key(get_object_vars($value), array_flip($evaluated)); + if (!$unevaluated) { + return; + } + + $basePath = $path ?? new JsonPointer(''); + foreach ($unevaluated as $propertyName => $propertyValue) { + $propertyPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + + if (is_object($schema->unevaluatedProperties)) { + $propertyConstraint = $this->factory->createInstanceFor('schema'); + $propertyConstraint->check($propertyValue, $schema->unevaluatedProperties, $propertyPath, $i); + if ($propertyConstraint->isValid()) { + continue; + } + + $this->addErrors($propertyConstraint->getErrors()); + continue; + } + + $this->addError(ConstraintError::UNEVALUATED_PROPERTIES(), $propertyPath, ['found' => $propertyName]); + } + } + + /** + * The validator does not propagate annotations between applicators, so this + * constraint derives the evaluated property names from the supported schema branches. + * + * @param array $visitedRefs + * + * @return array + */ + private function collectEvaluatedProperties(object $schema, object $value, ?JsonPointer $path = null, array $visitedRefs = []): array + { + $evaluated = []; + if (property_exists($schema, '$ref') && is_string($schema->{'$ref'})) { + $reference = $schema->{'$ref'}; + if (in_array($reference, $visitedRefs, true)) { + return []; + } + + try { + $visitedRefs[] = $reference; + $resolvedSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); + if (is_object($resolvedSchema)) { + $evaluated = array_merge( + $evaluated, + $this->collectEvaluatedProperties($resolvedSchema, $value, $path, $visitedRefs) + ); + } + } catch (\Exception $e) { + // Let the normal reference validation report resolution errors. + } + } + + $properties = get_object_vars($value); + + if (property_exists($schema, 'unevaluatedProperties') && $schema->unevaluatedProperties === true) { + $evaluated = array_merge($evaluated, array_keys($properties)); + } + + if (isset($schema->properties) && is_object($schema->properties)) { + $evaluated = array_merge( + $evaluated, + array_intersect(array_keys(get_object_vars($schema->properties)), array_keys($properties)) + ); + } + + if (isset($schema->patternProperties) && is_object($schema->patternProperties)) { + foreach ($properties as $propertyName => $_) { + foreach (array_keys(get_object_vars($schema->patternProperties)) as $pattern) { + if (preg_match($this->createPregMatchPattern($pattern), (string) $propertyName)) { + $evaluated[] = $propertyName; + break; + } + } + } + } + + if (property_exists($schema, 'additionalProperties')) { + if ($schema->additionalProperties === true) { + $evaluated = array_merge($evaluated, array_keys($properties)); + } elseif (is_object($schema->additionalProperties)) { + foreach (array_diff(array_keys($properties), $evaluated) as $propertyName) { + $propertyPath = $this->propertyPath($path, $propertyName); + if ($this->schemaIsValid($schema->additionalProperties, $properties[$propertyName], $propertyPath)) { + $evaluated[] = $propertyName; + } + } + } + } + + if (isset($schema->allOf) && is_array($schema->allOf)) { + foreach ($schema->allOf as $branch) { + if (!is_object($branch) || !$this->schemaIsValid($branch, $value, $path)) { + continue; + } + + $evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($branch, $value, $path, $visitedRefs)); + } + } + + if (isset($schema->anyOf) && is_array($schema->anyOf)) { + foreach ($schema->anyOf as $branch) { + if (!is_object($branch) || !$this->schemaIsValid($branch, $value, $path)) { + continue; + } + + $evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($branch, $value, $path, $visitedRefs)); + } + } + + if (isset($schema->oneOf) && is_array($schema->oneOf)) { + $validBranchCount = 0; + $validBranches = []; + foreach ($schema->oneOf as $branch) { + if ($this->schemaIsValid($branch, $value, $path)) { + ++$validBranchCount; + if (is_object($branch)) { + $validBranches[] = $branch; + } + } + } + + if ($validBranchCount === 1 && count($validBranches) === 1) { + $evaluated = array_merge( + $evaluated, + $this->collectEvaluatedProperties($validBranches[0], $value, $path, $visitedRefs) + ); + } + } + + if (property_exists($schema, 'if')) { + $ifMatches = $this->schemaIsValid($schema->if, $value, $path); + if ($ifMatches) { + if (is_object($schema->if)) { + $evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($schema->if, $value, $path, $visitedRefs)); + } + if (property_exists($schema, 'then') && is_object($schema->then) && $this->schemaIsValid($schema->then, $value, $path)) { + $evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($schema->then, $value, $path, $visitedRefs)); + } + } elseif (property_exists($schema, 'else') && is_object($schema->else) && $this->schemaIsValid($schema->else, $value, $path)) { + $evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($schema->else, $value, $path, $visitedRefs)); + } + } + + if (isset($schema->dependentSchemas) && is_object($schema->dependentSchemas)) { + foreach (get_object_vars($schema->dependentSchemas) as $propertyName => $dependentSchema) { + if (!array_key_exists($propertyName, $properties) || !is_object($dependentSchema) || !$this->schemaIsValid($dependentSchema, $value, $path)) { + continue; + } + + $evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($dependentSchema, $value, $path, $visitedRefs)); + } + } + + return array_values(array_unique($evaluated)); + } + + /** + * @param mixed $schema + * @param mixed $value + */ + private function schemaIsValid($schema, $value, ?JsonPointer $path = null): bool + { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $schema, $path); + + return $schemaConstraint->isValid(); + } + + private function propertyPath(?JsonPointer $path, string $propertyName): JsonPointer + { + $basePath = $path ?? new JsonPointer(''); + + return $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + } + + private function createPregMatchPattern(string $pattern): string + { + $pattern = str_replace('\\p{digit}', '\\p{Nd}', $pattern); + $pattern = str_replace('\\p{Letter}', '\\p{L}', $pattern); + + return '/' . str_replace('/', '\\/', $pattern) . '/u'; + } +} diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Tool/EcmaPatternConverter.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Tool/EcmaPatternConverter.php new file mode 100644 index 0000000000..96b0bafdc5 --- /dev/null +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Tool/EcmaPatternConverter.php @@ -0,0 +1,84 @@ + '0-9', + 'D' => '\x{0}-\x{2F}\x{3A}-\x{10FFFF}', + 'w' => 'A-Za-z0-9_', + 'W' => '\x{0}-\x{2F}\x{3A}-\x{40}\x{5B}-\x{5E}\x{60}\x{7B}-\x{10FFFF}', + 's' => '\s\x{200B}', // Explicitly include zero width white space + ]; + + /** + * PCRE rejects the ECMA long property names, so they are mapped to its abbreviations. + */ + private const PROPERTY_NAMES = [ + '\p{digit}' => '\p{Nd}', + '\p{Letter}' => '\p{L}', + ]; + + public static function toPcre(string $pattern): string + { + $regex = ''; + $inClass = false; + $length = strlen($pattern); + + for ($i = 0; $i < $length; $i++) { + $char = $pattern[$i]; + + if ($char === '\\' && $i + 1 < $length) { + $next = $pattern[$i + 1]; + + if (isset(self::SHORTHAND_CLASSES[$next])) { + $regex .= $inClass ? self::SHORTHAND_CLASSES[$next] : '[' . self::SHORTHAND_CLASSES[$next] . ']'; + $i++; + continue; + } + + foreach (self::PROPERTY_NAMES as $ecma => $pcre) { + if (substr_compare($pattern, $ecma, $i, strlen($ecma)) === 0) { + $regex .= $pcre; + $i += strlen($ecma) - 1; + continue 2; + } + } + + // Any other escape, an escaped backslash or delimiter included, is kept as is. + $regex .= $char . $next; + $i++; + continue; + } + + if ($char === '[' && !$inClass) { + $inClass = true; + } elseif ($char === ']' && $inClass) { + $inClass = false; + } elseif ($char === '/') { + $char = '\/'; + } + + $regex .= $char; + } + + return '/' . $regex . '/u'; + } +} diff --git a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Uri/UriRetriever.php b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Uri/UriRetriever.php index 76f1c7723e..781b3d5919 100644 --- a/core/vendor/justinrainbow/json-schema/src/JsonSchema/Uri/UriRetriever.php +++ b/core/vendor/justinrainbow/json-schema/src/JsonSchema/Uri/UriRetriever.php @@ -27,6 +27,8 @@ */ class UriRetriever implements BaseUriRetrieverInterface { + private const PACKAGE_SCHEME = 'package://'; + /** * @var array Map of URL translations */ @@ -352,7 +354,11 @@ public function translate($uri) } // translate references to local files within the json-schema package - $uri = preg_replace('|^package://|', sprintf('file://%s/', realpath(__DIR__ . '/../../..')), $uri); + // a plain replacement, because the package path is data: a Windows path such as + // C:\Users\4223627 would otherwise have its \4 read as a backreference + if (0 === strpos($uri, self::PACKAGE_SCHEME)) { + $uri = sprintf('file://%s/', realpath(__DIR__ . '/../../..')) . substr($uri, strlen(self::PACKAGE_SCHEME)); + } return $uri; } diff --git a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar.php b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar.php index 9f73a04e16..8454cb0790 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar.php @@ -34,20 +34,20 @@ ]; return [ - 'year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'a_year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'a_month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'a_week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'a_day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'a_hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'a_minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), - 'a_second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), + 'year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'a_year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'a_month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'a_week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'a_day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'a_hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'a_minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), + 'a_second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), 'ago' => 'منذ :time', 'from_now' => ':time من الآن', 'after' => 'بعد :time', @@ -61,7 +61,7 @@ 'diff_tomorrow_regexp' => 'غدً?اً?(?:\\s+عند)?(?:\\s+الساعة)?', 'diff_before_yesterday' => 'قبل الأمس', 'diff_after_tomorrow' => 'بعد غد', - 'period_recurrences' => implode('|', ['{0}:count مرة', '{1}مرة', '{2}مرتين', ']2,11[:count مرات', ']10,Inf[:count مرة']), + 'period_recurrences' => implode('|', [':count مرة', 'مرة', 'مرتين', ':count مرات', ':count مرة', ':count مرة']), 'period_interval' => 'كل :interval', 'period_start_date' => 'من :date', 'period_end_date' => 'إلى :date', diff --git a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_DZ.php b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_DZ.php index 01b800b0d8..297a687362 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_DZ.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_DZ.php @@ -34,20 +34,20 @@ ]; return [ - 'year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'a_year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'a_month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'a_week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'a_day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'a_hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'a_minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), - 'a_second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), + 'year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'a_year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'a_month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'a_week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'a_day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'a_hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'a_minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), + 'a_second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), 'ago' => 'منذ :time', 'from_now' => 'في :time', 'after' => 'بعد :time', @@ -61,7 +61,7 @@ 'diff_tomorrow_regexp' => 'غدً?اً?(?:\\s+على)?(?:\\s+الساعة)?', 'diff_before_yesterday' => 'قبل الأمس', 'diff_after_tomorrow' => 'بعد غد', - 'period_recurrences' => implode('|', ['{0}:count مرة', '{1}مرة', '{2}مرتين', ']2,11[:count مرات', ']10,Inf[:count مرة']), + 'period_recurrences' => implode('|', [':count مرة', 'مرة', 'مرتين', ':count مرات', ':count مرة', ':count مرة']), 'period_interval' => 'كل :interval', 'period_start_date' => 'من :date', 'period_end_date' => 'إلى :date', diff --git a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_KW.php b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_KW.php index 04cfafc2df..2e1dd5f339 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_KW.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_KW.php @@ -35,20 +35,20 @@ ]; return [ - 'year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'a_year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'a_month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'a_week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'a_day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'a_hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'a_minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), - 'a_second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), + 'year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'a_year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'a_month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'a_week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'a_day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'a_hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'a_minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), + 'a_second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), 'ago' => 'منذ :time', 'from_now' => 'في :time', 'after' => 'بعد :time', @@ -62,7 +62,7 @@ 'diff_tomorrow_regexp' => 'غدً?اً?(?:\\s+على)?(?:\\s+الساعة)?', 'diff_before_yesterday' => 'قبل الأمس', 'diff_after_tomorrow' => 'بعد غد', - 'period_recurrences' => implode('|', ['{0}:count مرة', '{1}مرة', '{2}مرتين', ']2,11[:count مرات', ']10,Inf[:count مرة']), + 'period_recurrences' => implode('|', [':count مرة', 'مرة', 'مرتين', ':count مرات', ':count مرة', ':count مرة']), 'period_interval' => 'كل :interval', 'period_start_date' => 'من :date', 'period_end_date' => 'إلى :date', diff --git a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_MA.php b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_MA.php index ac5392d77f..2ce193af24 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_MA.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_MA.php @@ -33,20 +33,20 @@ ]; return [ - 'year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'a_year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'a_month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'a_week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'a_day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'a_hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'a_minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), - 'a_second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), + 'year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'a_year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'a_month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'a_week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'a_day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'a_hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'a_minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), + 'a_second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), 'ago' => 'منذ :time', 'from_now' => 'في :time', 'after' => 'بعد :time', @@ -60,7 +60,7 @@ 'diff_tomorrow_regexp' => 'غدً?اً?(?:\\s+على)?(?:\\s+الساعة)?', 'diff_before_yesterday' => 'قبل الأمس', 'diff_after_tomorrow' => 'بعد غد', - 'period_recurrences' => implode('|', ['{0}:count مرة', '{1}مرة', '{2}مرتين', ']2,11[:count مرات', ']10,Inf[:count مرة']), + 'period_recurrences' => implode('|', [':count مرة', 'مرة', 'مرتين', ':count مرات', ':count مرة', ':count مرة']), 'period_interval' => 'كل :interval', 'period_start_date' => 'من :date', 'period_end_date' => 'إلى :date', diff --git a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_SA.php b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_SA.php index 7967c161e5..44d396e9fc 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_SA.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_SA.php @@ -34,20 +34,20 @@ ]; return [ - 'year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'a_year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'a_month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'a_week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'a_day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'a_hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'a_minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), - 'a_second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), + 'year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'a_year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'a_month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'a_week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'a_day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'a_hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'a_minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), + 'a_second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), 'ago' => 'منذ :time', 'from_now' => 'في :time', 'after' => 'بعد :time', @@ -61,7 +61,7 @@ 'diff_tomorrow_regexp' => 'غدً?اً?(?:\\s+على)?(?:\\s+الساعة)?', 'diff_before_yesterday' => 'قبل الأمس', 'diff_after_tomorrow' => 'بعد غد', - 'period_recurrences' => implode('|', ['{0}:count مرة', '{1}مرة', '{2}مرتين', ']2,11[:count مرات', ']10,Inf[:count مرة']), + 'period_recurrences' => implode('|', [':count مرة', 'مرة', 'مرتين', ':count مرات', ':count مرة', ':count مرة']), 'period_interval' => 'كل :interval', 'period_start_date' => 'من :date', 'period_end_date' => 'إلى :date', diff --git a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_Shakl.php b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_Shakl.php index 3ab8f23864..b5d2b9c2a3 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_Shakl.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_Shakl.php @@ -34,20 +34,20 @@ ]; return [ - 'year' => implode('|', ['{0}:count سَنَة', '{1}سَنَة', '{2}سَنَتَيْن', ']2,11[:count سَنَوَات', ']10,Inf[:count سَنَة']), - 'a_year' => implode('|', ['{0}:count سَنَة', '{1}سَنَة', '{2}سَنَتَيْن', ']2,11[:count سَنَوَات', ']10,Inf[:count سَنَة']), - 'month' => implode('|', ['{0}:count شَهْرَ', '{1}شَهْرَ', '{2}شَهْرَيْن', ']2,11[:count أَشْهُر', ']10,Inf[:count شَهْرَ']), - 'a_month' => implode('|', ['{0}:count شَهْرَ', '{1}شَهْرَ', '{2}شَهْرَيْن', ']2,11[:count أَشْهُر', ']10,Inf[:count شَهْرَ']), - 'week' => implode('|', ['{0}:count أُسْبُوع', '{1}أُسْبُوع', '{2}أُسْبُوعَيْن', ']2,11[:count أَسَابِيع', ']10,Inf[:count أُسْبُوع']), - 'a_week' => implode('|', ['{0}:count أُسْبُوع', '{1}أُسْبُوع', '{2}أُسْبُوعَيْن', ']2,11[:count أَسَابِيع', ']10,Inf[:count أُسْبُوع']), - 'day' => implode('|', ['{0}:count يَوْم', '{1}يَوْم', '{2}يَوْمَيْن', ']2,11[:count أَيَّام', ']10,Inf[:count يَوْم']), - 'a_day' => implode('|', ['{0}:count يَوْم', '{1}يَوْم', '{2}يَوْمَيْن', ']2,11[:count أَيَّام', ']10,Inf[:count يَوْم']), - 'hour' => implode('|', ['{0}:count سَاعَة', '{1}سَاعَة', '{2}سَاعَتَيْن', ']2,11[:count سَاعَات', ']10,Inf[:count سَاعَة']), - 'a_hour' => implode('|', ['{0}:count سَاعَة', '{1}سَاعَة', '{2}سَاعَتَيْن', ']2,11[:count سَاعَات', ']10,Inf[:count سَاعَة']), - 'minute' => implode('|', ['{0}:count دَقِيقَة', '{1}دَقِيقَة', '{2}دَقِيقَتَيْن', ']2,11[:count دَقَائِق', ']10,Inf[:count دَقِيقَة']), - 'a_minute' => implode('|', ['{0}:count دَقِيقَة', '{1}دَقِيقَة', '{2}دَقِيقَتَيْن', ']2,11[:count دَقَائِق', ']10,Inf[:count دَقِيقَة']), - 'second' => implode('|', ['{0}:count ثَانِيَة', '{1}ثَانِيَة', '{2}ثَانِيَتَيْن', ']2,11[:count ثَوَان', ']10,Inf[:count ثَانِيَة']), - 'a_second' => implode('|', ['{0}:count ثَانِيَة', '{1}ثَانِيَة', '{2}ثَانِيَتَيْن', ']2,11[:count ثَوَان', ']10,Inf[:count ثَانِيَة']), + 'year' => implode('|', [':count سَنَة', 'سَنَة', 'سَنَتَيْن', ':count سَنَوَات', ':count سَنَة', ':count سَنَة']), + 'a_year' => implode('|', [':count سَنَة', 'سَنَة', 'سَنَتَيْن', ':count سَنَوَات', ':count سَنَة', ':count سَنَة']), + 'month' => implode('|', [':count شَهْرَ', 'شَهْرَ', 'شَهْرَيْن', ':count أَشْهُر', ':count شَهْرَ', ':count شَهْرَ']), + 'a_month' => implode('|', [':count شَهْرَ', 'شَهْرَ', 'شَهْرَيْن', ':count أَشْهُر', ':count شَهْرَ', ':count شَهْرَ']), + 'week' => implode('|', [':count أُسْبُوع', 'أُسْبُوع', 'أُسْبُوعَيْن', ':count أَسَابِيع', ':count أُسْبُوع', ':count أُسْبُوع']), + 'a_week' => implode('|', [':count أُسْبُوع', 'أُسْبُوع', 'أُسْبُوعَيْن', ':count أَسَابِيع', ':count أُسْبُوع', ':count أُسْبُوع']), + 'day' => implode('|', [':count يَوْم', 'يَوْم', 'يَوْمَيْن', ':count أَيَّام', ':count يَوْم', ':count يَوْم']), + 'a_day' => implode('|', [':count يَوْم', 'يَوْم', 'يَوْمَيْن', ':count أَيَّام', ':count يَوْم', ':count يَوْم']), + 'hour' => implode('|', [':count سَاعَة', 'سَاعَة', 'سَاعَتَيْن', ':count سَاعَات', ':count سَاعَة', ':count سَاعَة']), + 'a_hour' => implode('|', [':count سَاعَة', 'سَاعَة', 'سَاعَتَيْن', ':count سَاعَات', ':count سَاعَة', ':count سَاعَة']), + 'minute' => implode('|', [':count دَقِيقَة', 'دَقِيقَة', 'دَقِيقَتَيْن', ':count دَقَائِق', ':count دَقِيقَة', ':count دَقِيقَة']), + 'a_minute' => implode('|', [':count دَقِيقَة', 'دَقِيقَة', 'دَقِيقَتَيْن', ':count دَقَائِق', ':count دَقِيقَة', ':count دَقِيقَة']), + 'second' => implode('|', [':count ثَانِيَة', 'ثَانِيَة', 'ثَانِيَتَيْن', ':count ثَوَان', ':count ثَانِيَة', ':count ثَانِيَة']), + 'a_second' => implode('|', [':count ثَانِيَة', 'ثَانِيَة', 'ثَانِيَتَيْن', ':count ثَوَان', ':count ثَانِيَة', ':count ثَانِيَة']), 'ago' => 'مُنْذُ :time', 'from_now' => 'مِنَ الْآن :time', 'after' => 'بَعْدَ :time', @@ -63,7 +63,7 @@ 'diff_tomorrow_regexp' => 'غدً?اً?(?:\\s+عند)?(?:\\s+الساعة)?', 'diff_before_yesterday' => 'قبل الأمس', 'diff_after_tomorrow' => 'بعد غد', - 'period_recurrences' => implode('|', ['{0}:count مرة', '{1}مرة', '{2}مرتين', ']2,11[:count مرات', ']10,Inf[:count مرة']), + 'period_recurrences' => implode('|', [':count مرة', 'مرة', 'مرتين', ':count مرات', ':count مرة', ':count مرة']), 'period_interval' => 'كل :interval', 'period_start_date' => 'من :date', 'period_end_date' => 'إلى :date', diff --git a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_TN.php b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_TN.php index be04f4ef86..5d805223c4 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_TN.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Lang/ar_TN.php @@ -32,20 +32,20 @@ ]; return [ - 'year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'a_year' => implode('|', ['{0}:count سنة', '{1}سنة', '{2}سنتين', ']2,11[:count سنوات', ']10,Inf[:count سنة']), - 'month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'a_month' => implode('|', ['{0}:count شهر', '{1}شهر', '{2}شهرين', ']2,11[:count أشهر', ']10,Inf[:count شهر']), - 'week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'a_week' => implode('|', ['{0}:count أسبوع', '{1}أسبوع', '{2}أسبوعين', ']2,11[:count أسابيع', ']10,Inf[:count أسبوع']), - 'day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'a_day' => implode('|', ['{0}:count يوم', '{1}يوم', '{2}يومين', ']2,11[:count أيام', ']10,Inf[:count يوم']), - 'hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'a_hour' => implode('|', ['{0}:count ساعة', '{1}ساعة', '{2}ساعتين', ']2,11[:count ساعات', ']10,Inf[:count ساعة']), - 'minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'a_minute' => implode('|', ['{0}:count دقيقة', '{1}دقيقة', '{2}دقيقتين', ']2,11[:count دقائق', ']10,Inf[:count دقيقة']), - 'second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), - 'a_second' => implode('|', ['{0}:count ثانية', '{1}ثانية', '{2}ثانيتين', ']2,11[:count ثواني', ']10,Inf[:count ثانية']), + 'year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'a_year' => implode('|', [':count سنة', 'سنة', 'سنتين', ':count سنوات', ':count سنة', ':count سنة']), + 'month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'a_month' => implode('|', [':count شهر', 'شهر', 'شهرين', ':count أشهر', ':count شهر', ':count شهر']), + 'week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'a_week' => implode('|', [':count أسبوع', 'أسبوع', 'أسبوعين', ':count أسابيع', ':count أسبوع', ':count أسبوع']), + 'day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'a_day' => implode('|', [':count يوم', 'يوم', 'يومين', ':count أيام', ':count يوم', ':count يوم']), + 'hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'a_hour' => implode('|', [':count ساعة', 'ساعة', 'ساعتين', ':count ساعات', ':count ساعة', ':count ساعة']), + 'minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'a_minute' => implode('|', [':count دقيقة', 'دقيقة', 'دقيقتين', ':count دقائق', ':count دقيقة', ':count دقيقة']), + 'second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), + 'a_second' => implode('|', [':count ثانية', 'ثانية', 'ثانيتين', ':count ثواني', ':count ثانية', ':count ثانية']), 'ago' => 'منذ :time', 'from_now' => 'في :time', 'after' => 'بعد :time', @@ -59,7 +59,7 @@ 'diff_tomorrow_regexp' => 'غدً?اً?(?:\\s+على)?(?:\\s+الساعة)?', 'diff_before_yesterday' => 'قبل الأمس', 'diff_after_tomorrow' => 'بعد غد', - 'period_recurrences' => implode('|', ['{0}:count مرة', '{1}مرة', '{2}مرتين', ']2,11[:count مرات', ']10,Inf[:count مرة']), + 'period_recurrences' => implode('|', [':count مرة', 'مرة', 'مرتين', ':count مرات', ':count مرة', ':count مرة']), 'period_interval' => 'كل :interval', 'period_start_date' => 'من :date', 'period_end_date' => 'إلى :date', diff --git a/core/vendor/nesbot/carbon/src/Carbon/Lang/ru.php b/core/vendor/nesbot/carbon/src/Carbon/Lang/ru.php index fe9607f46f..320bdb4e76 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Lang/ru.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Lang/ru.php @@ -67,10 +67,10 @@ 'second' => ':count секунда|:count секунды|:count секунд', 's' => ':count сек.', 'a_second' => '{1}несколько секунд|:count секунду|:count секунды|:count секунд', - 'millisecond' => '{1}:count миллисекунда|:count миллисекунды|:count миллисекунд', + 'millisecond' => ':count миллисекунда|:count миллисекунды|:count миллисекунд', 'a_millisecond' => '{1}миллисекунда|:count миллисекунда|:count миллисекунды|:count миллисекунд', 'ms' => ':count мс', - 'microsecond' => '{1}:count микросекунда|:count микросекунды|:count микросекунд', + 'microsecond' => ':count микросекунда|:count микросекунды|:count микросекунд', 'a_microsecond' => '{1}микросекунда|:count микросекунда|:count микросекунды|:count микросекунд', 'ago' => static fn (string $time) => $transformDiff($time).' назад', 'from_now' => static fn (string $time) => 'через '.$transformDiff($time), diff --git a/core/vendor/nesbot/carbon/src/Carbon/Traits/Eras.php b/core/vendor/nesbot/carbon/src/Carbon/Traits/Eras.php index e124a19187..a7ca76cd8f 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Traits/Eras.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Traits/Eras.php @@ -200,9 +200,9 @@ private static function resolveIsoFormatEras(string $format, string $time, ?stri foreach ($tokens as $index => $token) { if ($isEraToken($token)) { $eraTokens[$index] = $token; - $eraPattern = $token[0] === 'N' ? self::getEraNamesPattern($eras, $token) : ($token === 'yo' ? '\\d+\\p{L}*' : '\\d+'); + $eraPattern = $token[0] === 'N' ? self::getEraNamesPattern($eras, $token) : ($token === 'yo' ? '[0-9]+\\p{L}*' : '[0-9]+'); $pattern .= "($eraPattern)"; - $generic .= $token[0] === 'N' ? '(.+?)' : '(\\d+\\p{L}*)'; + $generic .= $token[0] === 'N' ? '(.+?)' : '([0-9]+\\p{L}*)'; continue; } @@ -335,10 +335,10 @@ private static function getIsoTokenPattern(string $token): string } return match (true) { - $token === 'YY' => '\d{2}', - (bool) preg_match('/^(?:Y+|x|X)$/', $token) => '[+-]?\d+', - (bool) preg_match('/^(?:DDD[Do]?|[Hh]mm(?:ss)?|S+|O[YMDHhms])$/', $token) => '\d+', - (bool) preg_match('/^(?:[DMHhkms]{1,2}|[MD]o)$/', $token) => '\d{1,2}', + $token === 'YY' => '[0-9]{2}', + (bool) preg_match('/^(?:Y+|x|X)$/', $token) => '[+-]?[0-9]+', + (bool) preg_match('/^(?:DDD[Do]?|[Hh]mm(?:ss)?|S+|O[YMDHhms])$/', $token) => '[0-9]+', + (bool) preg_match('/^(?:[DMHhkms]{1,2}|[MD]o)$/', $token) => '[0-9]{1,2}', $token === ' ' => '\s', $token === '?' => '.', $token === '!' || $token === '|' => '', diff --git a/core/vendor/nesbot/carbon/src/Carbon/Traits/Week.php b/core/vendor/nesbot/carbon/src/Carbon/Traits/Week.php index 0101d055eb..3f928b528d 100644 --- a/core/vendor/nesbot/carbon/src/Carbon/Traits/Week.php +++ b/core/vendor/nesbot/carbon/src/Carbon/Traits/Week.php @@ -85,6 +85,7 @@ public function weekYear($year = null, $dayOfWeek = null, $dayOfYear = null) $week = $this->week(null, $dayOfWeek, $dayOfYear); $day = $this->dayOfWeek; + $time = $this->avoidMutation(); $date = $this->year($year); $date = match ($date->weekYear(null, $dayOfWeek, $dayOfYear) - $year) { @@ -97,11 +98,11 @@ public function weekYear($year = null, $dayOfWeek = null, $dayOfYear = null) ->addWeeks($week - $date->week(null, $dayOfWeek, $dayOfYear)) ->startOfWeek($dayOfWeek); - if ($date->dayOfWeek === $day) { - return $date; + if ($date->dayOfWeek !== $day) { + $date = $date->next($day); } - return $date->next($day); + return $date->setTimeFrom($time); } $year = $this->year; diff --git a/core/vendor/symfony/console/Application.php b/core/vendor/symfony/console/Application.php index 68acadf57e..41ddc95b7e 100644 --- a/core/vendor/symfony/console/Application.php +++ b/core/vendor/symfony/console/Application.php @@ -1045,7 +1045,9 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI // We register application signals, so that we can dispatch the event foreach ($this->signalsToDispatchEvent as $signal) { - $signalEvent = new ConsoleSignalEvent($command, $input, $output, $signal); + // When this application runs inside another application that handles the signal, the outer application decides if the process exits + $previousHandler = pcntl_signal_get_handler($signal); + $signalEvent = new ConsoleSignalEvent($command, $input, $output, $signal, \is_array($previousHandler) && $previousHandler[0] instanceof SignalRegistry ? false : 0); $alarmEvent = \SIGALRM === $signal ? new ConsoleAlarmEvent($command, $input, $output) : null; $signalRegistry->register($signal, function ($signal) use ($signalEvent, $alarmEvent, $command, $commandSignals, $input, $output) { diff --git a/core/vendor/symfony/console/Command/CompleteCommand.php b/core/vendor/symfony/console/Command/CompleteCommand.php index 44d23cc638..bdc3a8c53e 100644 --- a/core/vendor/symfony/console/Command/CompleteCommand.php +++ b/core/vendor/symfony/console/Command/CompleteCommand.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Command; +use Symfony\Component\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; @@ -20,6 +21,7 @@ use Symfony\Component\Console\Completion\Output\ZshCompletionOutput; use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\ExceptionInterface; +use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -35,6 +37,7 @@ final class CompleteCommand extends Command public const COMPLETION_API_VERSION = '1'; private array $completionOutputs; + private ?InputDefinition $fullDefinition = null; private bool $isDebug = false; /** @@ -52,6 +55,53 @@ public function __construct(array $completionOutputs = []) parent::__construct(); } + public function setApplication(?Application $application): void + { + $this->fullDefinition = null; + + parent::setApplication($application); + } + + public function setDefinition(array|InputDefinition $definition): static + { + $this->fullDefinition = null; + + return parent::setDefinition($definition); + } + + /** + * @internal + */ + public function mergeApplicationDefinition(bool $mergeArgs = true): void + { + if (!$application = $this->getApplication()) { + return; + } + + $definition = $this->getNativeDefinition(); + $this->fullDefinition = new InputDefinition(); + $this->fullDefinition->setOptions($definition->getOptions()); + + foreach ($application->getDefinition()->getOptions() as $option) { + // the completion scripts rely on the options of this command, they win over the conflicting application ones + if ($definition->hasOption($option->getName()) || array_filter(explode('|', $option->getShortcut() ?? ''), $definition->hasShortcut(...))) { + continue; + } + + $this->fullDefinition->addOption($option); + } + + if ($mergeArgs) { + $this->fullDefinition->setArguments($application->getDefinition()->getArguments()); + } + $this->fullDefinition->addArguments($definition->getArguments()); + } + + public function getDefinition(): InputDefinition + { + return $this->fullDefinition ?? parent::getDefinition(); + } + protected function configure(): void { $this diff --git a/core/vendor/symfony/console/Command/InvokableCommand.php b/core/vendor/symfony/console/Command/InvokableCommand.php index 016da5a018..808083578d 100644 --- a/core/vendor/symfony/console/Command/InvokableCommand.php +++ b/core/vendor/symfony/console/Command/InvokableCommand.php @@ -35,6 +35,11 @@ */ class InvokableCommand implements SignalableCommandInterface { + /** + * @var list + */ + private static array $running = []; + private readonly ?SignalableCommandInterface $signalableCommand; private readonly \ReflectionFunction $invokable; /** @@ -58,7 +63,13 @@ public function __construct( */ public function __invoke(InputInterface $input, OutputInterface $output): int { - $statusCode = $this->invokable->invoke(...$this->getParameters($this->invokable, $input, $output)); + self::$running[] = [$this->invokable->getClosureThis(), $this->command]; + + try { + $statusCode = $this->invokable->invoke(...$this->getParameters($this->invokable, $input, $output)); + } finally { + array_pop(self::$running); + } if (!\is_int($statusCode)) { if ($this->triggerDeprecations) { @@ -114,6 +125,20 @@ public function getCode(): callable return $this->code; } + /** + * Returns the command that is currently running the given object, if any. + */ + public static function getRunningCommand(object $code): ?Command + { + for ($i = \count(self::$running) - 1; 0 <= $i; --$i) { + if ($code === self::$running[$i][0]) { + return self::$running[$i][1]; + } + } + + return null; + } + private function getClosure(callable $code): \Closure { if (!$code instanceof \Closure) { diff --git a/core/vendor/symfony/console/Command/LockableTrait.php b/core/vendor/symfony/console/Command/LockableTrait.php index b7abd2fdc5..25261a7552 100644 --- a/core/vendor/symfony/console/Command/LockableTrait.php +++ b/core/vendor/symfony/console/Command/LockableTrait.php @@ -55,20 +55,22 @@ private function lock(?string $name = null, bool $blocking = false): bool if (!$name) { if ($this instanceof Command) { $name = $this->getName(); + } elseif ($command = InvokableCommand::getRunningCommand($this)) { + $name = $command->getName(); } elseif ($attribute = (new \ReflectionClass($this::class))->getAttributes(AsCommand::class)) { - $name = $attribute[0]->newInstance()->name; + $name = explode('|', ltrim($attribute[0]->newInstance()->name, '|'), 2)[0]; } else { throw new LogicException(\sprintf('Lock name missing: provide it via "%s()", #[AsCommand] attribute, or by extending Command class.', __METHOD__)); } } - $this->lock = $this->lockFactory->createLock($name); - if (!$this->lock->acquire($blocking)) { - $this->lock = null; - + $lock = $this->lockFactory->createLock($name); + if (!$lock->acquire($blocking)) { return false; } + $this->lock = $lock; + return true; } @@ -77,9 +79,9 @@ private function lock(?string $name = null, bool $blocking = false): bool */ private function release(): void { - if ($this->lock) { - $this->lock->release(); + if ($lock = $this->lock) { $this->lock = null; + $lock->release(); } } } diff --git a/core/vendor/symfony/console/Formatter/OutputFormatter.php b/core/vendor/symfony/console/Formatter/OutputFormatter.php index 1e757183e1..21800ebe6c 100644 --- a/core/vendor/symfony/console/Formatter/OutputFormatter.php +++ b/core/vendor/symfony/console/Formatter/OutputFormatter.php @@ -24,8 +24,12 @@ */ class OutputFormatter implements WrappableOutputFormatterInterface { + private const ANSI_PATTERN = '\\x1B(?:\\[[0-9;:]*m|\\]8;[^;]*;[^\\x1B]*\\x1B\\\\)'; + private const ANSI_REGEX = '/'.self::ANSI_PATTERN.'/'; + private array $styles = []; private OutputFormatterStyleStack $styleStack; + private array $charWidths = []; public function __clone() { @@ -128,6 +132,7 @@ public function formatAndWrap(?string $message, int $width): string $openTagRegex = '[a-z](?:[^\\\\<>]*+ | \\\\.)*'; $closeTagRegex = '[a-z][^<>]*+'; $currentLineLength = 0; + $lineBreak = false; preg_match_all("#<(($openTagRegex) | /($closeTagRegex)?)>#ix", $message, $matches, \PREG_OFFSET_CAPTURE); foreach ($matches[0] as $i => $match) { $pos = $match[1]; @@ -139,7 +144,7 @@ public function formatAndWrap(?string $message, int $width): string // tags are delimited by "<" and ">", which are ASCII, so a tag offset can never // fall inside a multibyte sequence: slicing by bytes is safe here - $output .= $this->applyCurrentStyle(substr($message, $offset, $pos - $offset), $output, $width, $currentLineLength); + $output .= $this->applyCurrentStyle(substr($message, $offset, $pos - $offset), $width, $currentLineLength, $lineBreak); $offset = $pos + \strlen($text); // opening tag? @@ -153,7 +158,7 @@ public function formatAndWrap(?string $message, int $width): string // $this->styleStack->pop(); } elseif (null === $style = $this->createStyleFromString($tag)) { - $output .= $this->applyCurrentStyle($text, $output, $width, $currentLineLength); + $output .= $this->applyCurrentStyle($text, $width, $currentLineLength, $lineBreak); } elseif ($open) { $this->styleStack->push($style); } else { @@ -161,9 +166,18 @@ public function formatAndWrap(?string $message, int $width): string } } - $output .= $this->applyCurrentStyle(substr($message, $offset), $output, $width, $currentLineLength); + $output .= $this->applyCurrentStyle(substr($message, $offset), $width, $currentLineLength, $lineBreak); + + if ($lineBreak) { + $output .= "\n"; + } + + if ($width && str_contains($output, "\r")) { + // keep "\r\n" line breaks together when ANSI codes end the line + $output = preg_replace('/\r((?:'.self::ANSI_PATTERN.')++)(?=\n|$)/', "\$1\r", $output); + } - return strtr($output, ["\0" => '\\', '\\<' => '<', '\\>' => '>']); + return str_replace("\0", '\\', $output); } public function getStyleStack(): OutputFormatterStyleStack @@ -176,8 +190,8 @@ public function getStyleStack(): OutputFormatterStyleStack */ private function createStyleFromString(string $string): ?OutputFormatterStyleInterface { - if (isset($this->styles[$string])) { - return $this->styles[$string]; + if (isset($this->styles[$lowerString = strtolower($string)])) { + return $this->styles[$lowerString]; } if (!preg_match_all('/([^=]+)=([^;]+)(;|$)/', $string, $matches, \PREG_SET_ORDER)) { @@ -213,58 +227,108 @@ private function createStyleFromString(string $string): ?OutputFormatterStyleInt /** * Applies current style from stack to text, if must be applied. */ - private function applyCurrentStyle(string $text, string $current, int $width, int &$currentLineLength): string + private function applyCurrentStyle(string $text, int $width, int &$currentLineLength, bool &$lineBreak): string { if ('' === $text) { return ''; } - if (!$width) { + if (!$width || str_contains($text, "\e") && '' === preg_replace(self::ANSI_REGEX, '', $text)) { + $text = str_replace(['\\<', '\\>'], ['<', '>'], $text); + return $this->isDecorated() ? $this->styleStack->getCurrent()->apply($text) : $text; } - if (!$currentLineLength && '' !== $current) { - $text = ltrim($text); + $text = str_replace(['\\<', '\\>'], ['<', '>'], $text); + $prefix = ''; + $visibleText = str_contains($text, "\e") ? preg_replace(self::ANSI_REGEX, '', $text) : $text; + + if ($currentLineLength >= $width) { + // the current line is full: drop the whitespace that follows and break the line before the next character + if (str_contains($text, "\e")) { + $text = preg_replace_callback('/^(?:[\\x00\\x09\\x0B\\x0D\\x20]|'.self::ANSI_PATTERN.')++/', static function ($match) { + preg_match_all(self::ANSI_REGEX, $match[0], $ansi); + + return implode('', $ansi[0]); + }, $text); + $visibleText = ltrim($visibleText, " \t\r\0\x0B"); + } else { + $text = $visibleText = ltrim($text, " \t\r\0\x0B"); + } + + // whitespace at the end of the message still breaks the line + if ($lineBreak = '' === $visibleText) { + return '' === $text || !$this->isDecorated() ? $text : $this->styleStack->getCurrent()->apply($text); + } + + if ("\n" !== $visibleText[0]) { + $prefix = "\n"; + } + + $currentLineLength = 0; } + $trailingNewlines = substr($visibleText, \strlen(rtrim($visibleText, "\r\n"))); + if ($currentLineLength) { $lines = explode("\n", $text, 2); - $prefix = Helper::substr($lines[0], 0, $i = $width - $currentLineLength)."\n"; - $text = Helper::substr($lines[0], $i); + $eol = "\n"; + if (isset($lines[1]) && str_ends_with($lines[0], "\r")) { + $lines[0] = substr($lines[0], 0, -1); + $eol = "\r\n"; + } + $i = $width - $currentLineLength; + if ($widths = $this->getWidths($lines[0])) { + $room = $i; + $i = \count($widths); + foreach ($widths as $j => $charWidth) { + if (0 > $room -= $charWidth) { + $i = $j; + break; + } + } + } + $prefix = ($i ? $this->substr($lines[0], 0, $i) : '')."\n"; + $text = $i ? $this->substr($lines[0], $i) : $lines[0]; if (isset($lines[1])) { // $prefix may contain the full first line in which the \n is already a part of $prefix. if ('' !== $text) { - $text .= "\n"; + $text .= $eol; + } else { + $prefix = substr($prefix, 0, -1).$eol; } $text .= $lines[1]; } - } else { - $prefix = ''; } - preg_match('~(\\n)$~', $text, $matches); $text = $prefix.$this->addLineBreaks($text, $width); - $text = rtrim($text, "\n").($matches[1] ?? ''); - - if (!$currentLineLength && '' !== $current && !str_ends_with($current, "\n")) { - $text = "\n".$text; + if (str_contains($text, "\e")) { + $parts = preg_split('/('.self::ANSI_PATTERN.')/', $text, -1, \PREG_SPLIT_DELIM_CAPTURE); + $sequences = ''; + for ($i = \count($parts) - 1; $i >= 0; --$i) { + if ($i % 2) { + $sequences = $parts[$i].$sequences; + } elseif ('' !== $parts[$i] = rtrim($parts[$i], "\r\n")) { + break; + } + } + $text = implode('', \array_slice($parts, 0, $i + 1)).$sequences; + } else { + $text = rtrim($text, "\r\n"); } + $text .= $trailingNewlines; $lines = explode("\n", $text); foreach ($lines as $i => $line) { - $currentLineLength = 0 === $i ? $currentLineLength + Helper::length($line) : Helper::length($line); - if ($width <= $currentLineLength) { - $currentLineLength = 0; - } + $lineLength = array_sum($this->getWidths($line)) ?: Helper::length(str_contains($line, "\e") ? preg_replace(self::ANSI_REGEX, '', $line) : $line); + $currentLineLength = 0 === $i ? $currentLineLength + $lineLength : $lineLength; } - if ($this->isDecorated()) { - foreach ($lines as $i => $line) { - $lines[$i] = $this->styleStack->getCurrent()->apply($line); - } + foreach ($lines as $i => $line) { + $lines[$i] = $this->isDecorated() ? $this->styleStack->getCurrent()->apply($line) : $line; } return implode("\n", $lines); @@ -273,7 +337,156 @@ private function applyCurrentStyle(string $text, string $current, int $width, in private function addLineBreaks(string $text, int $width): string { $encoding = mb_detect_encoding($text, null, true) ?: 'UTF-8'; + $widths = $this->getWidths($text); + + if (!$widths && !str_contains($text, "\e") && !str_contains($text, "\r\n")) { + return b($text)->toUnicodeString($encoding)->wordwrap($width, "\n", true)->toByteString($encoding); + } + + $chars = []; + $mask = $pending = ''; + foreach ($this->getTokens($text) as $token) { + if (preg_match(self::ANSI_REGEX, $token)) { + $pending .= $token; + continue; + } + $chars[] = [$pending, $token]; + $pending = ''; + $mask .= "\n" === $token || "\r\n" === $token ? '#' : (' ' === $token ? ' ' : '?'); + } + + if (!$chars) { + return $pending; + } + + $wrapped = rtrim($mask, '#'); + $mask = ($widths ? $this->wordwrap($wrapped, $width, $widths) : wordwrap($wrapped, $width, '#', true)).substr($mask, \strlen($wrapped)); + $result = ''; + $j = 0; + foreach (str_split($mask) as $char) { + if ('#' === $char) { + $eol = "\n"; + if (isset($chars[$j]) && ("\n" === $chars[$j][1] || "\r\n" === $chars[$j][1] || ' ' === $chars[$j][1])) { + $eol = ' ' === $chars[$j][1] ? "\n" : $chars[$j][1]; + $result .= $chars[$j++][0]; + } + $result .= $eol; + } else { + $result .= implode('', $chars[$j++]); + } + } + + return $result.$pending; + } + + private function substr(string $text, int $from, ?int $length = null): string + { + if (!str_contains($text, "\e")) { + return Helper::substr($text, $from, $length); + } + + $result = ''; + $position = 0; + foreach ($this->getTokens($text) as $token) { + $isAnsi = preg_match(self::ANSI_REGEX, $token); + if ($position >= $from && (null === $length || $position < $from + $length || $isAnsi && $position === $from + $length) + && (!$isAnsi || !$from || $position > $from) + ) { + $result .= $token; + } + if (!$isAnsi) { + ++$position; + } + } + + return $result; + } + + private function getTokens(string $text): iterable + { + $encoding = mb_detect_encoding($text, null, true) ?: 'UTF-8'; + foreach (preg_split('/('.self::ANSI_PATTERN.')/', $text, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY) as $part) { + if (preg_match(self::ANSI_REGEX, $part)) { + yield $part; + continue; + } + foreach (b($part)->toUnicodeString($encoding)->chunk() as $char) { + yield (string) $char->toByteString($encoding); + } + } + } + + /** + * Returns the number of columns of each visible character, or an empty array when none uses more than one column. + * + * @return list + */ + private function getWidths(string $text): array + { + if (!preg_match('/[^\x00-\x{10FF}]/u', $text)) { + return []; + } + + $widths = []; + $wide = false; + foreach ($this->getTokens($text) as $token) { + if (!preg_match(self::ANSI_REGEX, $token)) { + $widths[] = $charWidth = 2 < \strlen($token) ? ($this->charWidths[$token] ??= Helper::width($token)) : 1; + $wide = $wide || 1 < $charWidth; + } + } + + return $wide ? $widths : []; + } + + /** + * Wraps a mask like wordwrap() does when cutting long words, but using the number of columns of each character. + * + * @param list $widths + */ + private function wordwrap(string $mask, int $width, array $widths): string + { + $result = ''; + $start = $lastSpace = $lineWidth = 0; + + for ($i = 0, $length = \strlen($mask); $i < $length; ++$i) { + if ('#' === $mask[$i]) { + $result .= substr($mask, $start, $i + 1 - $start); + $start = $lastSpace = $i + 1; + $lineWidth = 0; + + continue; + } + + if (' ' === $mask[$i]) { + if ($lineWidth >= $width) { + $result .= substr($mask, $start, $i - $start).'#'; + $start = $i + 1; + $lineWidth = 0; + } else { + ++$lineWidth; + } + $lastSpace = $i; + + continue; + } + + if ($lineWidth + $widths[$i] > $width && $start < $lastSpace) { + $result .= substr($mask, $start, $lastSpace - $start).'#'; + $start = $lastSpace = $lastSpace + 1; + $lineWidth = array_sum(\array_slice($widths, $start, $i - $start)); + } + + // cut long words, and also before a wide character that still doesn't fit after breaking at the last space + if ($lineWidth && $lineWidth + $widths[$i] > $width) { + $result .= substr($mask, $start, $i - $start).'#'; + $start = $lastSpace = $i; + $lineWidth = 0; + } + + $lineWidth += $widths[$i]; + } - return b($text)->toUnicodeString($encoding)->wordwrap($width, "\n", true)->toByteString($encoding); + return $result.substr($mask, $start); } } diff --git a/core/vendor/symfony/console/Formatter/OutputFormatterStyle.php b/core/vendor/symfony/console/Formatter/OutputFormatterStyle.php index 20a65b517c..de800f98b9 100644 --- a/core/vendor/symfony/console/Formatter/OutputFormatterStyle.php +++ b/core/vendor/symfony/console/Formatter/OutputFormatterStyle.php @@ -40,12 +40,14 @@ public function __construct(?string $foreground = null, ?string $background = nu public function setForeground(?string $color): void { - $this->color = new Color($this->foreground = $color ?: '', $this->background, $this->options); + $this->color = new Color($foreground = $color ?: '', $this->background, $this->options); + $this->foreground = $foreground; } public function setBackground(?string $color): void { - $this->color = new Color($this->foreground, $this->background = $color ?: '', $this->options); + $this->color = new Color($this->foreground, $background = $color ?: '', $this->options); + $this->background = $background; } public function setHref(string $url): void @@ -55,23 +57,20 @@ public function setHref(string $url): void public function setOption(string $option): void { + $this->color = new Color($this->foreground, $this->background, [...$this->options, $option]); $this->options[] = $option; - $this->color = new Color($this->foreground, $this->background, $this->options); } public function unsetOption(string $option): void { - $pos = array_search($option, $this->options); - if (false !== $pos) { - unset($this->options[$pos]); - } - + $this->options = array_diff($this->options, [$option]); $this->color = new Color($this->foreground, $this->background, $this->options); } public function setOptions(array $options): void { - $this->color = new Color($this->foreground, $this->background, $this->options = $options); + $this->color = new Color($this->foreground, $this->background, $options); + $this->options = $options; } public function apply(string $text): string diff --git a/core/vendor/symfony/console/Helper/ProgressBar.php b/core/vendor/symfony/console/Helper/ProgressBar.php index 0ce4986439..3982a3a468 100644 --- a/core/vendor/symfony/console/Helper/ProgressBar.php +++ b/core/vendor/symfony/console/Helper/ProgressBar.php @@ -521,13 +521,6 @@ private function overwrite(string $message): void --$lineCount; } - foreach ($messageLines as $messageLine) { - $messageLineLength = Helper::width(Helper::removeDecoration($this->output->getFormatter(), $messageLine)); - if ($messageLineLength > $this->terminal->getWidth()) { - $lineCount += floor($messageLineLength / $this->terminal->getWidth()); - } - } - $this->output->clear($lineCount); } else { $lineCount = substr_count($this->previousMessage, "\n"); diff --git a/core/vendor/symfony/console/Helper/Table.php b/core/vendor/symfony/console/Helper/Table.php index 80d7ed6ee0..aef14b9858 100644 --- a/core/vendor/symfony/console/Helper/Table.php +++ b/core/vendor/symfony/console/Helper/Table.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Formatter\WrappableOutputFormatterInterface; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleSectionOutput; use Symfony\Component\Console\Output\OutputInterface; @@ -50,7 +51,9 @@ class Table private array $columnWidths = []; private array $configuredColumnWidths = []; private array $columnMaxWidths = []; - private bool $rendered = false; + private array $renderedLines = []; + private array $tagStyles = []; + private ?\WeakMap $rowspanLines = null; private string $displayOrientation = self::DISPLAY_ORIENTATION_DEFAULT; private static array $styles; @@ -235,16 +238,40 @@ public function addRow(TableSeparator|array $row): static */ public function appendRow(TableSeparator|array $row): static { - if (!$this->output instanceof ConsoleSectionOutput) { + $output = $this->output; + if (!$output instanceof ConsoleSectionOutput) { throw new RuntimeException(\sprintf('Output should be an instance of "%s" when calling "%s".', ConsoleSectionOutput::class, __METHOD__)); } - if ($this->rendered) { - $this->output->clear($this->calculateRowCount()); + if (!$output->isDecorated()) { + $this->addRow($row); + $this->render(); + + return $this; + } + + $previous = $this->renderedLines; + if ($previous && !str_ends_with($output->getContent(), implode('', $previous))) { + // the section was cleared or written to since the table was rendered: keep its content and render the whole table again + $previous = []; } $this->addRow($row); - $this->render(); + $lines = $this->renderLines(); + + // Only rewrite the lines after the part both renderings have in common. + // While no column gets wider, that is just the bottom border, so lines that scrolled out of the terminal, which can't be cleared, are not touched. + $common = 0; + $max = min(\count($previous), \count($lines)); + while ($common < $max && $previous[$common] === $lines[$common]) { + ++$common; + } + + if (\count($previous) > $common) { + $output->clear(\count($previous) - $common); + } + $output->write(implode('', \array_slice($lines, $common)), false, OutputInterface::OUTPUT_RAW); + $this->renderedLines = $lines; return $this; } @@ -314,6 +341,15 @@ public function setVertical(bool $vertical = true): static */ public function render(): void { + if ($this->output instanceof ConsoleSectionOutput && $this->output->isDecorated()) { + // remember the lines on screen, so that appendRow() can rewrite only the ones that change + $lines = $this->renderLines(); + $this->output->write(implode('', $lines), false, OutputInterface::OUTPUT_RAW); + $this->renderedLines = $lines; + + return; + } + $divider = new TableSeparator(); $isCellWithColspan = static fn ($cell) => $cell instanceof TableCell && $cell->getColspan() >= 2; @@ -323,13 +359,13 @@ public function render(): void $rows = []; if ($horizontal) { foreach ($this->headers[0] ?? [] as $i => $header) { - $rows[$i] = [$header]; + $rows[$i] = [$this->closeStyles($header)]; foreach ($this->rows as $row) { if ($row instanceof TableSeparator) { continue; } if (isset($row[$i])) { - $rows[$i][] = $row[$i]; + $rows[$i][] = $this->closeStyles($row[$i]); } elseif ($isCellWithColspan($rows[$i][0])) { // Noop, there is a "title" } else { @@ -339,7 +375,8 @@ public function render(): void } } elseif ($vertical) { $formatter = $this->output->getFormatter(); - $maxHeaderLength = array_reduce($this->headers[0] ?? [], static fn ($max, $header) => max($max, Helper::width(Helper::removeDecoration($formatter, $header))), 0); + $headers = array_map($this->closeStyles(...), $this->headers[0] ?? []); + $maxHeaderLength = array_reduce($headers, static fn ($max, $header) => max($max, Helper::width(Helper::removeDecoration($formatter, $header))), 0); foreach ($this->rows as $row) { if ($row instanceof TableSeparator) { @@ -357,13 +394,12 @@ public function render(): void } } - $headers = $this->headers[0] ?? []; $maxRows = max(\count($headers), \count($row)); for ($i = 0; $i < $maxRows; ++$i) { - $cell = (string) ($row[$i] ?? ''); + $cell = (string) $this->closeStyles($row[$i] ?? ''); $eol = str_contains($cell, "\r\n") ? "\r\n" : "\n"; - $parts = explode($eol, $cell); + $parts = explode($eol, $this->closeStyles($cell, $eol)); foreach ($parts as $idx => $part) { if ($headers && !$containsColspan) { if (0 === $idx) { @@ -387,7 +423,8 @@ public function render(): void } } } else { - $rows = array_merge($this->headers, [$divider], $this->rows); + $closeStyles = $this->closeStyles(...); + $rows = array_map(static fn ($row) => $row instanceof TableSeparator ? $row : array_map($closeStyles, $row), array_merge($this->headers, [$divider], $this->rows)); } $this->calculateNumberOfColumns($rows); @@ -395,9 +432,13 @@ public function render(): void $rowGroups = $this->buildTableRows($rows); $this->calculateColumnsWidth($rowGroups); + $isMarkdown = self::$styles['markdown'] === $this->style; + // a markdown table must start with a header row, so one is added when the table has none + $needsMarkdownHeader = $isMarkdown && ($horizontal || $vertical || !array_filter($this->headers)); $isHeader = !$horizontal; $isFirstRow = $horizontal; - $hasTitle = (bool) $this->headerTitle; + // a title would break the delimiter row of a markdown table + $hasTitle = (bool) $this->headerTitle && !$isMarkdown; $previousRow = null; $pendingSeparators = 0; @@ -407,7 +448,8 @@ public function render(): void foreach ($rowGroup as $row) { if ($divider === $row) { $isHeader = false; - $isFirstRow = true; + // the delimiter row of a markdown table is written after its first header row + $isFirstRow = !$isMarkdown; continue; } @@ -423,6 +465,13 @@ public function render(): void continue; } + if ($needsMarkdownHeader) { + $this->renderRow(array_fill(0, $this->numberOfColumns, ''), $this->style->getCellHeaderFormat()); + $this->renderRowSeparator(self::SEPARATOR_TOP_BOTTOM); + $needsMarkdownHeader = false; + $isFirstRow = false; + } + for (; $pendingSeparators > 0; --$pendingSeparators) { $this->renderRowSeparator(self::SEPARATOR_MID, null, null, $previousRow, $row); } @@ -462,6 +511,12 @@ public function render(): void $this->renderRow($row, $isHeader ? $this->style->getCellHeaderFormat() : $this->style->getCellRowFormat()); } + if ($isMarkdown && $isHeader) { + // a markdown table has a single header row, so the next header rows are rendered as body rows + $this->renderRowSeparator(self::SEPARATOR_TOP_BOTTOM); + $isHeader = false; + } + $previousRow = $row; } } @@ -475,7 +530,6 @@ public function render(): void } $this->cleanup(); - $this->rendered = true; } /** @@ -604,34 +658,25 @@ private function renderCell(array $row, int $column, string $cellFormat): string } // str_pad won't work properly with multi-byte strings, we need to fix the padding - $width += \strlen($cell) - Helper::width($cell) - substr_count($cell, "\0"); + $width += \strlen($cell) - Helper::width(Helper::removeDecoration($this->output->getFormatter(), $cell)); $style = $this->getColumnStyle($column); if ($cell instanceof TableSeparator) { return \sprintf($style->getBorderFormat(), str_repeat($style->getBorderChars()[2], $width)); } - $width += Helper::length($cell) - Helper::length(Helper::removeDecoration($this->output->getFormatter(), $cell)); $content = \sprintf($style->getCellRowContentFormat(), $cell); $padType = $style->getPadType(); if ($cell instanceof TableCell && $cell->getStyle() instanceof TableCellStyle) { - $isNotStyledByTag = !preg_match('/^<(\w+|(\w+=[\w,]+;?)*)>.+<\/(\w+|(\w+=\w+;?)*)?>$/', $cell); + // the lines of a cell that spans rows get the style of the cell, even when a tag wraps them + $isNotStyledByTag = isset($this->rowspanLines[$cell]) || !preg_match('/^<(\w+|(\w+=[\w,]+;?)*)>.+<\/(\w+|(\w+=\w+;?)*)?>$/', $cell); if ($isNotStyledByTag) { $cellFormat = $cell->getStyle()->getCellFormat(); if (!\is_string($cellFormat)) { $tag = http_build_query($cell->getStyle()->getTagOptions(), '', ';'); $cellFormat = '<'.$tag.'>%s'; } - - if (str_contains($content, '')) { - $content = str_replace('', '', $content); - $width -= 3; - } - if (str_contains($content, '')) { - $content = str_replace('', '', $content); - $width -= \strlen(''); - } } $padType = $cell->getStyle()->getPadByAlign(); @@ -661,6 +706,7 @@ private function buildTableRows(array $rows): TableRows { /** @var WrappableOutputFormatterInterface $formatter */ $formatter = $this->output->getFormatter(); + $isMarkdown = self::$styles['markdown'] === $this->style; $unmergedRows = []; for ($rowKey = 0; $rowKey < \count($rows); ++$rowKey) { $rows = $this->fillNextRows($rows, $rowKey); @@ -690,8 +736,9 @@ private function buildTableRows(array $rows): TableRows } $cellWidth = Helper::width(Helper::removeDecoration($formatter, $cell)); - if ($minWrappedWidth && $cellWidth > $minWrappedWidth) { - $cell = $formatter->formatAndWrap($cell, $minWrappedWidth); + if ($wrapped = $minWrappedWidth && $cellWidth > $minWrappedWidth) { + // the wrapped cell is already formatted and gets formatted again when rendered + $cell = str_replace(['<', '>'], ['\\<', '\\>'], $formatter->formatAndWrap($cell, $minWrappedWidth)); } // update minimal columnWidths for spanned columns if ($colspan > 1 && $minWrappedWidth > 0) { @@ -717,13 +764,19 @@ private function buildTableRows(array $rows): TableRows $this->columnWidths[$i] = max($this->configuredColumnWidths[$i] ?? 0, $cellWidth + $lengthColumnBorder); } } - if (!str_contains($cell ?? '', "\n")) { + if ($isMarkdown && !$cell instanceof TableSeparator) { + // double the backslashes before a
so that markdown does not escape it, and add one that the output formatter removes + $escaped = preg_replace(['/\\\\++(?=[\r\n])/', '/\r\n?|\n/'], ['$0$0\\\\', '
'], str_replace('|', '\|', $cell ?? '')); + $rows[$rowKey][$column] = $cell instanceof TableCell || $colspan > 1 ? new TableCell($escaped, ['colspan' => $colspan, 'style' => $cell instanceof TableCell ? $cell->getStyle() : null]) : $escaped; + continue; } - $eol = str_contains($cell ?? '', "\r\n") ? "\r\n" : "\n"; - $escaped = implode($eol, array_map(OutputFormatter::escapeTrailingBackslash(...), explode($eol, $cell))); + if (!$wrapped && !str_contains($cell ?? '', "\n")) { + continue; + } + $escaped = implode("\n", array_map(OutputFormatter::escapeTrailingBackslash(...), preg_split('/\r?\n/', $wrapped ? $cell : $this->closeStyles(str_replace("\r\n", "\n", $cell), "\n")))); $cell = $cell instanceof TableCell ? new TableCell($escaped, ['colspan' => $cell->getColspan()]) : $escaped; - $lines = explode($eol, str_replace($eol, ''.$eol, $cell)); + $lines = explode("\n", str_replace("\n", ''."\n", $cell)); foreach ($lines as $lineKey => $line) { if ($colspan > 1) { $line = new TableCell($line, ['colspan' => $colspan]); @@ -754,21 +807,6 @@ private function buildTableRows(array $rows): TableRows }); } - private function calculateRowCount(): int - { - $numberOfRows = \count(iterator_to_array($this->buildTableRows(array_merge($this->headers, [new TableSeparator()], $this->rows)))); - - if ($this->headers) { - ++$numberOfRows; // Add row for header separator - } - - if ($this->rows) { - ++$numberOfRows; // Add row for footer separator - } - - return $numberOfRows; - } - /** * fill rows that contains rowspan > 1. * @@ -776,6 +814,7 @@ private function calculateRowCount(): int */ private function fillNextRows(array $rows, int $line): array { + $this->rowspanLines ??= new \WeakMap(); $unmergedRows = []; foreach ($rows[$line] as $column => $cell) { if (null !== $cell && !$cell instanceof TableCell && !\is_scalar($cell) && !$cell instanceof \Stringable) { @@ -786,10 +825,11 @@ private function fillNextRows(array $rows, int $line): array $lines = [$cell]; if (str_contains($cell, "\n")) { $eol = str_contains($cell, "\r\n") ? "\r\n" : "\n"; - $lines = explode($eol, str_replace($eol, ''.$eol.'', $cell)); + $lines = explode($eol, $this->closeStyles($cell, $eol)); $nbLines = \count($lines) > $nbLines ? substr_count($cell, $eol) : $nbLines; $rows[$line][$column] = new TableCell($lines[0], ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]); + $this->rowspanLines[$rows[$line][$column]] = true; unset($lines[0]); } @@ -798,6 +838,7 @@ private function fillNextRows(array $rows, int $line): array foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) { $value = $lines[$unmergedRowKey - $line] ?? ''; $unmergedRows[$unmergedRowKey][$column] = new TableCell($value, ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]); + $this->rowspanLines[$unmergedRows[$unmergedRowKey][$column]] = true; if ($nbLines === $unmergedRowKey - $line) { break; } @@ -836,6 +877,10 @@ private function fillCells(iterable $row): iterable foreach ($row as $column => $cell) { $newRow[] = $cell; if ($cell instanceof TableCell && $cell->getColspan() > 1) { + if (self::$styles['markdown'] === $this->style) { + // markdown has no colspan and needs one cell per column, so the cell only fills the first column it spans + $newRow[array_key_last($newRow)] = new TableCell((string) $cell, ['style' => $cell->getStyle()]); + } foreach (range($column + 1, $column + $cell->getColspan() - 1) as $position) { // insert empty value at column position $newRow[] = ''; @@ -859,6 +904,77 @@ private function copyRow(array $rows, int $line): array return $row; } + /** + * Closes the styles that a cell leaves open at its end. + * + * With $eol, closes the styles that span a line break at the end of each line, and reopens them on the next line. + */ + private function closeStyles(mixed $cell, ?string $eol = null): mixed + { + if (!\is_string($cell) && !$cell instanceof \Stringable || !str_contains($value = (string) $cell, '<') || null !== $eol && !str_contains($value, $eol) || !($formatter = $this->output->getFormatter()) instanceof OutputFormatter) { + return $cell; + } + + $lines = null === $eol ? [$value] : explode($eol, $value); + // the tags that are open at the start of each line, then at the end of the last one, with the codes of their style and their closing tag + $openTags = [[]]; + foreach ($lines as $i => $line) { + $tags = $openTags[$i]; + preg_match_all('#<(?:[a-z](?:[^\\\\<>]*+|\\\\.)*|/(?:[a-z][^<>]*+)?)>#i', $line, $matches, \PREG_OFFSET_CAPTURE); + + foreach ($matches[0] as [$tag, $offset]) { + if ($offset && '\\' === $line[$offset - 1]) { + continue; + } + + if ('' !== $tag) { + // the formatter pushes the style of a style tag, and prints any other tag as is + $name = ltrim(substr($tag, 1), '/'); + $this->tagStyles[$name] ??= '' === $formatter->format('<'.$name) ? $formatter->getStyleStack()->pop()->apply('') : false; + if (false === $style = $this->tagStyles[$name]) { + continue; + } + + if ('/' !== $tag[1]) { + // a closing tag cannot contain an escaped "<" or ">" + $tags[] = [$tag, $style, strpbrk(substr($tag, 1, -1), '<>') ? '' : '' === $tag) { + array_pop($tags); + } else { + // like the formatter, close the last tag that has the same style, and the ones opened after it + for ($j = \count($tags) - 1; $j >= 0; --$j) { + if ($style === $tags[$j][1]) { + array_splice($tags, $j); + break; + } + } + } + } + + $openTags[] = $tags; + } + + if (!array_filter(\array_slice($openTags, 1, null === $eol ? null : -1))) { + return $cell; + } + + foreach ($lines as $i => $line) { + // closing tags go before the trailing backslashes of the line, which would escape them otherwise + $text = rtrim($line, '\\'); + $line = $text.implode('', array_reverse(array_column($openTags[$i + 1], 2))).substr($line, \strlen($text)); + $lines[$i] = implode('', array_column($openTags[$i], 0)).$line; + } + $value = implode($eol ?? '', $lines); + + return $cell instanceof TableCell ? new TableCell($value, ['rowspan' => $cell->getRowspan(), 'colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]) : $value; + } + /** * Gets number of columns by row. */ @@ -908,7 +1024,7 @@ private function calculateColumnsWidth(iterable $groups): void if ($textLength > 0) { $contentColumns = mb_str_split($textContent, ceil($textLength / $cell->getColspan())); foreach ($contentColumns as $position => $content) { - $row[$i + $position] = $content; + $row[$i + $position] = OutputFormatter::escape($content); } } } @@ -939,7 +1055,7 @@ private function getCellWidth(array $row, int $column): int $columnWidth = $this->columnWidths[$column] ?? 0; $cellWidth = max($cellWidth, $columnWidth); - return isset($this->columnMaxWidths[$column]) ? min($this->columnMaxWidths[$column], $cellWidth) : $cellWidth; + return isset($this->columnMaxWidths[$column]) && self::$styles['markdown'] !== $this->style ? min($this->columnMaxWidths[$column], $cellWidth) : $cellWidth; } /** @@ -948,6 +1064,8 @@ private function getCellWidth(array $row, int $column): int private function cleanup(): void { $this->effectiveColumnWidths = []; + $this->tagStyles = []; + $this->rowspanLines = null; unset($this->numberOfColumns); } @@ -1016,4 +1134,22 @@ private function resolveStyle(TableStyle|string $name): TableStyle return self::$styles[$name] ?? throw new InvalidArgumentException(\sprintf('Style "%s" is not defined.', $name)); } + + /** + * @return string[] + */ + private function renderLines(): array + { + $output = $this->output; + $buffer = new BufferedOutput($output->getVerbosity(), true, $output->getFormatter()); + $this->output = $buffer; + + try { + $this->render(); + } finally { + $this->output = $output; + } + + return preg_split('/(?<=\n)/', $buffer->fetch(), -1, \PREG_SPLIT_NO_EMPTY) ?: []; + } } diff --git a/core/vendor/symfony/console/Helper/TerminalInputHelper.php b/core/vendor/symfony/console/Helper/TerminalInputHelper.php index d0bc120ae9..fe6d695ee7 100644 --- a/core/vendor/symfony/console/Helper/TerminalInputHelper.php +++ b/core/vendor/symfony/console/Helper/TerminalInputHelper.php @@ -102,7 +102,8 @@ public function finish(): void // and shell_exec() gives no way to detect that failure. Try the exact restore first so // any custom terminal settings survive, but always fall back to "stty sane" so the // terminal is left in a usable state even when the exact restore silently fails. - shell_exec('stty '.$this->initialState.' 2>/dev/null || stty sane'); + $nullDevice = '\\' === \DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null'; + shell_exec('stty '.$this->initialState.' 2>'.$nullDevice.' || stty sane'); $this->signalToKill = 0; foreach ($this->signalHandlers as $signal => $originalHandler) { diff --git a/core/vendor/symfony/console/Input/ArgvInput.php b/core/vendor/symfony/console/Input/ArgvInput.php index 74f3fca21a..b919667b07 100644 --- a/core/vendor/symfony/console/Input/ArgvInput.php +++ b/core/vendor/symfony/console/Input/ArgvInput.php @@ -270,6 +270,13 @@ private function addLongOption(string $name, mixed $value): void } public function getFirstArgument(): ?string + { + $position = $this->getFirstArgumentPosition(); + + return null === $position ? null : $this->tokens[$position]; + } + + private function getFirstArgumentPosition(): ?int { $isOption = false; foreach ($this->tokens as $i => $token) { @@ -295,7 +302,7 @@ public function getFirstArgument(): ?string continue; } - return $token; + return $i; } return null; @@ -364,20 +371,9 @@ public function getRawTokens(bool $strip = false): array return $this->tokens; } - $parameters = []; - $keep = false; - foreach ($this->tokens as $value) { - if (!$keep && $value === $this->getFirstArgument()) { - $keep = true; - - continue; - } - if ($keep) { - $parameters[] = $value; - } - } + $position = $this->getFirstArgumentPosition(); - return $parameters; + return null === $position ? [] : \array_slice($this->tokens, $position + 1); } /** diff --git a/core/vendor/symfony/console/Input/StringInput.php b/core/vendor/symfony/console/Input/StringInput.php index a70f048f98..1392bef2d2 100644 --- a/core/vendor/symfony/console/Input/StringInput.php +++ b/core/vendor/symfony/console/Input/StringInput.php @@ -63,7 +63,7 @@ private function tokenize(string $input): array $token = null; } } elseif (preg_match('/([^="\'\s]+?)(=?)('.self::REGEX_QUOTED_STRING.'+)/A', $input, $match, 0, $cursor)) { - $token .= $match[1].$match[2].stripcslashes(str_replace(['"\'', '\'"', '\'\'', '""'], '', substr($match[3], 1, -1))); + $token .= $match[1].$match[2].stripcslashes(preg_replace('/'.self::REGEX_QUOTED_STRING.'/', '$1$2', $match[3])); } elseif (preg_match('/'.self::REGEX_QUOTED_STRING.'/A', $input, $match, 0, $cursor)) { $token .= stripcslashes(substr($match[0], 1, -1)); } elseif (preg_match('/'.self::REGEX_UNQUOTED_STRING.'/A', $input, $match, 0, $cursor)) { diff --git a/core/vendor/symfony/console/Output/ConsoleSectionOutput.php b/core/vendor/symfony/console/Output/ConsoleSectionOutput.php index a45d177a10..b86ca7f424 100644 --- a/core/vendor/symfony/console/Output/ConsoleSectionOutput.php +++ b/core/vendor/symfony/console/Output/ConsoleSectionOutput.php @@ -52,7 +52,8 @@ public function setMaxHeight(int $maxHeight): void $this->maxHeight = $maxHeight; $existingContent = $this->popStreamContentUntilCurrentSection($previousMaxHeight ? min($previousMaxHeight, $this->lines) : $this->lines); - parent::doWrite($this->getVisibleContent(), false); + $visibleContent = $this->getVisibleContent(); + parent::doWrite($visibleContent, '' !== $visibleContent && !str_ends_with($visibleContent, \PHP_EOL)); parent::doWrite($existingContent, false); } @@ -68,17 +69,31 @@ public function clear(?int $lines = null): void } if ($lines) { - array_splice($this->content, -$lines); + $width = $this->terminal->getWidth(); + foreach (array_splice($this->content, -$lines) as $line) { + $lines += $this->getDisplayHeight($line, $width) - 1; + } } else { $lines = $this->lines; $this->content = []; } + $previousLines = $this->lines; + // callers may ask to clear more lines than this section tracks (e.g. ProgressBar // counts "\n"-separated lines while addContent() splits on PHP_EOL), so keep the // counter from going negative, which would break the max-height bookkeeping $this->lines = max(0, $this->lines - $lines); + if ($this->maxHeight && $previousLines > $this->maxHeight) { + // the section was scrolled: redraw it to show the lines that are visible again + $erasedContent = $this->popStreamContentUntilCurrentSection($this->maxHeight); + parent::doWrite($this->getVisibleContent(), false); + parent::doWrite($erasedContent, false); + + return; + } + parent::doWrite($this->popStreamContentUntilCurrentSection($this->maxHeight ? min($this->maxHeight, $lines) : $lines), false); } @@ -157,7 +172,7 @@ public function addContent(string $input, bool $newline = true): int && !str_ends_with($lastLine, \PHP_EOL) ) { // deduct the line count of the previous line - $this->lines -= (int) ceil($this->getDisplayLength($lastLine) / $width) ?: 1; + $this->lines -= $this->getDisplayHeight($lastLine, $width); // concatenate previous and new line $lineContent = $lastLine.$lineContent; // replace last entry of `$this->content` with the new expanded line @@ -167,7 +182,7 @@ public function addContent(string $input, bool $newline = true): int $this->content[] = $lineContent; } - $linesAdded += (int) ceil($this->getDisplayLength($lineContent) / $width) ?: 1; + $linesAdded += $this->getDisplayHeight($lineContent, $width); } $this->lines += $linesAdded; @@ -198,25 +213,30 @@ protected function doWrite(string $message, bool $newline): void return; } + if ('' === $message && !$newline) { + return; + } + // Check if the previous line (last entry of `$this->content`) needs to be continued // (i.e. does not end with a line break). In which case, it needs to be erased first. - $linesToClear = $deleteLastLine = ($lastLine = end($this->content) ?: '') && !str_ends_with($lastLine, \PHP_EOL) ? 1 : 0; + $deleteLastLine = ($lastLine = end($this->content) ?: '') && !str_ends_with($lastLine, \PHP_EOL); + $linesToClear = $deleteLastLine ? $this->getDisplayHeight($lastLine, $this->terminal->getWidth()) : 0; + $previousLines = $this->lines; + + $this->addContent($message, $newline); - $linesAdded = $this->addContent($message, $newline); + if ($this->maxHeight > 0 && $this->lines > $this->maxHeight) { + // on overflow, clear the lines displayed so far and redraw the section (to remove the first lines) + $erasedContent = $this->popStreamContentUntilCurrentSection(min($this->maxHeight, $previousLines)); + $visibleContent = $this->getVisibleContent(); + parent::doWrite($visibleContent, !str_ends_with($visibleContent, \PHP_EOL)); + parent::doWrite($erasedContent, false); - if ($lineOverflow = $this->maxHeight > 0 && $this->lines > $this->maxHeight) { - // on overflow, clear the whole section and redraw again (to remove the first lines) - $linesToClear = $this->maxHeight; + return; } $erasedContent = $this->popStreamContentUntilCurrentSection($linesToClear); - if ($lineOverflow) { - // redraw existing lines of the section - $previousLinesOfSection = \array_slice($this->content, $this->lines - $this->maxHeight, $this->maxHeight - $linesAdded); - parent::doWrite(implode('', $previousLinesOfSection), false); - } - // if the last line was removed, re-print its content together with the new content. // otherwise, just print the new content. parent::doWrite($deleteLastLine ? $lastLine.$message : $message, true); @@ -256,8 +276,45 @@ private function popStreamContentUntilCurrentSection(int $numberOfLinesToClearFr return implode('', array_reverse($erasedContent)); } - private function getDisplayLength(string $text): int + private function getDisplayHeight(string $line, int $width): int { - return Helper::width(Helper::removeDecoration($this->getFormatter(), str_replace("\t", ' ', $text))); + if ($width <= 0) { + return 1; + } + + // the line break must not count as a column, or a line as wide as the terminal would count as two + if (str_ends_with($line, \PHP_EOL)) { + $line = substr($line, 0, -\strlen(\PHP_EOL)); + } + + // formatting the line again would remove literal tags, so only the ANSI sequences are removed + $line = preg_replace(['/\e\[[^m]*m/', '/\e]8;[^;]*;[^\e]*\e\\\\/'], '', $line); + $lineWidth = Helper::width(str_replace("\t", ' ', $line)); + + if ($lineWidth <= $width || !preg_match('/[\t\x80-\xFF]/', $line) || !preg_match('//u', $line)) { + return (int) ceil($lineWidth / $width) ?: 1; + } + + // a wide character that does not fit at the end of a row is moved to the next one + $rows = 1; + $column = 0; + $offset = 0; + $widths = []; + while (false !== $char = grapheme_extract($line, 1, \GRAPHEME_EXTR_COUNT, $offset, $offset)) { + if ("\t" === $char) { + // a tab moves the cursor to the next multiple of 8 columns, but never past the last column + $column = min($column - $column % 8 + 8, $width - 1); + continue; + } + + $charWidth = $widths[$char] ??= Helper::width($char); + if ($column + $charWidth > $width) { + ++$rows; + $column = 0; + } + $column += $charWidth; + } + + return $rows; } } diff --git a/core/vendor/symfony/error-handler/DebugClassLoader.php b/core/vendor/symfony/error-handler/DebugClassLoader.php index 3fbb526652..311da36619 100644 --- a/core/vendor/symfony/error-handler/DebugClassLoader.php +++ b/core/vendor/symfony/error-handler/DebugClassLoader.php @@ -406,11 +406,11 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array $parent = get_parent_class($class) ?: null; self::$returnTypes[$class] = []; - $classIsTemplate = false; + $classTemplates = []; // Detect annotations on the class if ($doc = $this->parsePhpDoc($refl)) { - $classIsTemplate = isset($doc['template']) || isset($doc['template-covariant']); + $classTemplates = [...$doc['template'] ?? [], ...$doc['template-covariant'] ?? []]; foreach (['final', 'deprecated', 'internal'] as $annotation) { if (null !== $description = $doc[$annotation][0] ?? null) { @@ -431,7 +431,7 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array self::$method[$class][] = [$class, $static, $returnType, $name.$signature, $description]; if ('' !== $returnType) { - $this->setReturnType($returnType, $refl->name, $name, $refl->getFileName(), $parent); + $this->setReturnType($returnType, $refl->name, $name, $refl->getFileName(), $parent, null, $classTemplates); } } } @@ -575,8 +575,9 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array // To read method annotations $doc = $this->parsePhpDoc($method); + $templates = isset($doc['template']) || isset($doc['template-covariant']) ? [...$classTemplates, ...$doc['template'] ?? [], ...$doc['template-covariant'] ?? []] : $classTemplates; - if (($classIsTemplate || isset($doc['template']) || isset($doc['template-covariant'])) && $method->hasReturnType()) { + if ($templates && $method->hasReturnType()) { unset($doc['return']); } @@ -636,7 +637,7 @@ public function checkAnnotations(\ReflectionClass $refl, string $class): array } if (isset($doc['return'])) { - $this->setReturnType($doc['return'] ?? self::MAGIC_METHODS[$method->name], $method->class, $method->name, $method->getFileName(), $parent, $method->getReturnType()); + $this->setReturnType($doc['return'] ?? self::MAGIC_METHODS[$method->name], $method->class, $method->name, $method->getFileName(), $parent, $method->getReturnType(), $templates); if (isset(self::$returnTypes[$class][$method->name][0]) && $canAddReturnType) { $this->fixReturnStatements($method, self::$returnTypes[$class][$method->name][0]); @@ -837,7 +838,7 @@ private function getOwnInterfaces(string $class, ?string $parent): array return $ownInterfaces; } - private function setReturnType(string $types, string $class, string $method, string $filename, ?string $parent, ?\ReflectionType $returnType = null): void + private function setReturnType(string $types, string $class, string $method, string $filename, ?string $parent, ?\ReflectionType $returnType = null, array $templates = []): void { if ('__construct' === $method) { return; @@ -858,6 +859,18 @@ private function setReturnType(string $types, string $class, string $method, str $typesMap = []; $glue = str_contains($types, '&') ? '&' : '|'; foreach (explode($glue, $types) as $t) { + if (\in_array($t, $templates, true)) { + // there is no native type for a template, but $class remains the declaring class for its child classes + if (isset(self::$returnTypes[$class][$method])) { + self::$returnTypes[$class][$method][2] = $class; + } else { + // a null entry hides the types that child classes get from their own interfaces + self::$returnTypes[$class][$method] = null; + } + + return; + } + $t = self::SPECIAL_RETURN_TYPES[strtolower($t)] ?? $t; $typesMap[$this->normalizeType($t, $class, $parent, $returnType)][$t] = $t; } @@ -1329,6 +1342,12 @@ private function parsePhpDoc(\Reflector $reflector): array $tags['param'][$param] = $type; } + foreach (['template', 'template-covariant'] as $k) { + if ($templates = [...$tags[$k] ?? [], ...$tags['phpstan-'.$k] ?? [], ...$tags['psalm-'.$k] ?? []]) { + $tags[$k] = array_map(static fn ($v) => substr($v, 0, strpos($v, ' ') ?: \strlen($v)), $templates); + } + } + foreach (['var', 'return'] as $k) { if (null === $v = $tags[$k][0] ?? null) { continue; diff --git a/core/vendor/symfony/error-handler/ErrorHandler.php b/core/vendor/symfony/error-handler/ErrorHandler.php index 0b95b8922b..78d772105f 100644 --- a/core/vendor/symfony/error-handler/ErrorHandler.php +++ b/core/vendor/symfony/error-handler/ErrorHandler.php @@ -93,6 +93,7 @@ class ErrorHandler private bool $isRecursive = false; private bool $isRoot = false; + private bool $customExceptionHandlerCalled = false; /** @var callable|null */ private $exceptionHandler; private ?BufferingLogger $bootstrappingLogger = null; @@ -529,6 +530,25 @@ public function handleException(\Throwable $exception): void } catch (\Throwable $handlerException) { $loggerFailed = true; } + } elseif ([$this, 'renderException'] === $this->exceptionHandler && !$this->customExceptionHandlerCalled && !isset($this->loggers[$type][0]) && !$exception instanceof FatalError && (error_reporting() & \E_ERROR) && filter_var(\ini_get('log_errors'), \FILTER_VALIDATE_BOOL)) { + // renderException() replaces PHP's native handler, so log the exception like PHP does. PHP already logged fatal errors. + // In CLI, PHP logs to STDERR when error_log is empty, and renderException() already displays the exception. + // PHP logs this line with the error priority. error_log() uses the notice one, except with the SAPI logger. + if (($errorLog = \ini_get('error_log')) || !\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) { + try { + $message = \sprintf("PHP Fatal error: Uncaught %s\n thrown in %s on line %d", $this->parseAnonymousClass((string) $exception), $exception->getFile(), $exception->getLine()); + + if ('syslog' === $errorLog && \function_exists('openlog') && \function_exists('syslog')) { + $facility = \ini_get('syslog.facility'); + $facility = 'security' === $facility ? 'LOG_AUTH' : (str_starts_with($facility, 'LOG_') ? $facility : 'LOG_'.strtoupper($facility)); + openlog(\ini_get('syslog.ident'), 0, \defined($facility) ? \constant($facility) : \LOG_USER); + syslog(\LOG_ERR, $message); + } else { + error_log($message, $errorLog ? 0 : 4); + } + } catch (\Throwable) { + } + } } $exception = $this->enhanceError($exception); @@ -538,6 +558,9 @@ public function handleException(\Throwable $exception): void if (null === $exceptionHandler || $exceptionHandler === $this->exceptionHandler) { $this->exceptionHandler = null; + } else { + // renderException() is now the fallback of a custom handler, which is in charge of logging + $this->customExceptionHandlerCalled = true; } try { diff --git a/core/vendor/symfony/finder/Gitignore.php b/core/vendor/symfony/finder/Gitignore.php index 7764588983..97e4ddc606 100644 --- a/core/vendor/symfony/finder/Gitignore.php +++ b/core/vendor/symfony/finder/Gitignore.php @@ -46,7 +46,7 @@ private static function buildRegex(string $gitignoreFileContent, bool $inverted) continue; } - $line = preg_replace('~(? '['.('' !== $matches[1] ? '^' : '').str_replace('\\-', '-', $matches[2]).']', $regex); + $parts = preg_split('~\\\\(.)~s', $gitignoreLine, -1, \PREG_SPLIT_DELIM_CAPTURE); + + if (str_ends_with(end($parts), '\\')) { + return '$f'; // a trailing backslash never matches + } + + $regex = ''; + foreach ($parts as $i => $part) { + // escaped characters are written in hex so that the replacements below leave them alone + $regex .= $i % 2 ? \sprintf('\x%02X', \ord($part)) : preg_quote($part, '~'); + } + $regex = preg_replace_callback('~\\\\\[((?:\\\\[!^])?+)((?:\\\\\])?+[^\]]*)\\\\\]~', static fn (array $matches): string => ('' !== $matches[1] ? '(?!/)[^' : '[').self::rangesToRegex($matches[2]).']', $regex); $regex = preg_replace('~(?:(?:\\\\\*){2,}(/?))+~', '(?:(?:(?!//).(? 4 === \strlen($c) ? hexdec(substr($c, 2)) : \ord($c[-1]), [$m[1], $m[2]]); + + return $from <= $to ? $m[1].'-'.$m[2] : $m[1]; + }, $class) ?? $class; + } } diff --git a/core/vendor/symfony/finder/Glob.php b/core/vendor/symfony/finder/Glob.php index ac11e8e0de..e0d2e24564 100644 --- a/core/vendor/symfony/finder/Glob.php +++ b/core/vendor/symfony/finder/Glob.php @@ -42,7 +42,8 @@ public static function toRegex(string $glob, bool $strictLeadingDot = true, bool { $firstByte = true; $escaping = false; - $inCurlies = 0; + $curlies = []; + $bracketScanEnd = 0; $regex = ''; if ($unanchored = str_starts_with($glob, '**/')) { $glob = '/'.$glob; @@ -76,6 +77,8 @@ public static function toRegex(string $glob, bool $strictLeadingDot = true, bool if ($delimiter === $car || '.' === $car || '(' === $car || ')' === $car || '|' === $car || '+' === $car || '^' === $car || '$' === $car) { $regex .= "\\$car"; + } elseif ('[' === $car && !$escaping && null !== $class = self::bracketExpressionToRegex($glob, $i, $delimiter, $bracketScanEnd)) { + $regex .= $class; } elseif ('*' === $car) { if ($escaping || $strictWildcardSlash || !str_ends_with($regex, '.*')) { // ".*.*" matches the same as ".*" but backtracks quadratically @@ -84,16 +87,19 @@ public static function toRegex(string $glob, bool $strictLeadingDot = true, bool } elseif ('?' === $car) { $regex .= $escaping ? '\\?' : ($strictWildcardSlash ? '[^/]' : '.'); } elseif ('{' === $car) { - $regex .= $escaping ? '\\{' : '('; if (!$escaping) { - ++$inCurlies; + $curlies[] = [\strlen($regex)]; } - } elseif ('}' === $car && $inCurlies) { + $regex .= $escaping ? '\\{' : '('; + } elseif ('}' === $car && $curlies) { $regex .= $escaping ? '}' : ')'; if (!$escaping) { - --$inCurlies; + array_pop($curlies); + } + } elseif (',' === $car && $curlies) { + if (!$escaping) { + $curlies[\count($curlies) - 1][] = \strlen($regex); } - } elseif (',' === $car && $inCurlies) { $regex .= $escaping ? ',' : '|'; } elseif ('\\' === $car) { if ($escaping) { @@ -104,16 +110,76 @@ public static function toRegex(string $glob, bool $strictLeadingDot = true, bool } continue; + } elseif ($escaping && ('[' === $car || ']' === $car)) { + $regex .= '\\'.$car; } else { $regex .= $car; } $escaping = false; } + // A "{" that no "}" closes is a literal character, and so are the "," it contains + $prefix = ''; + $start = 0; + foreach (array_merge(...$curlies) as $offset) { + $prefix .= substr($regex, $start, $offset - $start).('(' === $regex[$offset] ? '\\{' : ','); + $start = $offset + 1; + } + $regex = $prefix.substr($regex, $start); + if ($unanchored) { $regex = substr_replace($regex, '?', 1 + ('/' === $delimiter) + ($strictLeadingDot ? \strlen('(?=[^\.])') : 0), 0); } return $delimiter.'^'.$regex.'$'.$delimiter; } + + /** + * Returns null when a "/" comes before the closing "]", and "\[" when no "]" closes the "[" at position $i. + * + * $scanEnd is where the last scan stopped without finding a "]": every "[" before that position gets the same result. + */ + private static function bracketExpressionToRegex(string $glob, int &$i, string $delimiter, int &$scanEnd): ?string + { + $sizeGlob = \strlen($glob); + + if ($i < $scanEnd) { + return $scanEnd < $sizeGlob ? null : '\\['; + } + + $class = '['; + + for ($j = $i + 1; $j < $sizeGlob; ++$j) { + $car = $glob[$j]; + + if (']' === $car && $j > $i + 1) { + $i = $j; + + return $class.']'; + } + + if ('/' === $car || ('\\' === $car && '/' === ($glob[$j + 1] ?? ''))) { + $scanEnd = $j; + + return null; + } + + if ('[' === $car && preg_match('/\[:[a-z]++:\]/A', $glob, $m, 0, $j)) { + $class .= $m[0]; + $j += \strlen($m[0]) - 1; + } elseif ('\\' === $car) { + if (!isset($glob[++$j])) { + break; + } + $class .= preg_quote($glob[$j], $delimiter); + } elseif ($delimiter === $car || str_contains('.()|+^$', $car)) { + $class .= '\\'.$car; + } else { + $class .= $car; + } + } + $scanEnd = $sizeGlob; + + return '\\['; + } } diff --git a/core/vendor/symfony/finder/Iterator/MultiplePcreFilterIterator.php b/core/vendor/symfony/finder/Iterator/MultiplePcreFilterIterator.php index 3450c49d43..2a4c0b8afe 100644 --- a/core/vendor/symfony/finder/Iterator/MultiplePcreFilterIterator.php +++ b/core/vendor/symfony/finder/Iterator/MultiplePcreFilterIterator.php @@ -87,12 +87,12 @@ protected function isRegex(string $str): bool $end = substr($m[1], -1); if ($start === $end) { - return !preg_match('/[*?[:alnum:] \\\\]/', $start); + return !preg_match('/[*?[:alnum:] \\\\]/', $start) && false !== @preg_match($str, ''); } foreach ([['{', '}'], ['(', ')'], ['[', ']'], ['<', '>']] as $delimiters) { if ($start === $delimiters[0] && $end === $delimiters[1]) { - return true; + return false !== @preg_match($str, ''); } } } diff --git a/core/vendor/symfony/finder/Iterator/RecursiveDirectoryIterator.php b/core/vendor/symfony/finder/Iterator/RecursiveDirectoryIterator.php index f5fd2d4dc5..c8702d5ee7 100644 --- a/core/vendor/symfony/finder/Iterator/RecursiveDirectoryIterator.php +++ b/core/vendor/symfony/finder/Iterator/RecursiveDirectoryIterator.php @@ -25,6 +25,7 @@ class RecursiveDirectoryIterator extends \RecursiveDirectoryIterator { private bool $ignoreUnreadableDirs; private bool $ignoreFirstRewind = true; + private array $realPaths = []; // these 3 properties take part of the performance optimization to avoid redoing the same work in all iterations private string $rootPath; @@ -46,6 +47,9 @@ public function __construct(string $path, int $flags, bool $ignoreUnreadableDirs if ('/' !== \DIRECTORY_SEPARATOR && !($flags & self::UNIX_PATHS)) { $this->directorySeparator = \DIRECTORY_SEPARATOR; } + if ($flags & self::FOLLOW_SYMLINKS && false !== ($realPath = realpath($path))) { + $this->realPaths[$realPath] = true; + } } /** @@ -76,6 +80,11 @@ public function hasChildren(bool $allowLinks = false): bool { $hasChildren = parent::hasChildren($allowLinks); + // don't loop on a directory that resolves to one of its parents + if ($hasChildren && $this->realPaths && false !== ($realPath = realpath($this->getPathname())) && isset($this->realPaths[$realPath])) { + return false; + } + if (!$hasChildren || !$this->ignoreUnreadableDirs) { return $hasChildren; } @@ -104,6 +113,8 @@ public function getChildren(): \RecursiveDirectoryIterator // performance optimization to avoid redoing the same work in all children $children->rootPath = $this->rootPath; + + $children->realPaths += $this->realPaths; } return $children; diff --git a/core/vendor/symfony/finder/Iterator/VcsIgnoredFilterIterator.php b/core/vendor/symfony/finder/Iterator/VcsIgnoredFilterIterator.php index 3715e269ed..cc5e45a5e8 100644 --- a/core/vendor/symfony/finder/Iterator/VcsIgnoredFilterIterator.php +++ b/core/vendor/symfony/finder/Iterator/VcsIgnoredFilterIterator.php @@ -18,7 +18,10 @@ */ final class VcsIgnoredFilterIterator extends \FilterIterator { + private string $searchDir; + private string $realSearchDir; private string $baseDir; + private ?string $gitInfoExcludeFile = null; /** * @var array|null> @@ -35,12 +38,14 @@ final class VcsIgnoredFilterIterator extends \FilterIterator */ public function __construct(\Iterator $iterator, string $baseDir) { - // paths are compared to the real path of each file, so the base directory needs the same treatment + $this->searchDir = rtrim($this->normalizePath($baseDir), '/').'/'; $this->baseDir = $this->normalizePath(realpath($baseDir) ?: $baseDir); + $this->realSearchDir = rtrim($this->baseDir, '/').'/'; foreach ([$this->baseDir, ...$this->parentDirectoriesUpwards($this->baseDir)] as $directory) { if (@file_exists("{$directory}/.git")) { $this->baseDir = $directory; + $this->gitInfoExcludeFile = $this->getGitInfoExcludePath(); break; } } @@ -50,21 +55,24 @@ public function __construct(\Iterator $iterator, string $baseDir) public function accept(): bool { - $file = $this->current(); + $path = $this->normalizePath($this->current()->getPathname()); - $fileRealPath = $this->normalizePath($file->getRealPath()); + // git matches a symlink by its own path, so only the search directory is resolved + if (str_starts_with($path, $this->searchDir)) { + $path = $this->realSearchDir.substr($path, \strlen($this->searchDir)); + } - return !$this->isIgnored($fileRealPath); + return !$this->isIgnored($path); } - private function isIgnored(string $fileRealPath): bool + private function isIgnored(string $path): bool { - if (isset($this->ignoredPathsCache[$fileRealPath])) { - return $this->ignoredPathsCache[$fileRealPath]; + if (isset($this->ignoredPathsCache[$path])) { + return $this->ignoredPathsCache[$path]; } $ignored = false; - $parentDirectories = $this->parentDirectoriesDownwards($fileRealPath); + $parentDirectories = $this->parentDirectoriesDownwards($path); if ($parentDirectories && $this->baseDir !== $parentDirectory = $parentDirectories[\count($parentDirectories) - 1]) { // paths inside an ignored directory are ignored and cannot be re-included @@ -72,15 +80,24 @@ private function isIgnored(string $fileRealPath): bool } if (!$ignored) { - $isDir = is_dir($fileRealPath); + $isDir = !is_link($path) && is_dir($path); + $gitignoreFiles = []; - // the last matching rule of the deepest .gitignore file decides foreach (array_reverse($parentDirectories) as $parentDirectory) { - if (null === $rules = $this->readGitignoreFile("{$parentDirectory}/.gitignore")) { + $gitignoreFiles["{$parentDirectory}/.gitignore"] = $parentDirectory; + } + + if (null !== $this->gitInfoExcludeFile && $this->baseDir === ($parentDirectories[0] ?? null)) { + $gitignoreFiles[$this->gitInfoExcludeFile] = $this->baseDir; + } + + // the last matching rule of the deepest .gitignore file decides, and the exclude file of the repository comes after the root one + foreach ($gitignoreFiles as $gitignoreFile => $parentDirectory) { + if (null === $rules = $this->readGitignoreFile($gitignoreFile)) { continue; } - $fileRelativePath = substr($fileRealPath, \strlen($parentDirectory) + 1); + $fileRelativePath = substr($path, \strlen($parentDirectory) + 1); foreach ($rules as [$regex, $isNegated, $isDirOnly]) { if ($isDirOnly && !$isDir) { @@ -96,7 +113,7 @@ private function isIgnored(string $fileRealPath): bool } } - return $this->ignoredPathsCache[$fileRealPath] = $ignored; + return $this->ignoredPathsCache[$path] = $ignored; } /** @@ -140,6 +157,32 @@ private function parentDirectoriesDownwards(string $fileRealPath): array ); } + private function getGitInfoExcludePath(): ?string + { + $gitDir = "{$this->baseDir}/.git"; + + // worktrees and submodules have a .git file that points to their git directory, which can be outside of open_basedir + if (!is_dir($gitDir)) { + if (!preg_match('~^gitdir:\s*(.+)~i', (string) @file_get_contents($gitDir), $matches)) { + return null; + } + + $gitDir = $this->resolveGitPath(trim($matches[1]), $this->baseDir); + + // the git directory of a worktree points to the common directory, where the exclude file is + if (false !== $commonDir = @file_get_contents("{$gitDir}/commondir")) { + $gitDir = $this->resolveGitPath(trim($commonDir), $gitDir); + } + } + + return @is_file("{$gitDir}/info/exclude") ? "{$gitDir}/info/exclude" : null; + } + + private function resolveGitPath(string $path, string $from): string + { + return preg_match('~^(?:[a-zA-Z]:)?[/\\\\]~', $path) ? $path : "{$from}/{$path}"; + } + /** * Returns the rules of a .gitignore file, last one first, as [regex, isNegated, isDirOnly] tuples. * diff --git a/core/vendor/symfony/http-foundation/BinaryFileResponse.php b/core/vendor/symfony/http-foundation/BinaryFileResponse.php index 6f2b884524..e16cd6b921 100644 --- a/core/vendor/symfony/http-foundation/BinaryFileResponse.php +++ b/core/vendor/symfony/http-foundation/BinaryFileResponse.php @@ -187,7 +187,7 @@ public function setContentDisposition(string $disposition, string $filename = '' public function prepare(Request $request): static { - if ($this->isInformational() || $this->isEmpty()) { + if (!$this->isSuccessful() || $this->isEmpty()) { parent::prepare($request); $this->maxlen = 0; @@ -260,13 +260,15 @@ public function prepare(Request $request): static if (!$request->headers->has('If-Range') || $this->hasValidIfRangeHeader($request->headers->get('If-Range'))) { $range = $request->headers->get('Range'); - if (str_starts_with($range, 'bytes=')) { - [$start, $end] = explode('-', substr($range, 6), 2) + [1 => 0]; + if (preg_match('/^bytes=(\d*)-(\d*)$/i', $range, $matches) && ('' !== $matches[1] || '' !== $matches[2])) { + $start = $matches[1]; + $end = $matches[2]; $end = ('' === $end) ? $fileSize - 1 : (int) $end; if ('' === $start) { - $start = $fileSize - $end; + // a suffix longer than the file selects the whole file, see RFC 9110 section 14.1.2 + $start = max(0, $fileSize - $end); $end = $fileSize - 1; } else { $start = (int) $start; diff --git a/core/vendor/symfony/http-foundation/CHANGELOG.md b/core/vendor/symfony/http-foundation/CHANGELOG.md index 321d58e755..6e8f42a1bc 100644 --- a/core/vendor/symfony/http-foundation/CHANGELOG.md +++ b/core/vendor/symfony/http-foundation/CHANGELOG.md @@ -8,7 +8,7 @@ CHANGELOG * Add support for the `QUERY` HTTP method * Add support for structured MIME suffix * Add `Request::set/getAllowedHttpMethodOverride()` to list which HTTP methods can be overridden - * Deprecate using `Request::sendHeaders()` after headers have already been sent; use a `StreamedResponse` instead + * Deprecate using `Response::sendHeaders()` after headers have already been sent; use a `StreamedResponse` instead * Deprecate method `Request::get()`, use properties `->attributes`, `query` or `request` directly instead * Make `Request::createFromGlobals()` parse the body of PUT, DELETE, PATCH and QUERY requests * Deprecate HTTP method override for methods GET, HEAD, CONNECT and TRACE; it will be ignored in Symfony 8.0 diff --git a/core/vendor/symfony/http-foundation/Cookie.php b/core/vendor/symfony/http-foundation/Cookie.php index 10127176b3..bf847d2c96 100644 --- a/core/vendor/symfony/http-foundation/Cookie.php +++ b/core/vendor/symfony/http-foundation/Cookie.php @@ -51,17 +51,62 @@ public static function fromString(string $cookie, bool $decode = false): static 'partitioned' => false, ]; - $parts = HeaderUtils::split($cookie, ';='); - $part = array_shift($parts); + [$pair, $attributes] = explode(';', $cookie, 2) + ['', '']; + $part = explode('=', $pair, 2); - $name = $decode ? urldecode($part[0]) : $part[0]; - $value = isset($part[1]) ? ($decode ? urldecode($part[1]) : $part[1]) : null; + $name = trim($part[0]); + $value = isset($part[1]) ? trim($part[1]) : null; + $name = $decode ? urldecode($name) : $name; + $value = $decode && null !== $value ? urldecode($value) : $value; - $data = HeaderUtils::combine($parts) + $data; - $data['expires'] = self::expiresTimestamp($data['expires']); + foreach (explode(';', $attributes) as $attribute) { + $attribute = explode('=', $attribute, 2); + $attributeName = strtolower(trim($attribute[0])); + $attributeValue = trim($attribute[1] ?? ''); - if (isset($data['max-age']) && ($data['max-age'] > 0 || $data['expires'] > time())) { - $data['expires'] = time() + (int) $data['max-age']; + if ('raw' === $attributeName || !\array_key_exists($attributeName, $data) && 'max-age' !== $attributeName) { + continue; + } + + if (\in_array($attributeName, ['secure', 'httponly', 'partitioned'], true)) { + $attributeValue = true; + } elseif ('expires' === $attributeName) { + // double quotes are delimiters in cookie dates, see RFC 6265, section 5.1.1 + $attributeValue = str_replace('"', '', $attributeValue); + + if (is_numeric($attributeValue)) { + $attributeValue = self::expiresTimestamp($attributeValue); + } elseif (false === $attributeValue = strtotime($attributeValue)) { + continue; + } else { + $attributeValue = max(1, $attributeValue); + } + } elseif ('max-age' === $attributeName) { + if (!preg_match('/^-?\d+$/D', $attributeValue)) { + continue; + } + } elseif ('samesite' === $attributeName) { + $attributeValue = strtolower($attributeValue); + + if (!\in_array($attributeValue, [self::SAMESITE_LAX, self::SAMESITE_STRICT, self::SAMESITE_NONE], true)) { + continue; + } + } + + $data[$attributeName] = $attributeValue; + } + + if (isset($data['max-age'])) { + $maxAge = (int) $data['max-age']; + $now = time(); + + if ($maxAge > 0) { + $data['expires'] = $now + min($maxAge, \PHP_INT_MAX - $now); + } elseif ($data['expires'] > $now) { + $data['expires'] = $now; + } elseif (0 === $data['expires']) { + $data['expires'] = 1; + } } return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite'], $data['partitioned']); @@ -107,7 +152,7 @@ public function __construct( throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $name)); } - if (!$name) { + if ('' === $name) { throw new \InvalidArgumentException('The cookie name cannot be empty.'); } diff --git a/core/vendor/symfony/http-foundation/InputBag.php b/core/vendor/symfony/http-foundation/InputBag.php index 8c153dc30e..2dae232514 100644 --- a/core/vendor/symfony/http-foundation/InputBag.php +++ b/core/vendor/symfony/http-foundation/InputBag.php @@ -141,12 +141,56 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; $options['flags'] |= \FILTER_NULL_ON_FAILURE; - $value = filter_var($value, $filter, $options); + $invalid = false; + $forceArray = !\is_array($value) && ($options['flags'] & \FILTER_FORCE_ARRAY) && !($options['flags'] & \FILTER_REQUIRE_ARRAY); + + if (!$nullOnFailure && \is_array($value) && $value && !($options['flags'] & \FILTER_REQUIRE_SCALAR) && ($options['flags'] & (\FILTER_REQUIRE_ARRAY | \FILTER_FORCE_ARRAY))) { + $options['flags'] &= ~(\FILTER_REQUIRE_ARRAY | \FILTER_FORCE_ARRAY); + $value = self::filterArray($value, $filter, $options, $invalid); + } else { + if ($forceArray) { + $options['flags'] &= ~\FILTER_FORCE_ARRAY; + } + + $value = filter_var($value, $filter, $options); + $invalid = null === $value; + + if ($forceArray) { + $value = [$value]; + } + } - if (null !== $value || $nullOnFailure) { + if (!$invalid || $nullOnFailure) { return $value; } throw new BadRequestException(\sprintf('Input value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key)); } + + private static function filterArray(array $value, int $filter, array $options, bool &$invalid, array &$references = []): array + { + foreach ($value as $key => $item) { + if (!\is_array($item)) { + $value[$key] = filter_var($item, $filter, $options); + $invalid = $invalid || null === $value[$key]; + continue; + } + + $id = \ReflectionReference::fromArrayElement($value, $key)?->getId(); + if (null !== $id && isset($references[$id])) { + continue; + } + if (null !== $id) { + $references[$id] = true; + } + + $value[$key] = self::filterArray($item, $filter, $options, $invalid, $references); + + if (null !== $id) { + unset($references[$id]); + } + } + + return $value; + } } diff --git a/core/vendor/symfony/http-foundation/ParameterBag.php b/core/vendor/symfony/http-foundation/ParameterBag.php index 5e36cf2f9d..1998806fb8 100644 --- a/core/vendor/symfony/http-foundation/ParameterBag.php +++ b/core/vendor/symfony/http-foundation/ParameterBag.php @@ -242,9 +242,26 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; $options['flags'] |= \FILTER_NULL_ON_FAILURE; - $value = filter_var($value, $filter, $options); + $invalid = false; + $forceArray = !\is_array($value) && ($options['flags'] & \FILTER_FORCE_ARRAY) && !($options['flags'] & \FILTER_REQUIRE_ARRAY); + + if (!$nullOnFailure && \is_array($value) && $value && !($options['flags'] & \FILTER_REQUIRE_SCALAR) && ($options['flags'] & (\FILTER_REQUIRE_ARRAY | \FILTER_FORCE_ARRAY))) { + $options['flags'] &= ~(\FILTER_REQUIRE_ARRAY | \FILTER_FORCE_ARRAY); + $value = self::filterArray($value, $filter, $options, $invalid); + } else { + if ($forceArray) { + $options['flags'] &= ~\FILTER_FORCE_ARRAY; + } + + $value = filter_var($value, $filter, $options); + $invalid = null === $value; + + if ($forceArray) { + $value = [$value]; + } + } - if (null !== $value || $nullOnFailure) { + if (!$invalid || $nullOnFailure) { return $value; } @@ -268,4 +285,31 @@ public function count(): int { return \count($this->parameters); } + + private static function filterArray(array $value, int $filter, array $options, bool &$invalid, array &$references = []): array + { + foreach ($value as $key => $item) { + if (!\is_array($item)) { + $value[$key] = filter_var($item, $filter, $options); + $invalid = $invalid || null === $value[$key]; + continue; + } + + $id = \ReflectionReference::fromArrayElement($value, $key)?->getId(); + if (null !== $id && isset($references[$id])) { + continue; + } + if (null !== $id) { + $references[$id] = true; + } + + $value[$key] = self::filterArray($item, $filter, $options, $invalid, $references); + + if (null !== $id) { + unset($references[$id]); + } + } + + return $value; + } } diff --git a/core/vendor/symfony/http-foundation/Request.php b/core/vendor/symfony/http-foundation/Request.php index fba1cf33ea..a0366a8a0e 100644 --- a/core/vendor/symfony/http-foundation/Request.php +++ b/core/vendor/symfony/http-foundation/Request.php @@ -1714,7 +1714,7 @@ public function getPreferredLanguage(?array $locales = null): ?string $combinations = array_merge(...array_map($this->getLanguageCombinations(...), $preferredLanguages)); foreach ($combinations as $combination) { foreach ($locales as $locale) { - if (str_starts_with($locale, $combination)) { + if ($locale === $combination || str_starts_with($locale, $combination.'_')) { return $locale; } } diff --git a/core/vendor/symfony/http-foundation/Session/Storage/Handler/AbstractSessionHandler.php b/core/vendor/symfony/http-foundation/Session/Storage/Handler/AbstractSessionHandler.php index bfdc55ba62..a14086406f 100644 --- a/core/vendor/symfony/http-foundation/Session/Storage/Handler/AbstractSessionHandler.php +++ b/core/vendor/symfony/http-foundation/Session/Storage/Handler/AbstractSessionHandler.php @@ -27,6 +27,7 @@ abstract class AbstractSessionHandler implements \SessionHandlerInterface, \Sess private string|false $prefetchData; private ?\Throwable $prefetchError = null; private ?string $newSessionId = null; + private ?string $readSessionId = null; private string $igbinaryEmptyData; public function __serialize(): array @@ -80,6 +81,8 @@ public function validateId(#[\SensitiveParameter] string $sessionId): bool public function read(#[\SensitiveParameter] string $sessionId): string|false { + $this->readSessionId = $sessionId; + if (isset($this->prefetchId)) { $prefetchId = $this->prefetchId; $prefetchData = $this->prefetchData; @@ -127,7 +130,8 @@ public function write(#[\SensitiveParameter] string $sessionId, string $data): b public function destroy(#[\SensitiveParameter] string $sessionId): bool { - if (!headers_sent() && filter_var(\ini_get('session.use_cookies'), \FILTER_VALIDATE_BOOL)) { + // only the handler that read the session manages its cookie, e.g. not the write-only handler of MigratingSessionHandler + if ($this->readSessionId === $sessionId && !headers_sent() && filter_var(\ini_get('session.use_cookies'), \FILTER_VALIDATE_BOOL)) { if (!isset($this->sessionName)) { throw new \LogicException(\sprintf('Session name cannot be empty, did you forget to call "parent::open()" in "%s"?.', static::class)); } diff --git a/core/vendor/symfony/http-foundation/Session/Storage/Handler/MarshallingSessionHandler.php b/core/vendor/symfony/http-foundation/Session/Storage/Handler/MarshallingSessionHandler.php index 8482250014..1d4ae1ed4b 100644 --- a/core/vendor/symfony/http-foundation/Session/Storage/Handler/MarshallingSessionHandler.php +++ b/core/vendor/symfony/http-foundation/Session/Storage/Handler/MarshallingSessionHandler.php @@ -78,6 +78,11 @@ public function read(#[\SensitiveParameter] string $sessionId): string|false public function write(#[\SensitiveParameter] string $sessionId, string $data): bool { + // empty data must reach the inner handler unchanged so that it destroys the session + if ('' === $data || (\function_exists('igbinary_serialize') && igbinary_serialize([]) === $data)) { + return $this->handler->write($sessionId, $data); + } + $failed = []; $marshalledData = $this->marshaller->marshall(['data' => $data], $failed); diff --git a/core/vendor/symfony/http-foundation/Session/Storage/Handler/MemcachedSessionHandler.php b/core/vendor/symfony/http-foundation/Session/Storage/Handler/MemcachedSessionHandler.php index 4f1254f1b6..597b0c99a5 100644 --- a/core/vendor/symfony/http-foundation/Session/Storage/Handler/MemcachedSessionHandler.php +++ b/core/vendor/symfony/http-foundation/Session/Storage/Handler/MemcachedSessionHandler.php @@ -70,6 +70,11 @@ protected function doRead(#[\SensitiveParameter] string $sessionId): string|fals public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { + // PHP 8.6 calls updateTimestamp() instead of write() for a session that is still empty + if ('' === $data || (\function_exists('igbinary_serialize') && igbinary_serialize([]) === $data)) { + return $this->destroy($sessionId); + } + $this->memcached->touch($this->prefix.$sessionId, $this->getCompatibleTtl()); return true; diff --git a/core/vendor/symfony/http-foundation/Session/Storage/Handler/MongoDbSessionHandler.php b/core/vendor/symfony/http-foundation/Session/Storage/Handler/MongoDbSessionHandler.php index d5586030f0..df1620dd17 100644 --- a/core/vendor/symfony/http-foundation/Session/Storage/Handler/MongoDbSessionHandler.php +++ b/core/vendor/symfony/http-foundation/Session/Storage/Handler/MongoDbSessionHandler.php @@ -140,6 +140,11 @@ protected function doWrite(#[\SensitiveParameter] string $sessionId, string $dat public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { + // PHP 8.6 calls updateTimestamp() instead of write() for a session that is still empty + if ('' === $data || (\function_exists('igbinary_serialize') && igbinary_serialize([]) === $data)) { + return $this->destroy($sessionId); + } + $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'); $expiry = $this->getUTCDateTime($ttl); diff --git a/core/vendor/symfony/http-foundation/Session/Storage/Handler/NullSessionHandler.php b/core/vendor/symfony/http-foundation/Session/Storage/Handler/NullSessionHandler.php index a77185e2eb..cde3b9c7c4 100644 --- a/core/vendor/symfony/http-foundation/Session/Storage/Handler/NullSessionHandler.php +++ b/core/vendor/symfony/http-foundation/Session/Storage/Handler/NullSessionHandler.php @@ -33,11 +33,6 @@ protected function doRead(#[\SensitiveParameter] string $sessionId): string return ''; } - public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool - { - return true; - } - protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool { return true; diff --git a/core/vendor/symfony/http-foundation/Session/Storage/Handler/PdoSessionHandler.php b/core/vendor/symfony/http-foundation/Session/Storage/Handler/PdoSessionHandler.php index 281925eaa5..5e5a6946d6 100644 --- a/core/vendor/symfony/http-foundation/Session/Storage/Handler/PdoSessionHandler.php +++ b/core/vendor/symfony/http-foundation/Session/Storage/Handler/PdoSessionHandler.php @@ -449,6 +449,11 @@ protected function doWrite(#[\SensitiveParameter] string $sessionId, string $dat public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { + // PHP 8.6 calls updateTimestamp() instead of write() for a session that is still empty + if ('' === $data || (\function_exists('igbinary_serialize') && igbinary_serialize([]) === $data)) { + return $this->destroy($sessionId); + } + $expiry = time() + (int) (($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime')); try { diff --git a/core/vendor/symfony/http-foundation/Session/Storage/Handler/RedisSessionHandler.php b/core/vendor/symfony/http-foundation/Session/Storage/Handler/RedisSessionHandler.php index 5bbd236dfe..665b51071b 100644 --- a/core/vendor/symfony/http-foundation/Session/Storage/Handler/RedisSessionHandler.php +++ b/core/vendor/symfony/http-foundation/Session/Storage/Handler/RedisSessionHandler.php @@ -98,6 +98,11 @@ public function gc(int $maxlifetime): int|false public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { + // PHP 8.6 calls updateTimestamp() instead of write() for a session that is still empty + if ('' === $data || (\function_exists('igbinary_serialize') && igbinary_serialize([]) === $data)) { + return $this->destroy($sessionId); + } + $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'); return $this->redis->expire($this->prefix.$sessionId, (int) $ttl); diff --git a/core/vendor/symfony/http-foundation/Session/Storage/NativeSessionStorage.php b/core/vendor/symfony/http-foundation/Session/Storage/NativeSessionStorage.php index 5c08513477..ffb90a97d3 100644 --- a/core/vendor/symfony/http-foundation/Session/Storage/NativeSessionStorage.php +++ b/core/vendor/symfony/http-foundation/Session/Storage/NativeSessionStorage.php @@ -37,6 +37,8 @@ class NativeSessionStorage implements SessionStorageInterface protected AbstractProxy|\SessionHandlerInterface $saveHandler; protected MetadataBag $metadataBag; + private static bool $shutdownRegistered = false; + /** * Depending on how you want the storage driver to behave you probably * want to override this constructor entirely. @@ -87,7 +89,10 @@ public function __construct(array $options = [], AbstractProxy|\SessionHandlerIn 'use_strict_mode' => 1, ]; - session_register_shutdown(); + if (!self::$shutdownRegistered) { + session_register_shutdown(); + self::$shutdownRegistered = true; + } $this->setMetadataBag($metaBag); $this->setOptions($options); diff --git a/core/vendor/symfony/http-kernel/DependencyInjection/LoggerPass.php b/core/vendor/symfony/http-kernel/DependencyInjection/LoggerPass.php index 7566d7358c..52addd7dcb 100644 --- a/core/vendor/symfony/http-kernel/DependencyInjection/LoggerPass.php +++ b/core/vendor/symfony/http-kernel/DependencyInjection/LoggerPass.php @@ -42,6 +42,7 @@ public function process(ContainerBuilder $container): void } $container->register('logger', Logger::class) - ->setArguments([null, null, null, new Reference(RequestStack::class), $debug]); + ->setArguments([null, null, null, new Reference(RequestStack::class), $debug]) + ->addTag('kernel.reset', ['method' => 'clear', 'on_invalid' => 'ignore']); } } diff --git a/core/vendor/symfony/http-kernel/HttpCache/HttpCache.php b/core/vendor/symfony/http-kernel/HttpCache/HttpCache.php index 4b58a1f5c6..b445fc38aa 100644 --- a/core/vendor/symfony/http-kernel/HttpCache/HttpCache.php +++ b/core/vendor/symfony/http-kernel/HttpCache/HttpCache.php @@ -91,7 +91,12 @@ public function __construct( array $options = [], ) { // needed in case there is a fatal error because the backend is too slow to respond - register_shutdown_function($this->store->cleanup(...)); + $pid = getmypid(); + register_shutdown_function(static function () use ($store, $pid) { + if ($pid === getmypid()) { + $store->cleanup(); + } + }); $this->options = array_merge([ 'debug' => false, diff --git a/core/vendor/symfony/http-kernel/HttpCache/Store.php b/core/vendor/symfony/http-kernel/HttpCache/Store.php index bcfe4b023e..b66700511c 100644 --- a/core/vendor/symfony/http-kernel/HttpCache/Store.php +++ b/core/vendor/symfony/http-kernel/HttpCache/Store.php @@ -28,6 +28,7 @@ class Store implements StoreInterface private \SplObjectStorage $keyCache; /** @var array */ private array $locks = []; + private int|false $pid; /** * Constructor. @@ -43,6 +44,7 @@ public function __construct( protected string $root, private array $options = [], ) { + $this->pid = getmypid(); if (!is_dir($this->root) && !@mkdir($this->root, 0o777, true) && !is_dir($this->root)) { throw new \RuntimeException(\sprintf('Unable to create the store directory (%s).', $this->root)); } @@ -55,6 +57,8 @@ public function __construct( */ public function cleanup(): void { + $this->resetLocksAfterFork(); + // unlock everything foreach ($this->locks as $lock) { flock($lock, \LOCK_UN); @@ -71,6 +75,8 @@ public function cleanup(): void */ public function lock(Request $request): bool|string { + $this->resetLocksAfterFork(); + $key = $this->getCacheKey($request); if (!isset($this->locks[$key])) { @@ -98,6 +104,8 @@ public function lock(Request $request): bool|string */ public function unlock(Request $request): bool { + $this->resetLocksAfterFork(); + $key = $this->getCacheKey($request); if (isset($this->locks[$key])) { @@ -113,6 +121,8 @@ public function unlock(Request $request): bool public function isLocked(Request $request): bool { + $this->resetLocksAfterFork(); + $key = $this->getCacheKey($request); if (isset($this->locks[$key])) { @@ -333,6 +343,8 @@ public function purge(string $url): bool */ private function doPurge(string $url): bool { + $this->resetLocksAfterFork(); + $key = $this->getCacheKey(Request::create($url)); if (isset($this->locks[$key])) { flock($this->locks[$key], \LOCK_UN); @@ -364,6 +376,8 @@ private function load(string $key): ?string */ private function save(string $key, string $data, bool $overwrite = true): bool { + $this->resetLocksAfterFork(); + $path = $this->getPath($key); if (!$overwrite && file_exists($path)) { @@ -495,4 +509,19 @@ private function restoreResponse(array $headers, ?string $path = null): ?Respons return new Response($content, $status, $headers); } + + private function resetLocksAfterFork(): void + { + if ($this->pid === $pid = getmypid()) { + return; + } + + // Closing an inherited handle preserves the parent's lock. + foreach ($this->locks as $lock) { + fclose($lock); + } + + $this->locks = []; + $this->pid = $pid; + } } diff --git a/core/vendor/symfony/http-kernel/HttpKernel.php b/core/vendor/symfony/http-kernel/HttpKernel.php index 8032030c42..994e978e62 100644 --- a/core/vendor/symfony/http-kernel/HttpKernel.php +++ b/core/vendor/symfony/http-kernel/HttpKernel.php @@ -54,6 +54,7 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface protected RequestStack $requestStack; private ArgumentResolverInterface $argumentResolver; private bool $terminating = false; + private ?\WeakReference $terminateException = null; public function __construct( protected EventDispatcherInterface $dispatcher, @@ -112,6 +113,10 @@ public function terminate(Request $request, Response $response): void try { $this->terminating = true; $this->dispatcher->dispatch(new TerminateEvent($this, $request, $response), KernelEvents::TERMINATE); + } catch (\Throwable $e) { + $this->terminateException = \WeakReference::create($e); + + throw $e; } finally { $this->terminating = false; } @@ -130,9 +135,14 @@ public function terminateWithException(\Throwable $exception, ?Request $request $this->requestStack->push($request); } + $terminating = $this->terminating; + $this->terminating = $terminating || $exception === $this->terminateException?->get(); + try { $response = $this->handleThrowable($exception, $request, self::MAIN_REQUEST); } finally { + $this->terminating = $terminating; + if ($pop) { $this->requestStack->pop(); } diff --git a/core/vendor/symfony/http-kernel/Kernel.php b/core/vendor/symfony/http-kernel/Kernel.php index e2a3da938f..1d3b9552c4 100644 --- a/core/vendor/symfony/http-kernel/Kernel.php +++ b/core/vendor/symfony/http-kernel/Kernel.php @@ -74,11 +74,11 @@ abstract class Kernel implements KernelInterface, RebootableInterface, Terminabl */ private static array $freshCache = []; - public const VERSION = '7.4.21'; - public const VERSION_ID = 70421; + public const VERSION = '7.4.22'; + public const VERSION_ID = 70422; public const MAJOR_VERSION = 7; public const MINOR_VERSION = 4; - public const RELEASE_VERSION = 21; + public const RELEASE_VERSION = 22; public const EXTRA_VERSION = ''; public const END_OF_MAINTENANCE = '11/2028'; @@ -143,6 +143,18 @@ public function terminate(Request $request, Response $response): void return; } + if (!$this->handlingHttpCache && $this->container->has('http_cache') && ($httpCache = $this->container->get('http_cache')) instanceof TerminableInterface) { + $this->handlingHttpCache = true; + + try { + $httpCache->terminate($request, $response); + } finally { + $this->handlingHttpCache = false; + } + + return; + } + if ($this->getHttpKernel() instanceof TerminableInterface) { $this->getHttpKernel()->terminate($request, $response); } @@ -164,8 +176,6 @@ public function shutdown(): void $this->container = null; $this->requestStackSize = 0; $this->resetServices = false; - - gc_collect_cycles(); } public function handle(Request $request, int $type = HttpKernelInterface::MAIN_REQUEST, bool $catch = true): Response @@ -460,6 +470,8 @@ protected function initializeContainer(): void error_reporting($errorLevel); } + $this->container = null; + try { if ($collectDeprecations = $this->debug && !\defined('PHPUNIT_COMPOSER_INSTALL')) { $collectedLogs = []; diff --git a/core/vendor/symfony/http-kernel/Profiler/FileProfilerStorage.php b/core/vendor/symfony/http-kernel/Profiler/FileProfilerStorage.php index 8722d96983..5407e129f8 100644 --- a/core/vendor/symfony/http-kernel/Profiler/FileProfilerStorage.php +++ b/core/vendor/symfony/http-kernel/Profiler/FileProfilerStorage.php @@ -50,11 +50,19 @@ public function find(?string $ip, ?string $url, ?int $limit, ?string $method, ?i return []; } + // the lines before this offset belong to expired profiles, whose files were already removed + $offset = is_file($file.'.offset') ? (int) file_get_contents($file.'.offset') : 0; + $file = fopen($file, 'r'); fseek($file, 0, \SEEK_END); + if (ftell($file) < $offset) { + // the index was recreated after the offset was saved + $offset = 0; + } + $result = []; - while (\count($result) < $limit && $line = $this->readLineFromFile($file)) { + while (\count($result) < $limit && ftell($file) > $offset && $line = $this->readLineFromFile($file)) { $values = str_getcsv($line, ',', '"', '\\'); if (7 > \count($values)) { @@ -326,6 +334,11 @@ private function removeExpiredProfiles(): void $handle = fopen($file, 'r'); if ($offset = is_file($file.'.offset') ? (int) file_get_contents($file.'.offset') : 0) { + if ($offset > fstat($handle)['size']) { + // the index was recreated after the offset was saved + $offset = 0; + } + fseek($handle, $offset); } @@ -340,7 +353,7 @@ private function removeExpiredProfiles(): void [$csvToken, , , , $csvTime] = $values; - if ($csvTime >= $minimalProfileTimestamp) { + if ((int) $csvTime >= $minimalProfileTimestamp) { break; } diff --git a/core/vendor/symfony/mime/Email.php b/core/vendor/symfony/mime/Email.php index d05115eb51..76e92cd4cb 100644 --- a/core/vendor/symfony/mime/Email.php +++ b/core/vendor/symfony/mime/Email.php @@ -573,7 +573,7 @@ public function __serialize(): array */ public function __unserialize(array $data): void { - if (($data[1] ?? null) instanceof \Stringable || ($data[3] ?? null) instanceof \Stringable) { + if (!\is_string($data[1] ?? '') || !\is_string($data[3] ?? '')) { throw new \BadMethodCallException('Cannot unserialize '.self::class); } diff --git a/core/vendor/symfony/mime/Header/ParameterizedHeader.php b/core/vendor/symfony/mime/Header/ParameterizedHeader.php index 6592a6e6aa..93f218d5b3 100644 --- a/core/vendor/symfony/mime/Header/ParameterizedHeader.php +++ b/core/vendor/symfony/mime/Header/ParameterizedHeader.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Mime\Header; use Symfony\Component\Mime\Encoder\Rfc2231Encoder; +use Symfony\Component\Mime\Exception\RfcComplianceException; /** * @author Chris Corbyn @@ -56,6 +57,12 @@ public function getParameter(string $parameter): string */ public function setParameters(array $parameters): void { + foreach ($parameters as $k => $v) { + if (!preg_match('/^'.self::TOKEN_REGEX.'$/D', $k)) { + throw new RfcComplianceException(\sprintf('The parameter name "%s" contains non-token characters.', $k)); + } + } + $this->parameters = $parameters; } @@ -176,7 +183,7 @@ private function getEndOfParameterValue(string $value, bool $encoded = false, bo { $forceHttpQuoting = 'form-data' === $this->getValue() && 'content-disposition' === strtolower($this->getName()); if ($forceHttpQuoting || !preg_match('/^'.self::TOKEN_REGEX.'$/D', $value)) { - $value = '"'.$value.'"'; + $value = '"'.addcslashes($value, '"\\').'"'; } $prepend = '='; if ($encoded) { diff --git a/core/vendor/symfony/mime/Part/AbstractMultipartPart.php b/core/vendor/symfony/mime/Part/AbstractMultipartPart.php index 1da0ddf331..3a30beeeaa 100644 --- a/core/vendor/symfony/mime/Part/AbstractMultipartPart.php +++ b/core/vendor/symfony/mime/Part/AbstractMultipartPart.php @@ -88,6 +88,47 @@ public function asDebugString(): string return $str; } + public function __serialize(): array + { + $data = parent::__serialize(); + + if (!method_exists($this, '__sleep')) { + $data['boundary'] = $this->boundary; + $data['parts'] = $this->parts; + } + + return $data; + } + + public function __unserialize(array $data): void + { + foreach (['', "\0".self::class."\0"] as $prefix) { + if (!\array_key_exists($prefix.'parts', $data)) { + continue; + } + + $boundary = $data[$prefix.'boundary'] ?? null; + $parts = $data[$prefix.'parts']; + unset($data[$prefix.'boundary'], $data[$prefix.'parts']); + + if ((null !== $boundary && !\is_string($boundary)) || !\is_array($parts)) { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + foreach ($parts as $part) { + if (!$part instanceof AbstractPart) { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + } + + $this->boundary = $boundary; + $this->parts = $parts; + break; + } + + parent::__unserialize($data); + } + private function getBoundary(): string { return $this->boundary ??= strtr(base64_encode(random_bytes(6)), '+/', '-_'); diff --git a/core/vendor/symfony/mime/Part/AbstractPart.php b/core/vendor/symfony/mime/Part/AbstractPart.php index f79266cb9f..8142252db2 100644 --- a/core/vendor/symfony/mime/Part/AbstractPart.php +++ b/core/vendor/symfony/mime/Part/AbstractPart.php @@ -66,7 +66,7 @@ abstract public function getMediaSubtype(): string; public function __serialize(): array { if (!method_exists($this, '__sleep')) { - return ['headers' => $this->headers]; + return ['headers' => $this->headers] + $this->getSubclassState(); } trigger_deprecation('symfony/mime', '7.4', 'Implementing "%s::__sleep()" is deprecated, use "__serialize()" instead.', get_debug_type($this)); @@ -87,7 +87,7 @@ public function __serialize(): array public function __unserialize(array $data): void { - if ($wakeup = method_exists($this, '__wakeup') && self::class === (new \ReflectionMethod($this, '__unserialize'))->class) { + if ($wakeup = method_exists($this, '__wakeup') && \in_array((new \ReflectionMethod($this, '__unserialize'))->class, [self::class, AbstractMultipartPart::class], true)) { trigger_deprecation('symfony/mime', '7.4', 'Implementing "%s::__wakeup()" is deprecated, use "__unserialize()" instead.', get_debug_type($this)); } @@ -101,8 +101,24 @@ public function __unserialize(array $data): void return; } + // the payload comes from __serialize() for a subclass that declares properties, or from the native serialization of Symfony < 7.4 + $headersKey = \array_key_exists('headers', $data) ? 'headers' : "\0".self::class."\0headers"; + if (\array_key_exists($headersKey, $data) && self::isCoreClass((new \ReflectionMethod($this, '__unserialize'))->class) && self::isCoreClass((new \ReflectionMethod($this, '__serialize'))->class)) { + $this->headers = $data[$headersKey]; + unset($data[$headersKey]); + $this->setSubclassState($data); + + if ($wakeup) { + $this->__wakeup(); + } + + return; + } + trigger_deprecation('symfony/mime', '7.4', 'Passing more than just key "headers" to "%s::__unserialize()" is deprecated, populate properties in "%s::__unserialize()" instead.', self::class, get_debug_type($this)); + self::rejectStringCasts(static::class, $data); + \Closure::bind(function ($data) use ($wakeup) { foreach ($data as $key => $value) { $this->{("\0" === $key[0] ?? '') ? substr($key, 1 + strrpos($key, "\0")) : $key} = $value; @@ -113,4 +129,84 @@ public function __unserialize(array $data): void } }, $this, static::class)($data); } + + /** + * Returns the properties declared by subclasses that are not part of Symfony, keyed like the native serialization does. + */ + private function getSubclassState(): array + { + if (self::isCoreClass(static::class)) { + return []; + } + + $state = []; + foreach ((array) $this as $key => $value) { + if (!str_starts_with($key, "\0".__NAMESPACE__.'\\')) { + $state[$key] = $value; + } + } + + return $state; + } + + private function setSubclassState(array $data): void + { + foreach ($data as $key => $value) { + [$class, $name] = "\0" === ($key[0] ?? '') ? explode("\0", substr($key, 1), 2) + [1 => ''] : ['*', $key]; + $class = '*' === $class ? static::class : $class; + + if (!$this instanceof $class) { + throw new \BadMethodCallException('Cannot unserialize '.static::class); + } + + $property = property_exists($class, $name) ? new \ReflectionProperty($class, $name) : null; + + if (self::isCoreClass($property?->class ?? $class) || ($property && $value instanceof \Stringable && self::castsToString($property, $value))) { + throw new \BadMethodCallException('Cannot unserialize '.static::class); + } + + \Closure::bind(function () use ($name, $value) { + $this->$name = $value; + }, $this, $class)(); + } + } + + /** + * Rejects the \Stringable values that would be cast to string when assigned by name in the scope of the class. + */ + private static function rejectStringCasts(string $class, array $data): void + { + foreach ($data as $key => $value) { + $name = "\0" === ($key[0] ?? '') ? substr($key, 1 + strrpos($key, "\0")) : $key; + + if ($value instanceof \Stringable && property_exists($class, $name) && self::castsToString(new \ReflectionProperty($class, $name), $value)) { + throw new \BadMethodCallException('Cannot unserialize '.$class); + } + } + } + + private static function isCoreClass(string $class): bool + { + return str_starts_with($class, __NAMESPACE__.'\\'); + } + + private static function castsToString(\ReflectionProperty $property, object $value): bool + { + $type = $property->getType(); + $string = false; + + foreach ($type instanceof \ReflectionUnionType ? $type->getTypes() : [$type] as $t) { + if (!$t instanceof \ReflectionNamedType) { + continue; + } + + if ('string' === $t->getName()) { + $string = true; + } elseif ('object' === $t->getName() || (!$t->isBuiltin() && $value instanceof ($t->getName()))) { + return false; + } + } + + return $string; + } } diff --git a/core/vendor/symfony/mime/Part/DataPart.php b/core/vendor/symfony/mime/Part/DataPart.php index 90b9cffc31..3f952e3800 100644 --- a/core/vendor/symfony/mime/Part/DataPart.php +++ b/core/vendor/symfony/mime/Part/DataPart.php @@ -163,6 +163,13 @@ public function __serialize(): array public function __unserialize(array $data): void { + if (!\is_string($data['filename'] ?? $data["\0".self::class."\0filename"] ?? '') + || !\is_string($data['mediaType'] ?? $data["\0".self::class."\0mediaType"] ?? null) + || !\is_string($data['cid'] ?? $data["\0".self::class."\0cid"] ?? '') + ) { + throw new \BadMethodCallException('Cannot unserialize '.self::class); + } + if ($wakeup = self::class !== (new \ReflectionMethod($this, '__wakeup'))->class && self::class === (new \ReflectionMethod($this, '__unserialize'))->class) { trigger_deprecation('symfony/mime', '7.4', 'Implementing "%s::__wakeup()" is deprecated, use "__unserialize()" instead.', get_debug_type($this)); } @@ -180,10 +187,12 @@ public function __unserialize(array $data): void return; } - if (["\0*\0_headers", "\0*\0_parent", "\0".self::class."\0filename", "\0".self::class."\0mediaType"] === array_keys($data)) { + $p = "\0".self::class."\0"; + if (["\0*\0_headers", "\0*\0_parent", $p.'filename', $p.'mediaType'] === array_keys($data) || ["\0*\0_headers", "\0*\0_parent", $p.'filename', $p.'mediaType', $p.'cid'] === array_keys($data)) { parent::__unserialize(['_headers' => $data["\0*\0_headers"], ...$data["\0*\0_parent"]]); - $this->filename = $data["\0".self::class."\0filename"]; - $this->mediaType = $data["\0".self::class."\0mediaType"]; + $this->filename = $data[$p.'filename']; + $this->mediaType = $data[$p.'mediaType']; + $this->cid = $data[$p.'cid'] ?? null; if ($wakeup) { $this->__wakeup(); @@ -194,15 +203,37 @@ public function __unserialize(array $data): void trigger_deprecation('symfony/mime', '7.4', 'Passing extra keys to "%s::__unserialize()" is deprecated, populate properties in "%s::__unserialize()" instead.', self::class, get_debug_type($this)); - \Closure::bind(function ($data) use ($wakeup) { - foreach ($data as $key => $value) { - $this->{("\0" === $key[0] ?? '') ? substr($key, 1 + strrpos($key, "\0")) : $key} = $value; - } + $headers = $data['_headers'] ?? $data["\0*\0_headers"] ?? null; + $parent = $data['_parent'] ?? $data["\0*\0_parent"] ?? null; + unset($data['_headers'], $data["\0*\0_headers"], $data['_parent'], $data["\0*\0_parent"]); - if ($wakeup) { - $this->__wakeup(); - } - }, $this, static::class)($data); + $state = []; + foreach (['filename', 'mediaType', 'cid'] as $name) { + $key = \array_key_exists($name, $data) ? $name : "\0".self::class."\0".$name; + $state[$name] = $data[$key] ?? null; + unset($data[$key]); + } + + if (!\is_array($parent)) { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + (new \ReflectionMethod(AbstractPart::class, 'setSubclassState'))->invoke($this, $data); + + if ($wakeup) { + $this->_headers = $headers; + $this->_parent = $parent; + } else { + parent::__unserialize(['_headers' => $headers, ...$parent]); + } + + foreach ($state as $name => $value) { + $this->$name = $value; + } + + if ($wakeup) { + $this->__wakeup(); + } } /** diff --git a/core/vendor/symfony/mime/Part/Multipart/FormDataPart.php b/core/vendor/symfony/mime/Part/Multipart/FormDataPart.php index ca4080198d..c74757d681 100644 --- a/core/vendor/symfony/mime/Part/Multipart/FormDataPart.php +++ b/core/vendor/symfony/mime/Part/Multipart/FormDataPart.php @@ -44,6 +44,24 @@ public function getParts(): array return $this->prepareFields($this->fields); } + public function __serialize(): array + { + return parent::__serialize() + ['fields' => $this->fields]; + } + + public function __unserialize(array $data): void + { + foreach (['fields', "\0".self::class."\0fields"] as $key) { + if (\array_key_exists($key, $data)) { + $this->fields = $data[$key]; + unset($data[$key]); + break; + } + } + + parent::__unserialize($data); + } + private function prepareFields(array $fields): array { $values = []; diff --git a/core/vendor/symfony/mime/Part/Multipart/RelatedPart.php b/core/vendor/symfony/mime/Part/Multipart/RelatedPart.php index 18ca9f17ab..c02ea4d2d3 100644 --- a/core/vendor/symfony/mime/Part/Multipart/RelatedPart.php +++ b/core/vendor/symfony/mime/Part/Multipart/RelatedPart.php @@ -39,6 +39,24 @@ public function getMediaSubtype(): string return 'related'; } + public function __serialize(): array + { + return parent::__serialize() + ['mainPart' => $this->mainPart]; + } + + public function __unserialize(array $data): void + { + foreach (['mainPart', "\0".self::class."\0mainPart"] as $key) { + if (\array_key_exists($key, $data)) { + $this->mainPart = $data[$key]; + unset($data[$key]); + break; + } + } + + parent::__unserialize($data); + } + private function generateContentId(): string { return bin2hex(random_bytes(16)).'@symfony'; diff --git a/core/vendor/symfony/mime/Part/SMimePart.php b/core/vendor/symfony/mime/Part/SMimePart.php index 102ac3f7dc..8dacb8f737 100644 --- a/core/vendor/symfony/mime/Part/SMimePart.php +++ b/core/vendor/symfony/mime/Part/SMimePart.php @@ -119,10 +119,11 @@ public function __serialize(): array public function __unserialize(array $data): void { - foreach (['body', 'type', 'subtype'] as $prop) { - if (($data[$prop] ?? $data["\0".self::class."\0".$prop] ?? $data["\0*\0".$prop] ?? null) instanceof \Stringable) { - throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); - } + if (($data['body'] ?? $data["\0".self::class."\0body"] ?? $data["\0*\0body"] ?? null) instanceof \Stringable + || !\is_string($data['type'] ?? $data["\0".self::class."\0type"] ?? $data["\0*\0type"] ?? null) + || !\is_string($data['subtype'] ?? $data["\0".self::class."\0subtype"] ?? $data["\0*\0subtype"] ?? null) + ) { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } if ($wakeup = self::class !== (new \ReflectionMethod($this, '__wakeup'))->class && self::class === (new \ReflectionMethod($this, '__unserialize'))->class) { @@ -163,15 +164,31 @@ public function __unserialize(array $data): void trigger_deprecation('symfony/mime', '7.4', 'Passing extra keys to "%s::__unserialize()" is deprecated, populate properties in "%s::__unserialize()" instead.', self::class, get_debug_type($this)); - \Closure::bind(function ($data) use ($wakeup) { - foreach ($data as $key => $value) { - $this->{("\0" === $key[0] ?? '') ? substr($key, 1 + strrpos($key, "\0")) : $key} = $value; - } + $headers = $data['_headers'] ?? $data["\0*\0_headers"] ?? null; + unset($data['_headers'], $data["\0*\0_headers"]); - if ($wakeup) { - $this->__wakeup(); - } - }, $this, static::class)($data); + $state = []; + foreach (['body', 'type', 'subtype', 'parameters'] as $name) { + $key = \array_key_exists($name, $data) ? $name : "\0".self::class."\0".$name; + $state[$name] = $data[$key] ?? null; + unset($data[$key]); + } + + (new \ReflectionMethod(AbstractPart::class, 'setSubclassState'))->invoke($this, $data); + + if ($wakeup) { + $this->_headers = $headers; + } else { + parent::__unserialize(['headers' => $headers]); + } + + foreach ($state as $name => $value) { + $this->$name = $value; + } + + if ($wakeup) { + $this->__wakeup(); + } } /** diff --git a/core/vendor/symfony/mime/Part/TextPart.php b/core/vendor/symfony/mime/Part/TextPart.php index c83e7acde8..246ba87ab0 100644 --- a/core/vendor/symfony/mime/Part/TextPart.php +++ b/core/vendor/symfony/mime/Part/TextPart.php @@ -276,8 +276,10 @@ public function __serialize(): array public function __unserialize(array $data): void { - foreach (['charset', 'subtype', 'disposition', 'name', 'encoding'] as $prop) { - if (($data[$prop] ?? $data["\0".self::class."\0".$prop] ?? $data["\0*\0".$prop] ?? null) instanceof \Stringable) { + foreach (['charset' => true, 'subtype' => false, 'disposition' => true, 'name' => true, 'encoding' => false] as $prop => $nullable) { + $value = $data[$prop] ?? $data["\0".self::class."\0".$prop] ?? $data["\0*\0".$prop] ?? null; + + if (!\is_string($value) && (!$nullable || null !== $value)) { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } } @@ -329,15 +331,25 @@ public function __unserialize(array $data): void trigger_deprecation('symfony/mime', '7.4', 'Passing extra keys to "%s::__unserialize()" is deprecated, populate properties in "%s::__unserialize()" instead.', self::class, get_debug_type($this)); - \Closure::bind(function ($data) use ($wakeup) { - foreach ($data as $key => $value) { - $this->{("\0" === $key[0] ?? '') ? substr($key, 1 + strrpos($key, "\0")) : $key} = $value; - } + $state = []; + foreach (['body', 'charset', 'subtype', 'disposition', 'name', 'encoding'] as $name) { + $key = \array_key_exists($name, $data) ? $name : "\0".self::class."\0".$name; + $state[$name] = $data[$key] ?? null; + unset($data[$key]); + } - if ($wakeup) { - $this->__wakeup(); - } - }, $this, static::class)($data); + (new \ReflectionMethod(AbstractPart::class, 'setSubclassState'))->invoke($this, $data); + + foreach ($state as $name => $value) { + $this->$name = $value; + } + + if ($wakeup) { + $this->_headers = $headers; + $this->__wakeup(); + } elseif (!\is_string($this->body) && !$this->body instanceof File) { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } } /** diff --git a/core/vendor/symfony/polyfill-intl-grapheme/Grapheme.php b/core/vendor/symfony/polyfill-intl-grapheme/Grapheme.php index 94d080ac9b..a7e18d7def 100644 --- a/core/vendor/symfony/polyfill-intl-grapheme/Grapheme.php +++ b/core/vendor/symfony/polyfill-intl-grapheme/Grapheme.php @@ -49,6 +49,9 @@ final class Grapheme ['μ', 's', 'ι', 'σ', 'β', 'θ', 'φ', 'π', 'κ', 'ρ', 'ε', "\xE1\xB9\xA1", 'ι'], ]; + // The characters that extend a grapheme cluster and that ICU 74 ignores when it compares strings + private const IGNORABLE_RX = '/^[\x{034F}\x{0488}-\x{0489}\x{0591}-\x{05AF}\x{05BD}\x{05C4}-\x{05C5}\x{0610}-\x{061A}\x{06D6}-\x{06DC}\x{06DF}-\x{06E4}\x{06E7}-\x{06E8}\x{06EA}-\x{06ED}\x{0740}\x{0743}-\x{0744}\x{0747}-\x{074A}\x{0898}-\x{089D}\x{08CA}-\x{08E1}\x{08EA}-\x{08EF}\x{08F3}\x{0951}-\x{0952}\x{0F18}-\x{0F19}\x{0F35}\x{0F37}\x{0F3E}-\x{0F3F}\x{0F86}-\x{0F87}\x{0FC6}\x{17B4}-\x{17B5}\x{17D3}\x{180B}-\x{180D}\x{180F}\x{1A7F}\x{1B6B}-\x{1B73}\x{1CD0}-\x{1CD2}\x{1CD4}-\x{1CE8}\x{1CF4}\x{1CF7}-\x{1CF9}\x{200C}-\x{200D}\x{2D7F}\x{A670}-\x{A672}\x{A8E0}-\x{A8F1}\x{FE00}-\x{FE0F}\x{FE21}\x{FE23}-\x{FE26}\x{FE28}\x{FE2A}-\x{FE2D}\x{FE2F}\x{102E0}\x{10EFD}-\x{10EFF}\x{11366}-\x{1136C}\x{11370}-\x{11374}\x{13440}\x{13447}-\x{13455}\x{16FE4}\x{1CF00}-\x{1CF2D}\x{1CF30}-\x{1CF46}\x{1D165}-\x{1D169}\x{1D16D}-\x{1D172}\x{1D17B}-\x{1D182}\x{1D185}-\x{1D18B}\x{1D1AA}-\x{1D1AD}\x{1D242}-\x{1D244}\x{1DA00}-\x{1DA36}\x{1DA3B}-\x{1DA6C}\x{1DA75}\x{1DA84}\x{1DA9B}-\x{1DA9F}\x{1DAA1}-\x{1DAAF}\x{1E8D0}-\x{1E8D6}\x{E0020}-\x{E007F}\x{E0100}-\x{E01EF}]*+\z/u'; + // indexed by the $mode argument of grapheme_position() private const POSITION_FUNCTIONS = ['grapheme_strpos', 'grapheme_stripos', 'grapheme_strrpos', 'grapheme_strripos']; @@ -377,13 +380,72 @@ private static function grapheme_position($s, $needle, $offset, $mode) $needle = str_replace(self::CASE_FOLD[0], self::CASE_FOLD[1], $needle); } } - if ($reverse) { - $needlePos = strrpos($s, $needle); - } else { - $needlePos = strpos($s, $needle); + $needleLen = \strlen($needle); + $needlePos = $reverse ? strrpos($s, $needle) : strpos($s, $needle); + + while (false !== $needlePos) { + if (false !== $i = self::graphemeIndex($s, $needlePos, $needlePos + $needleLen)) { + return $i + $offset; + } + + if (!$reverse) { + $needlePos = strpos($s, $needle, $needlePos + 1); + } elseif ($needlePos) { + $needlePos = strrpos(substr($s, 0, $needlePos + $needleLen - 1), $needle); + } else { + $needlePos = false; + } + } + + return false; + } + + /** + * Returns the position in grapheme units of the bytes from $start to $end, + * or false when they split a grapheme cluster on characters that are not ignorable. + */ + private static function graphemeIndex($s, $start, $end) + { + if (self::isAsciiBoundary($s, $start) && self::isAsciiBoundary($s, $end)) { + return self::grapheme_strlen(substr($s, 0, $start)); } - return false !== $needlePos ? self::grapheme_strlen(substr($s, 0, $needlePos)) + $offset : false; + $i = $pos = 0; + + if ($start) { + // count the clusters that start before $start, then find where the last one starts + $prefix = substr($s, 0, $start); + preg_replace('/'.SYMFONY_GRAPHEME_CLUSTER_RX.'/u', '', $prefix, -1, $i); + + if (!$i) { + return false; + } + + $pos = 1 < $i ? $start - \strlen(preg_replace('/'.SYMFONY_GRAPHEME_CLUSTER_RX.'/u', '', $prefix, $i - 1)) : 0; + } + + do { + if (!preg_match('/\G'.SYMFONY_GRAPHEME_CLUSTER_RX.'/u', $s, $cluster, 0, $pos)) { + return false; + } + + $next = $pos + \strlen($cluster[0]); + + if ($pos < $start && $start < $next && !preg_match(self::IGNORABLE_RX, substr($s, $start, min($next, $end) - $start))) { + return false; + } + + if ($pos < $end && $end < $next && !preg_match(self::IGNORABLE_RX, substr($s, $end, $next - $end))) { + return false; + } + } while (($pos = $next) < $end); + + return $i; + } + + private static function isAsciiBoundary($s, $i) + { + return 0 === $i || !isset($s[$i]) || ($s[$i - 1] < "\x80" && $s[$i] < "\x80" && ("\r" !== $s[$i - 1] || "\n" !== $s[$i])); } public static function grapheme_strrev(string $string) diff --git a/core/vendor/symfony/polyfill-mbstring/Mbstring.php b/core/vendor/symfony/polyfill-mbstring/Mbstring.php index bc9e6acfa4..5a59d26904 100644 --- a/core/vendor/symfony/polyfill-mbstring/Mbstring.php +++ b/core/vendor/symfony/polyfill-mbstring/Mbstring.php @@ -827,7 +827,10 @@ public static function mb_http_output($encoding = null) public static function mb_strwidth($s, $encoding = null) { $encoding = self::getEncoding($encoding); - $wideChars = '/[\x{1100}-\x{115F}\x{2329}\x{232A}\x{2E80}-\x{303E}\x{3040}-\x{A4CF}\x{AC00}-\x{D7A3}\x{F900}-\x{FAFF}\x{FE10}-\x{FE19}\x{FE30}-\x{FE6F}\x{FF00}-\x{FF60}\x{FFE0}-\x{FFE6}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}]/u'; + static $wideChars = null; + if (null === $wideChars) { + $wideChars = self::getData('eastAsianWidthRegexp'); + } if ('UTF-8' !== $encoding) { $s = self::iconv($encoding, 'UTF-8', $s); @@ -1374,7 +1377,12 @@ private static function html_encoding_callback(array $m) private static function title_case(array $s) { - return self::mb_convert_case($s[1], \MB_CASE_UPPER, 'UTF-8').self::mb_convert_case($s[2], \MB_CASE_LOWER, 'UTF-8'); + static $titleCase = null; + if (null === $titleCase) { + $titleCase = self::getData('titleCase'); + } + + return ($titleCase[$s[1]] ?? self::mb_convert_case($s[1], \MB_CASE_UPPER, 'UTF-8')).self::mb_convert_case($s[2], \MB_CASE_LOWER, 'UTF-8'); } private static function getData($file) @@ -1414,6 +1422,10 @@ private static function getEncoding($encoding) return 'UTF-16BE'; } + if ('UCS-2' === $encoding) { + return 'UCS-2BE'; + } + return $encoding; } diff --git a/core/vendor/symfony/polyfill-mbstring/Resources/unidata/eastAsianWidthRegexp.php b/core/vendor/symfony/polyfill-mbstring/Resources/unidata/eastAsianWidthRegexp.php new file mode 100644 index 0000000000..34d875e526 --- /dev/null +++ b/core/vendor/symfony/polyfill-mbstring/Resources/unidata/eastAsianWidthRegexp.php @@ -0,0 +1,5 @@ + 'Dž', + 'Dž' => 'Dž', + 'dž' => 'Dž', + 'LJ' => 'Lj', + 'Lj' => 'Lj', + 'lj' => 'Lj', + 'NJ' => 'Nj', + 'Nj' => 'Nj', + 'nj' => 'Nj', + 'DZ' => 'Dz', + 'Dz' => 'Dz', + 'dz' => 'Dz', + 'ა' => 'ა', + 'ბ' => 'ბ', + 'გ' => 'გ', + 'დ' => 'დ', + 'ე' => 'ე', + 'ვ' => 'ვ', + 'ზ' => 'ზ', + 'თ' => 'თ', + 'ი' => 'ი', + 'კ' => 'კ', + 'ლ' => 'ლ', + 'მ' => 'მ', + 'ნ' => 'ნ', + 'ო' => 'ო', + 'პ' => 'პ', + 'ჟ' => 'ჟ', + 'რ' => 'რ', + 'ს' => 'ს', + 'ტ' => 'ტ', + 'უ' => 'უ', + 'ფ' => 'ფ', + 'ქ' => 'ქ', + 'ღ' => 'ღ', + 'ყ' => 'ყ', + 'შ' => 'შ', + 'ჩ' => 'ჩ', + 'ც' => 'ც', + 'ძ' => 'ძ', + 'წ' => 'წ', + 'ჭ' => 'ჭ', + 'ხ' => 'ხ', + 'ჯ' => 'ჯ', + 'ჰ' => 'ჰ', + 'ჱ' => 'ჱ', + 'ჲ' => 'ჲ', + 'ჳ' => 'ჳ', + 'ჴ' => 'ჴ', + 'ჵ' => 'ჵ', + 'ჶ' => 'ჶ', + 'ჷ' => 'ჷ', + 'ჸ' => 'ჸ', + 'ჹ' => 'ჹ', + 'ჺ' => 'ჺ', + 'ჽ' => 'ჽ', + 'ჾ' => 'ჾ', + 'ჿ' => 'ჿ', + 'ß' => 'Ss', + 'ff' => 'Ff', + 'fi' => 'Fi', + 'fl' => 'Fl', + 'ffi' => 'Ffi', + 'ffl' => 'Ffl', + 'ſt' => 'St', + 'st' => 'St', + 'և' => 'Եւ', + 'ﬓ' => 'Մն', + 'ﬔ' => 'Մե', + 'ﬕ' => 'Մի', + 'ﬖ' => 'Վն', + 'ﬗ' => 'Մխ', + 'ᾀ' => 'ᾈ', + 'ᾁ' => 'ᾉ', + 'ᾂ' => 'ᾊ', + 'ᾃ' => 'ᾋ', + 'ᾄ' => 'ᾌ', + 'ᾅ' => 'ᾍ', + 'ᾆ' => 'ᾎ', + 'ᾇ' => 'ᾏ', + 'ᾈ' => 'ᾈ', + 'ᾉ' => 'ᾉ', + 'ᾊ' => 'ᾊ', + 'ᾋ' => 'ᾋ', + 'ᾌ' => 'ᾌ', + 'ᾍ' => 'ᾍ', + 'ᾎ' => 'ᾎ', + 'ᾏ' => 'ᾏ', + 'ᾐ' => 'ᾘ', + 'ᾑ' => 'ᾙ', + 'ᾒ' => 'ᾚ', + 'ᾓ' => 'ᾛ', + 'ᾔ' => 'ᾜ', + 'ᾕ' => 'ᾝ', + 'ᾖ' => 'ᾞ', + 'ᾗ' => 'ᾟ', + 'ᾘ' => 'ᾘ', + 'ᾙ' => 'ᾙ', + 'ᾚ' => 'ᾚ', + 'ᾛ' => 'ᾛ', + 'ᾜ' => 'ᾜ', + 'ᾝ' => 'ᾝ', + 'ᾞ' => 'ᾞ', + 'ᾟ' => 'ᾟ', + 'ᾠ' => 'ᾨ', + 'ᾡ' => 'ᾩ', + 'ᾢ' => 'ᾪ', + 'ᾣ' => 'ᾫ', + 'ᾤ' => 'ᾬ', + 'ᾥ' => 'ᾭ', + 'ᾦ' => 'ᾮ', + 'ᾧ' => 'ᾯ', + 'ᾨ' => 'ᾨ', + 'ᾩ' => 'ᾩ', + 'ᾪ' => 'ᾪ', + 'ᾫ' => 'ᾫ', + 'ᾬ' => 'ᾬ', + 'ᾭ' => 'ᾭ', + 'ᾮ' => 'ᾮ', + 'ᾯ' => 'ᾯ', + 'ᾳ' => 'ᾼ', + 'ᾼ' => 'ᾼ', + 'ῃ' => 'ῌ', + 'ῌ' => 'ῌ', + 'ῳ' => 'ῼ', + 'ῼ' => 'ῼ', + 'ᾲ' => 'Ὰͅ', + 'ᾴ' => 'Άͅ', + 'ῂ' => 'Ὴͅ', + 'ῄ' => 'Ήͅ', + 'ῲ' => 'Ὼͅ', + 'ῴ' => 'Ώͅ', + 'ᾷ' => 'ᾼ͂', + 'ῇ' => 'ῌ͂', + 'ῷ' => 'ῼ͂', +); diff --git a/core/vendor/symfony/process/PhpProcess.php b/core/vendor/symfony/process/PhpProcess.php index 930f591f0d..97081c17aa 100644 --- a/core/vendor/symfony/process/PhpProcess.php +++ b/core/vendor/symfony/process/PhpProcess.php @@ -42,7 +42,12 @@ public function __construct(string $script, ?string $cwd = null, ?array $env = n if ('phpdbg' === \PHP_SAPI) { $file = tempnam(sys_get_temp_dir(), 'dbg'); file_put_contents($file, $script); - register_shutdown_function('unlink', $file); + $pid = getmypid(); + register_shutdown_function(static function () use ($file, $pid) { + if ($pid === getmypid()) { + unlink($file); + } + }); $php[] = $file; $script = null; } diff --git a/core/vendor/symfony/process/PhpSubprocess.php b/core/vendor/symfony/process/PhpSubprocess.php index 8282f93cd4..11e3c3ffab 100644 --- a/core/vendor/symfony/process/PhpSubprocess.php +++ b/core/vendor/symfony/process/PhpSubprocess.php @@ -66,7 +66,12 @@ public function __construct(array $command, ?string $cwd = null, ?array $env = n $tmpIni = $this->writeTmpIni($this->getAllIniFiles(), sys_get_temp_dir()); $php = array_merge($php, ['-n', '-c', $tmpIni]); - register_shutdown_function('unlink', $tmpIni); + $pid = getmypid(); + register_shutdown_function(static function () use ($tmpIni, $pid) { + if ($pid === getmypid()) { + unlink($tmpIni); + } + }); $command = array_merge($php, $command); diff --git a/core/vendor/symfony/routing/Loader/schema/routing.schema.json b/core/vendor/symfony/routing/Loader/schema/routing.schema.json index d3695af4c5..bf4909b2cc 100644 --- a/core/vendor/symfony/routing/Loader/schema/routing.schema.json +++ b/core/vendor/symfony/routing/Loader/schema/routing.schema.json @@ -62,17 +62,7 @@ "locale": { "type": ["string", "null"] }, "format": { "type": ["string", "null"] }, "utf8": { "type": ["boolean", "null"] }, - "stateless": { "type": ["boolean", "null"] }, - "deprecated": { - "type": "object", - "properties": { - "package": { "type": "string" }, - "version": { "type": "string" }, - "message": { "type": "string" } - }, - "required": ["package", "version"], - "additionalProperties": false - } + "stateless": { "type": ["boolean", "null"] } }, "required": ["path"], "additionalProperties": false diff --git a/core/vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherDumper.php b/core/vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherDumper.php index 4060e73bea..d79e1ba2df 100644 --- a/core/vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherDumper.php +++ b/core/vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherDumper.php @@ -82,7 +82,7 @@ public function getCompiledRoutes(bool $forDump = false): array [$staticRoutes, $dynamicRoutes] = $this->groupStaticRoutes($routes); - $conditions = [null]; + $conditions = ['' => null]; $compiledRoutes[] = $this->compileStaticRoutes($staticRoutes, $conditions); $chunkLimit = \count($dynamicRoutes); @@ -104,7 +104,7 @@ public function getCompiledRoutes(bool $forDump = false): array if ($forDump) { $compiledRoutes[2] = $compiledRoutes[4]; } - unset($conditions[0]); + unset($conditions['']); if ($conditions) { foreach ($conditions as $expression => $condition) { @@ -427,7 +427,7 @@ private function compileRoute(Route $route, string $name, string|array|null $var unset($defaults['_canonical_route']); } - if ($condition = $route->getCondition()) { + if ('' !== $condition = $route->getCondition()) { $condition = $this->getExpressionLanguage()->compile($condition, ['context', 'request', 'params']); $condition = $conditions[$condition] ??= (str_contains($condition, '$request') ? 1 : -1) * \count($conditions); } else { diff --git a/core/vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php b/core/vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php index 5177c269ae..2105473b7c 100644 --- a/core/vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php +++ b/core/vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php @@ -34,8 +34,8 @@ trait CompiledUrlMatcherTrait public function match(string $pathinfo): array { - $allow = $allowSchemes = []; - if ($ret = $this->doMatch($pathinfo, $allow, $allowSchemes)) { + $allow = $allowSchemes = $trailingSlashSchemes = []; + if ($ret = $this->doMatch($pathinfo, $allow, $allowSchemes, $trailingSlashSchemes)) { return $ret; } if ($allow) { @@ -58,11 +58,17 @@ public function match(string $pathinfo): array $this->context->setScheme($scheme); } } elseif ('' !== $trimmedPathinfo = rtrim($pathinfo, '/')) { + $fallbackSchemes = $trailingSlashSchemes; + $fallbackPathinfo = $pathinfo; $pathinfo = $trimmedPathinfo === $pathinfo ? $pathinfo.'/' : $trimmedPathinfo; - if ($ret = $this->doMatch($pathinfo, $allow, $allowSchemes)) { + if ($ret = $this->doMatch($pathinfo, $allow, $allowSchemes, $trailingSlashSchemes)) { return $this->redirect($pathinfo, $ret['_route']) + $ret; } - if ($allowSchemes) { + if ($allowSchemes = $allowSchemes ?: $trailingSlashSchemes) { + goto redirect_scheme; + } + if ($allowSchemes = $fallbackSchemes) { + $pathinfo = $fallbackPathinfo; goto redirect_scheme; } } @@ -70,9 +76,9 @@ public function match(string $pathinfo): array throw new ResourceNotFoundException(\sprintf('No routes found for "%s".', $pathinfo)); } - private function doMatch(string $pathinfo, array &$allow = [], array &$allowSchemes = []): array + private function doMatch(string $pathinfo, array &$allow = [], array &$allowSchemes = [], array &$trailingSlashSchemes = []): array { - $allow = $allowSchemes = []; + $allow = $allowSchemes = $trailingSlashSchemes = []; $pathinfo = '' === ($pathinfo = rawurldecode($pathinfo)) ? '/' : $pathinfo; $trimmedPathinfo = '' === ($trimmedPathinfo = rtrim($pathinfo, '/')) ? '/' : $trimmedPathinfo; $context = $this->context; @@ -104,6 +110,8 @@ private function doMatch(string $pathinfo, array &$allow = [], array &$allowSche if ('/' !== $pathinfo && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) { if ($supportsRedirections && (!$requiredMethods || isset($requiredMethods['GET']))) { + $trailingSlashSchemes = $allowSchemes; + return $allow = $allowSchemes = []; } continue; @@ -154,6 +162,8 @@ private function doMatch(string $pathinfo, array &$allow = [], array &$allowSche if ('/' !== $pathinfo && !$hasTrailingVar && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) { if ($supportsRedirections && (!$requiredMethods || isset($requiredMethods['GET']))) { + $trailingSlashSchemes = $allowSchemes; + return $allow = $allowSchemes = []; } continue; diff --git a/core/vendor/symfony/routing/Matcher/RedirectableUrlMatcher.php b/core/vendor/symfony/routing/Matcher/RedirectableUrlMatcher.php index 3e7b78b5e6..47478d68f8 100644 --- a/core/vendor/symfony/routing/Matcher/RedirectableUrlMatcher.php +++ b/core/vendor/symfony/routing/Matcher/RedirectableUrlMatcher.php @@ -44,6 +44,8 @@ public function match(string $pathinfo): array } elseif ('' === $trimmedPathinfo = rtrim($pathinfo, '/')) { throw $e; } else { + $fallbackSchemes = $this->trailingSlashSchemes; + $fallbackPathinfo = $pathinfo; try { $pathinfo = $trimmedPathinfo === $pathinfo ? $pathinfo.'/' : $trimmedPathinfo; $ret = parent::match($pathinfo); @@ -53,6 +55,13 @@ public function match(string $pathinfo): array if ($this->allowSchemes) { goto redirect_scheme; } + if ($this->allowSchemes = $this->trailingSlashSchemes) { + goto redirect_scheme; + } + if ($this->allowSchemes = $fallbackSchemes) { + $pathinfo = $fallbackPathinfo; + goto redirect_scheme; + } throw $e; } } diff --git a/core/vendor/symfony/routing/Matcher/TraceableUrlMatcher.php b/core/vendor/symfony/routing/Matcher/TraceableUrlMatcher.php index 5729df225a..6309f18a9c 100644 --- a/core/vendor/symfony/routing/Matcher/TraceableUrlMatcher.php +++ b/core/vendor/symfony/routing/Matcher/TraceableUrlMatcher.php @@ -53,7 +53,7 @@ public function getTracesForRequest(Request $request): array protected function matchCollection(string $pathinfo, RouteCollection $routes): array { // HEAD and GET are equivalent as per RFC - if ('HEAD' === $method = $this->context->getMethod()) { + if ('HEAD' === $method = $requestMethod = $this->context->getMethod()) { $method = 'GET'; } $supportsTrailingSlash = 'GET' === $method && $this instanceof RedirectableUrlMatcherInterface; @@ -127,6 +127,7 @@ protected function matchCollection(string $pathinfo, RouteCollection $routes): a if ('/' !== $pathinfo && !$hasTrailingVar && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) { if ($supportsTrailingSlash && (!$requiredMethods || \in_array('GET', $requiredMethods, true))) { $this->addTrace('Route matches!', self::ROUTE_MATCHES, $name, $route); + $this->trailingSlashSchemes = $this->allowSchemes; return $this->allow = $this->allowSchemes = []; } @@ -140,7 +141,7 @@ protected function matchCollection(string $pathinfo, RouteCollection $routes): a continue; } - if ($requiredMethods && !\in_array($method, $requiredMethods, true)) { + if ($requiredMethods && !\in_array($method, $requiredMethods, true) && !\in_array($requestMethod, $requiredMethods, true)) { $this->allow = array_merge($this->allow, $requiredMethods); $this->addTrace(\sprintf('Method "%s" does not match any of the required methods (%s)', $this->context->getMethod(), implode(', ', $requiredMethods)), self::ROUTE_ALMOST_MATCHES, $name, $route); continue; diff --git a/core/vendor/symfony/routing/Matcher/UrlMatcher.php b/core/vendor/symfony/routing/Matcher/UrlMatcher.php index 8fde37685b..f5dafd5db1 100644 --- a/core/vendor/symfony/routing/Matcher/UrlMatcher.php +++ b/core/vendor/symfony/routing/Matcher/UrlMatcher.php @@ -43,6 +43,14 @@ class UrlMatcher implements UrlMatcherInterface, RequestMatcherInterface * @internal */ protected array $allowSchemes = []; + + /** + * Collects URI schemes that would be allowed for the request before a trailing slash redirection took precedence. + * + * @internal + */ + protected array $trailingSlashSchemes = []; + protected ?Request $request = null; protected ExpressionLanguage $expressionLanguage; @@ -69,7 +77,7 @@ public function getContext(): RequestContext public function match(string $pathinfo): array { - $this->allow = $this->allowSchemes = []; + $this->allow = $this->allowSchemes = $this->trailingSlashSchemes = []; $pathinfo = '' === ($pathinfo = rawurldecode($pathinfo)) ? '/' : $pathinfo; if ($ret = $this->matchCollection($pathinfo, $this->routes)) { @@ -114,7 +122,7 @@ public function addExpressionLanguageProvider(ExpressionFunctionProviderInterfac protected function matchCollection(string $pathinfo, RouteCollection $routes): array { // HEAD and GET are equivalent as per RFC - if ('HEAD' === $method = $this->context->getMethod()) { + if ('HEAD' === $method = $requestMethod = $this->context->getMethod()) { $method = 'GET'; } $supportsTrailingSlash = 'GET' === $method && $this instanceof RedirectableUrlMatcherInterface; @@ -164,6 +172,8 @@ protected function matchCollection(string $pathinfo, RouteCollection $routes): a if ('/' !== $pathinfo && !$hasTrailingVar && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) { if ($supportsTrailingSlash && (!$requiredMethods || \in_array('GET', $requiredMethods, true))) { + $this->trailingSlashSchemes = $this->allowSchemes; + return $this->allow = $this->allowSchemes = []; } continue; @@ -174,7 +184,7 @@ protected function matchCollection(string $pathinfo, RouteCollection $routes): a continue; } - if ($requiredMethods && !\in_array($method, $requiredMethods, true)) { + if ($requiredMethods && !\in_array($method, $requiredMethods, true) && !\in_array($requestMethod, $requiredMethods, true)) { $this->allow = array_merge($this->allow, $requiredMethods); continue; } @@ -216,7 +226,7 @@ protected function getAttributes(Route $route, string $name, array $attributes): protected function handleRouteRequirements(string $pathinfo, string $name, Route $route, array $routeParameters): array { // expression condition - if ($route->getCondition() && !$this->getExpressionLanguage()->evaluate($route->getCondition(), [ + if ('' !== $route->getCondition() && !$this->getExpressionLanguage()->evaluate($route->getCondition(), [ 'context' => $this->context, 'request' => $this->request ?: $this->createRequest($pathinfo), 'params' => $routeParameters, diff --git a/core/vendor/symfony/string/AbstractUnicodeString.php b/core/vendor/symfony/string/AbstractUnicodeString.php index 7c59fe9ec9..c6f7135641 100644 --- a/core/vendor/symfony/string/AbstractUnicodeString.php +++ b/core/vendor/symfony/string/AbstractUnicodeString.php @@ -405,10 +405,16 @@ public function localeTitle(string $locale): static public function trim(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): static { - if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" !== $chars && !preg_match('//u', $chars)) { + if ('' === $chars) { + return clone $this; + } + if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" === $chars) { + $chars = preg_quote($chars); + } elseif (false === $ranges = preg_match('/\.\./u', $chars)) { throw new InvalidArgumentException('Invalid UTF-8 chars.'); + } else { + $chars = $ranges ? self::quoteCharRanges($chars) : preg_quote($chars); } - $chars = preg_quote($chars); $str = clone $this; $str->string = preg_replace("{^[$chars]++|[$chars]++$}uD", '', $str->string); @@ -418,10 +424,16 @@ public function trim(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): static public function trimEnd(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): static { - if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" !== $chars && !preg_match('//u', $chars)) { + if ('' === $chars) { + return clone $this; + } + if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" === $chars) { + $chars = preg_quote($chars); + } elseif (false === $ranges = preg_match('/\.\./u', $chars)) { throw new InvalidArgumentException('Invalid UTF-8 chars.'); + } else { + $chars = $ranges ? self::quoteCharRanges($chars) : preg_quote($chars); } - $chars = preg_quote($chars); $str = clone $this; $str->string = preg_replace("{[$chars]++$}uD", '', $str->string); @@ -451,10 +463,16 @@ public function trimPrefix($prefix): static public function trimStart(string $chars = " \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}"): static { - if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" !== $chars && !preg_match('//u', $chars)) { + if ('' === $chars) { + return clone $this; + } + if (" \t\n\r\0\x0B\x0C\u{A0}\u{FEFF}" === $chars) { + $chars = preg_quote($chars); + } elseif (false === $ranges = preg_match('/\.\./u', $chars)) { throw new InvalidArgumentException('Invalid UTF-8 chars.'); + } else { + $chars = $ranges ? self::quoteCharRanges($chars) : preg_quote($chars); } - $chars = preg_quote($chars); $str = clone $this; $str->string = preg_replace("{^[$chars]++}uD", '', $str->string); @@ -544,6 +562,27 @@ public function width(bool $ignoreAnsiDecoration = true): int return $width; } + /** + * Quotes $chars for a character class, reading "x..y" as a range of code points like trim() does with bytes. + */ + private static function quoteCharRanges(string $chars): string + { + $chars = preg_split('//u', $chars, -1, \PREG_SPLIT_NO_EMPTY); + $n = \count($chars); + $class = ''; + + for ($i = 0; $i < $n; ++$i) { + if ($i + 3 < $n && '.' === $chars[$i + 1] && '.' === $chars[$i + 2] && ($from = mb_ord($chars[$i], 'UTF-8')) <= ($to = mb_ord($chars[$i + 3], 'UTF-8'))) { + $class .= \sprintf('\x{%X}-\x{%X}', $from, $to); + $i += 3; + } elseif ('.' !== $chars[$i] || '.' !== ($chars[$i + 1] ?? '')) { + $class .= preg_quote($chars[$i]); + } + } + + return $class; + } + private function pad(int $len, self $pad, int $type): static { $sLen = $this->length(); @@ -594,7 +633,9 @@ private function wcswidth(string $string): int if ($joined) { $joined = false; - continue; + if ("\x7F" < $c[0] && preg_match(self::EMOJI_REGEX, $c)) { + continue; + } } $codePoint = mb_ord($c, 'UTF-8'); diff --git a/core/vendor/symfony/string/CodePointString.php b/core/vendor/symfony/string/CodePointString.php index f699168e4d..97e4520fc9 100644 --- a/core/vendor/symfony/string/CodePointString.php +++ b/core/vendor/symfony/string/CodePointString.php @@ -55,6 +55,10 @@ public function chunk(int $length = 1): array return []; } + if (\strlen($this->string) <= $length) { + return [clone $this]; + } + $rx = '/('; while (65535 < $length) { $rx .= '.{65535}'; @@ -197,7 +201,7 @@ public function replace(string $from, string $to): static public function slice(int $start = 0, ?int $length = null): static { $str = clone $this; - $str->string = mb_substr($this->string, $start, $length, 'UTF-8'); + $str->string = mb_substr($this->string, max(-\PHP_INT_MAX, $start), max(-\PHP_INT_MAX, $length ?? \PHP_INT_MAX), 'UTF-8'); return $str; } @@ -209,8 +213,8 @@ public function splice(string $replacement, int $start = 0, ?int $length = null) } $str = clone $this; - $start = $start ? \strlen(mb_substr($this->string, 0, $start, 'UTF-8')) : 0; - $length = $length ? \strlen(mb_substr(substr($this->string, $start), 0, $length, 'UTF-8')) : $length; + $start = $start ? \strlen(mb_substr($this->string, 0, max(-\PHP_INT_MAX, $start), 'UTF-8')) : 0; + $length = $length ? \strlen(mb_substr(substr($this->string, $start), 0, max(-\PHP_INT_MAX, $length), 'UTF-8')) : $length; $str->string = substr_replace($this->string, $replacement, $start, $length ?? \PHP_INT_MAX); return $str; diff --git a/core/vendor/symfony/string/Slugger/AsciiSlugger.php b/core/vendor/symfony/string/Slugger/AsciiSlugger.php index 689a404788..e15497c5f6 100644 --- a/core/vendor/symfony/string/Slugger/AsciiSlugger.php +++ b/core/vendor/symfony/string/Slugger/AsciiSlugger.php @@ -63,7 +63,7 @@ class AsciiSlugger implements SluggerInterface, LocaleAwareInterface /** * Cache of transliterators per locale. * - * @var \Transliterator[] + * @var (\Transliterator|null)[] */ private array $transliterators = []; @@ -106,7 +106,7 @@ public function slug(string $string, string $separator = '-', ?string $locale = $locale ??= $this->defaultLocale; $transliterator = []; - if ($locale && ('de' === $locale || str_starts_with($locale, 'de_'))) { + if ($locale && ('de' === $locale || str_starts_with($locale, 'de_') || str_starts_with($locale, 'de-'))) { // Use the shortcut for German in UnicodeString::ascii() if possible (faster and no requirement on intl) $transliterator = ['de-ASCII']; } elseif (\function_exists('transliterator_transliterate') && $locale) { @@ -151,6 +151,8 @@ public function slug(string $string, string $separator = '-', ?string $locale = private function createTransliterator(string $locale): ?\Transliterator { + $locale = str_replace('-', '_', $locale); + if (\array_key_exists($locale, $this->transliterators)) { return $this->transliterators[$locale]; } @@ -167,10 +169,10 @@ private function createTransliterator(string $locale): ?\Transliterator // Try to use the parent locale (ie. try "de" for "de_AT") and cache both locales if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$parent] ?? null) { - $transliterator = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id); + return $this->transliterators[$locale] = $this->transliterators[$parent] = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id); } - return $this->transliterators[$locale] = $this->transliterators[$parent] = $transliterator ?? null; + return $this->transliterators[$locale] = null; } private function createEmojiTransliterator(?string $locale): ?EmojiTransliterator @@ -181,6 +183,8 @@ private function createEmojiTransliterator(?string $locale): ?EmojiTransliterato return null; } + $locale = null !== $locale ? str_replace('-', '_', $locale) : null; + while (null !== $locale) { try { return EmojiTransliterator::create("emoji-$locale"); @@ -197,11 +201,11 @@ private static function getParentLocale(?string $locale): ?string if (!$locale) { return null; } - if (false === $str = strrchr($locale, '_')) { + if (false === $i = strrpos(str_replace('-', '_', $locale), '_')) { // no parent locale return null; } - return substr($locale, 0, -\strlen($str)); + return substr($locale, 0, $i); } } diff --git a/core/vendor/symfony/string/UnicodeString.php b/core/vendor/symfony/string/UnicodeString.php index ca3499ffea..d23833b048 100644 --- a/core/vendor/symfony/string/UnicodeString.php +++ b/core/vendor/symfony/string/UnicodeString.php @@ -73,6 +73,10 @@ public function chunk(int $length = 1): array return []; } + if (\strlen($this->string) <= $length) { + return [clone $this]; + } + $rx = '/('; while (65535 < $length) { $rx .= '\X{65535}'; @@ -179,7 +183,7 @@ public function indexOfLast(string|iterable|AbstractString $needle, int $offset if (0 > $offset) { // workaround https://bugs.php.net/74264 if (0 > $offset += grapheme_strlen($needle)) { - $string = grapheme_substr($string, 0, $offset); + $string = grapheme_substr($string, 0, max(-2147483648, $offset)); } $offset = 0; } @@ -248,12 +252,10 @@ public function replace(string $from, string $to): static if ('' !== $from && false !== $from) { $tail = $str->string; $result = ''; - $indexOf = $this->ignoreCase ? 'grapheme_stripos' : 'grapheme_strpos'; - while ('' !== $tail && false !== $i = $indexOf($tail, $from)) { - $slice = grapheme_substr($tail, 0, $i); - $result .= $slice.$to; - $tail = substr($tail, \strlen($slice) + \strlen($from)); + while ('' !== $tail && false !== $i = $this->search($tail, $from, $length)) { + $result .= substr($tail, 0, $i).$to; + $tail = substr($tail, $i + $length); } $str->string = $result.$tail; @@ -284,7 +286,10 @@ public function slice(int $start = 0, ?int $length = null): static { $str = clone $this; - $str->string = (string) grapheme_substr($this->string, $start, $length ?? 2147483647); + // grapheme_substr() accepts only 32-bit offsets and lengths + $start = max(-2147483648, min($start, 2147483647)); + $length = max(-2147483648, min($length ?? 2147483647, 2147483647)); + $str->string = (string) grapheme_substr($this->string, $start, $length); return $str; } @@ -293,8 +298,8 @@ public function splice(string $replacement, int $start = 0, ?int $length = null) { $str = clone $this; - $start = $start ? \strlen(grapheme_substr($this->string, 0, $start)) : 0; - $length = $length ? \strlen(grapheme_substr(substr($this->string, $start), 0, $length)) : $length; + $start = $start ? \strlen(grapheme_substr($this->string, 0, max(-2147483648, min($start, 2147483647)))) : 0; + $length = $length ? \strlen(grapheme_substr(substr($this->string, $start), 0, max(-2147483648, min($length, 2147483647)))) : $length; $str->string = substr_replace($this->string, $replacement, $start, $length ?? 2147483647); if (normalizer_is_normalized($str->string)) { @@ -333,12 +338,11 @@ public function split(string $delimiter, ?int $limit = null, ?int $flags = null) $str = clone $this; $tail = $this->string; $chunks = []; - $indexOf = $this->ignoreCase ? 'grapheme_stripos' : 'grapheme_strpos'; - while (1 < $limit && false !== $i = $indexOf($tail, $delimiter)) { - $str->string = grapheme_substr($tail, 0, $i); + while (1 < $limit && false !== $i = $this->search($tail, $delimiter, $length)) { + $str->string = substr($tail, 0, $i); $chunks[] = clone $str; - $tail = substr($tail, \strlen($str->string) + \strlen($delimiter)); + $tail = substr($tail, $i + $length); --$limit; } @@ -473,4 +477,49 @@ public function __clone() $this->ignoreCase = false; } + + /** + * Returns the byte offset of the first occurrence of $needle in $haystack and sets $length to its length in bytes. + */ + private function search(string $haystack, string $needle, ?int &$length = null): int|false + { + if (false === $i = $this->ignoreCase ? grapheme_stripos($haystack, $needle) : grapheme_strpos($haystack, $needle)) { + return false; + } + + $offset = \strlen(grapheme_substr($haystack, 0, $i)); + $length = \strlen($needle); + $match = substr($haystack, $offset, $length); + + // On non-ASCII strings, grapheme_strpos() ignores characters like U+200B. + // Its match can then differ from $needle, or start after the first characters of $needle. + // Without ext-intl, a match that starts inside a grapheme is reported at the next grapheme. + if (!$this->ignoreCase) { + if ($needle === $match) { + return $offset; + } + + $previous = $i ? \strlen(grapheme_substr($haystack, 0, $i - 1)) : 0; + + return strpos($haystack, $needle, max(0, min($offset + 1 - $length, $previous + 1))); + } + + if (0 === strcasecmp($needle, $match) || (preg_match('//u', $match) && ( + mb_convert_case($needle, \MB_CASE_FOLD_SIMPLE, 'UTF-8') === mb_convert_case($match, \MB_CASE_FOLD_SIMPLE, 'UTF-8') + || (0 === grapheme_stripos($needle, $match) && 0 === grapheme_stripos($match, $needle)) + ))) { + return $offset; + } + + $previous = $i ? mb_strlen(grapheme_substr($haystack, 0, $i - 1), 'UTF-8') : 0; + $i = mb_strlen(substr($haystack, 0, $offset), 'UTF-8'); + + if (false === $i = mb_stripos($haystack, $needle, max(0, min($i + 1 - mb_strlen($needle, 'UTF-8'), $previous + 1)), 'UTF-8')) { + return false; + } + + $length = \strlen(mb_substr($haystack, $i, mb_strlen($needle, 'UTF-8'), 'UTF-8')); + + return \strlen(mb_substr($haystack, 0, $i, 'UTF-8')); + } } diff --git a/core/vendor/symfony/translation/Command/XliffLintCommand.php b/core/vendor/symfony/translation/Command/XliffLintCommand.php index 30553821f6..45e2110ff0 100644 --- a/core/vendor/symfony/translation/Command/XliffLintCommand.php +++ b/core/vendor/symfony/translation/Command/XliffLintCommand.php @@ -133,11 +133,11 @@ private function validate(string $content, ?string $file = null): array // http://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#target-language $expectedFilenamePattern = $this->requireStrictFileNames ? \sprintf('/^.*\.(?i:%s)\.(?:xlf|xliff)/', $normalizedLocalePattern) : \sprintf('/^(?:.*\.(?i:%s)|(?i:%s)\..*)\.(?:xlf|xliff)/', $normalizedLocalePattern, $normalizedLocalePattern); - if (0 === preg_match($expectedFilenamePattern, basename($file))) { + if (null !== $file && 0 === preg_match($expectedFilenamePattern, basename($file))) { $errors[] = [ 'line' => -1, 'column' => -1, - 'message' => \sprintf('There is a mismatch between the language included in the file name ("%s") and the "%s" value used in the "target-language" attribute of the file.', basename($file), $targetLanguage), + 'message' => \sprintf('There is a mismatch between the language included in the file name ("%s") and the "%s" value used in the "%s" attribute of the file.', basename($file), $targetLanguage, str_starts_with($document->documentElement->getAttribute('version'), '2.') ? 'trgLang' : 'target-language'), ]; } } @@ -264,13 +264,14 @@ private function isReadable(string $fileOrDirectory): bool private function getTargetLanguageFromFile(\DOMDocument $xliffContents): ?string { - foreach ($xliffContents->getElementsByTagName('file')[0]->attributes ?? [] as $attribute) { - if ('target-language' === $attribute->nodeName) { - return $attribute->nodeValue; - } + $xliff = $xliffContents->documentElement; + if ('xliff' === $xliff?->localName && str_starts_with($xliff->getAttribute('version'), '2.')) { + return $xliff->getAttribute('trgLang') ?: null; } - return null; + $file = $xliffContents->getElementsByTagName('file')->item(0); + + return $file instanceof \DOMElement ? ($file->getAttribute('target-language') ?: null) : null; } public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void diff --git a/core/vendor/symfony/translation/Dumper/FileDumper.php b/core/vendor/symfony/translation/Dumper/FileDumper.php index 6fae26438c..91668421c4 100644 --- a/core/vendor/symfony/translation/Dumper/FileDumper.php +++ b/core/vendor/symfony/translation/Dumper/FileDumper.php @@ -49,7 +49,7 @@ public function dump(MessageCatalogue $messages, array $options = []): void $fullpath = $options['path'].'/'.$this->getRelativePath($domain, $messages->getLocale()); if (!file_exists($fullpath)) { $directory = \dirname($fullpath); - if (!file_exists($directory) && !@mkdir($directory, 0o777, true)) { + if (!file_exists($directory) && !@mkdir($directory, 0o777, true) && !is_dir($directory)) { throw new RuntimeException(\sprintf('Unable to create directory "%s".', $directory)); } } diff --git a/core/vendor/symfony/translation/Resources/data/parents.json b/core/vendor/symfony/translation/Resources/data/parents.json index 0197e451b3..ce82bc7704 100644 --- a/core/vendor/symfony/translation/Resources/data/parents.json +++ b/core/vendor/symfony/translation/Resources/data/parents.json @@ -136,7 +136,11 @@ "ff_Adlm": "root", "hi_Latn": "en_IN", "kk_Arab": "root", + "kok_Latn": "root", "ks_Deva": "root", + "kxv_Deva": "root", + "kxv_Orya": "root", + "kxv_Telu": "root", "nb": "no", "nn": "no", "pa_Arab": "root", @@ -151,9 +155,12 @@ "pt_ST": "pt_PT", "pt_TL": "pt_PT", "sd_Deva": "root", + "shi_Latn": "root", "sr_Latn": "root", "uz_Arab": "root", "uz_Cyrl": "root", + "vai_Latn": "root", + "yue_Hans": "root", "zh_Hant": "root", "zh_Hant_MO": "zh_Hant_HK" } diff --git a/core/vendor/symfony/translation/Util/XliffUtils.php b/core/vendor/symfony/translation/Util/XliffUtils.php index 1ce8f185ae..88613be6fc 100644 --- a/core/vendor/symfony/translation/Util/XliffUtils.php +++ b/core/vendor/symfony/translation/Util/XliffUtils.php @@ -108,8 +108,11 @@ private static function shouldEnableEntityLoader(): bool $dom->loadXML(''); $tmpfile = tempnam(sys_get_temp_dir(), 'symfony'); - register_shutdown_function(static function () use ($tmpfile) { - @unlink($tmpfile); + $pid = getmypid(); + register_shutdown_function(static function () use ($tmpfile, $pid) { + if (getmypid() === $pid) { + @unlink($tmpfile); + } }); $schema = ' @@ -167,8 +170,11 @@ private static function fixXmlLocation(string $schemaSource, string $xmlUri): st $newPath = self::getFileUrl($path); } elseif ($tmpfile = tempnam(sys_get_temp_dir(), 'symfony')) { copy($path, $tmpfile); - register_shutdown_function(static function () use ($tmpfile) { - @unlink($tmpfile); + $pid = getmypid(); + register_shutdown_function(static function () use ($tmpfile, $pid) { + if (getmypid() === $pid) { + @unlink($tmpfile); + } }); $newPath = self::getFileUrl($tmpfile); } else { diff --git a/core/vendor/symfony/var-dumper/Caster/ExceptionCaster.php b/core/vendor/symfony/var-dumper/Caster/ExceptionCaster.php index 9726a99edc..1111a446df 100644 --- a/core/vendor/symfony/var-dumper/Caster/ExceptionCaster.php +++ b/core/vendor/symfony/var-dumper/Caster/ExceptionCaster.php @@ -322,7 +322,7 @@ private static function traceUnshift(array &$trace, ?string $class, string $file private static function extractSource(string $srcLines, int $line, int $srcContext, string $lang, ?string $file, array $frame): EnumStub { - $srcLines = explode("\n", $srcLines); + $srcLines = explode("\n", str_replace(["\r\n", "\r"], "\n", $srcLines)); $src = []; for ($i = $line - 1 - $srcContext; $i <= $line - 1 + $srcContext; ++$i) { @@ -359,7 +359,7 @@ private static function extractSource(string $srcLines, int $line, int $srcConte do { $pad = null; for ($i = $srcContext << 1; $i >= 0; --$i) { - if (isset($src[$i][$ltrim]) && "\r" !== ($c = $src[$i][$ltrim]) && "\n" !== $c) { + if (isset($src[$i][$ltrim]) && "\n" !== ($c = $src[$i][$ltrim])) { $pad ??= $c; if ((' ' !== $c && "\t" !== $c) || $pad !== $c) { break; @@ -373,7 +373,7 @@ private static function extractSource(string $srcLines, int $line, int $srcConte foreach ($src as $i => $c) { if ($ltrim) { - $c = isset($c[$ltrim]) && "\r" !== $c[$ltrim] ? substr($c, $ltrim) : ltrim($c, " \t"); + $c = isset($c[$ltrim]) ? substr($c, $ltrim) : ltrim($c, " \t"); } $c = substr($c, 0, -1); if ($i !== $srcContext) { diff --git a/core/vendor/symfony/var-dumper/Cloner/Stub.php b/core/vendor/symfony/var-dumper/Cloner/Stub.php index 18577f4b47..81faac1a92 100644 --- a/core/vendor/symfony/var-dumper/Cloner/Stub.php +++ b/core/vendor/symfony/var-dumper/Cloner/Stub.php @@ -14,6 +14,8 @@ /** * Represents the main properties of a PHP variable. * + * Stub classes meant to be extended should not declare private properties: they are not serialized with child classes. + * * @author Nicolas Grekas */ class Stub diff --git a/core/vendor/symfony/var-dumper/Dumper/ContextProvider/SourceContextProvider.php b/core/vendor/symfony/var-dumper/Dumper/ContextProvider/SourceContextProvider.php index 2beaea684d..a3b2fabf59 100644 --- a/core/vendor/symfony/var-dumper/Dumper/ContextProvider/SourceContextProvider.php +++ b/core/vendor/symfony/var-dumper/Dumper/ContextProvider/SourceContextProvider.php @@ -66,7 +66,7 @@ public function getContext(): ?array $file = method_exists($template, 'getSourceContext') ? $template->getSourceContext()->getPath() : null; if ($src) { - $src = explode("\n", $src); + $src = explode("\n", str_replace(["\r\n", "\r"], "\n", $src)); $fileExcerpt = []; for ($i = max($line - 3, 1), $max = min($line + 3, \count($src)); $i <= $max; ++$i) { diff --git a/core/vendor/tracy/tracy/composer.json b/core/vendor/tracy/tracy/composer.json index a4f2e7530e..d46107b08a 100644 --- a/core/vendor/tracy/tracy/composer.json +++ b/core/vendor/tracy/tracy/composer.json @@ -1,21 +1,17 @@ { "name": "tracy/tracy", - "description": "😎 Tracy: the addictive tool to ease debugging PHP code for cool developers. Friendly design, logging, profiler, advanced features like debugging AJAX calls or CLI support. You will love it.", - "keywords": ["debug", "debugger", "nette", "profiler", "xdebug"], + "description": "😎 Tracy: the addictive PHP debugger. Clear error pages with full context, a debug bar, readable dumps, AJAX and CLI support, and logging with e-mail alerts in production.", + "keywords": ["tracy", "nette", "debugger", "debugging", "debug bar", "error handler", "dump", "var_dump", "logging", "whoops"], "homepage": "https://tracy.nette.org", "license": ["BSD-3-Clause"], "authors": [ { "name": "David Grudl", "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" } ], "require": { - "php": "8.2 - 8.5", + "php": "8.2 - 8.6", "ext-session": "*", "ext-json": "*" }, @@ -27,7 +23,7 @@ "nette/tester": "^2.6", "latte/latte": "^2.5 || ^3.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "phpstan/phpstan": "^2.1@stable", + "phpstan/phpstan": "^2.3@stable", "phpstan/extension-installer": "^1.4@stable", "nette/phpstan-rules": "^1.0" }, diff --git a/core/vendor/tracy/tracy/readme.md b/core/vendor/tracy/tracy/readme.md index 2298b2d432..30b787200f 100644 --- a/core/vendor/tracy/tracy/readme.md +++ b/core/vendor/tracy/tracy/readme.md @@ -48,7 +48,7 @@ composer require tracy/tracy Alternatively, you can download the whole package or [tracy.phar](https://github.com/nette/tracy/releases) file. -Tracy is compatible with PHP 8.2 to 8.5. +Tracy is compatible with PHP 8.2 to 8.6.   diff --git a/core/vendor/tracy/tracy/src/Bridges/Nette/Bridge.php b/core/vendor/tracy/tracy/src/Bridges/Nette/Bridge.php index 31108464ea..d8de7b9720 100644 --- a/core/vendor/tracy/tracy/src/Bridges/Nette/Bridge.php +++ b/core/vendor/tracy/tracy/src/Bridges/Nette/Bridge.php @@ -9,9 +9,7 @@ use Nette; use Tracy; -use Tracy\BlueScreen; -use Tracy\Helpers; -use const ENT_IGNORE; +use Tracy\{BlueScreen, Helpers}; /** diff --git a/core/vendor/tracy/tracy/src/Bridges/Psr/PsrToTracyLoggerAdapter.php b/core/vendor/tracy/tracy/src/Bridges/Psr/PsrToTracyLoggerAdapter.php index d6d19c3c1d..b6ae661e94 100644 --- a/core/vendor/tracy/tracy/src/Bridges/Psr/PsrToTracyLoggerAdapter.php +++ b/core/vendor/tracy/tracy/src/Bridges/Psr/PsrToTracyLoggerAdapter.php @@ -37,7 +37,14 @@ public function __construct( public function log(mixed $value, string $level = self::INFO): void { if ($value instanceof \Throwable) { - $message = get_debug_type($value) . ': ' . $value->getMessage() . ($value->getCode() ? ' #' . $value->getCode() : '') . ' in ' . $value->getFile() . ':' . $value->getLine(); + $message = get_debug_type($value) + . ': ' + . $value->getMessage() + . ($value->getCode() ? ' #' . $value->getCode() : '') + . ' in ' + . $value->getFile() + . ':' + . $value->getLine(); $context = ['exception' => $value]; } elseif (!is_string($value)) { diff --git a/core/vendor/tracy/tracy/src/Tracy/Bar/Bar.php b/core/vendor/tracy/tracy/src/Tracy/Bar/Bar.php index c85df6c745..aab89d6d1e 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Bar/Bar.php +++ b/core/vendor/tracy/tracy/src/Tracy/Bar/Bar.php @@ -65,6 +65,7 @@ public function renderLoader(DeferredContent $defer): void /** * Renders debug bar. + * @internal */ public function render(DeferredContent $defer): void { @@ -89,7 +90,10 @@ public function render(DeferredContent $defer): void } } elseif (Helpers::isHtmlMode()) { if (preg_match('#^Content-Length:#im', implode("\n", headers_list()))) { - Debugger::log(new \LogicException('Tracy cannot display the Bar because the Content-Length header is being sent'), Debugger::EXCEPTION); + Debugger::log( + new \LogicException('Tracy cannot display the Bar because the Content-Length header is being sent'), + Debugger::EXCEPTION, + ); } $content = $this->renderPartial('main'); @@ -180,10 +184,11 @@ private function renderPanels(string $suffix = ''): array /** * Captures debug bar as plain text (markdown) for AI agents. + * @internal */ public function renderAgent(): string { - $time = number_format((microtime(true) - Debugger::$time) * 1000, 1); + $time = number_format((microtime(as_float: true) - Debugger::$time) * 1000, 1); $memory = number_format(memory_get_peak_usage() / 1_000_000, 2); $parts = ["Tracy Bar | $time ms | $memory MB"]; foreach ($this->panels as $panel) { diff --git a/core/vendor/tracy/tracy/src/Tracy/Bar/panels/info.panel.php b/core/vendor/tracy/tracy/src/Tracy/Bar/panels/info.panel.php index 325a9cad13..c43fb56dcf 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Bar/panels/info.panel.php +++ b/core/vendor/tracy/tracy/src/Tracy/Bar/panels/info.panel.php @@ -3,7 +3,7 @@ namespace Tracy; use Composer\Autoload\ClassLoader; -use function count; +use function count, extension_loaded, function_exists; use const PHP_VERSION, PHP_ZTS; /** @var DefaultBarPanel $this */ @@ -16,7 +16,10 @@ $systemUsage = -round(($this->cpuUsage['ru_stime.tv_sec'] * 1e6 + $this->cpuUsage['ru_stime.tv_usec']) / $this->time / 10000); } -$countClasses = fn(array $list): int => count(array_filter($list, fn(string $name): bool => (new \ReflectionClass($name))->isUserDefined())); +$countClasses = fn(array $list): int => count(array_filter( + $list, + fn(string $name): bool => (new \ReflectionClass($name))->isUserDefined(), +)); $ipFormatter = static function (?string $ip): ?string { if ($ip === '127.0.0.1' || $ip === '::1') { @@ -31,13 +34,17 @@ $info = [ 'Execution time' => number_format($this->time * 1000, 1, '.', "\u{202f}") . "\u{202f}ms", - 'CPU usage user + system' => isset($userUsage, $systemUsage) ? (int) $userUsage . "\u{202f}% + " . (int) $systemUsage . "\u{202f}%" : null, + 'CPU usage user + system' => isset($userUsage, $systemUsage) + ? (int) $userUsage . "\u{202f}% + " . (int) $systemUsage . "\u{202f}%" + : null, 'Peak of allocated memory' => number_format(memory_get_peak_usage() / 1_000_000, 2, '.', "\u{202f}") . "\u{202f}MB", 'Included files' => count(get_included_files()), 'Classes + interfaces + traits' => $countClasses(get_declared_classes()) . ' + ' . $countClasses(get_declared_interfaces()) . ' + ' . $countClasses(get_declared_traits()), 'OPcache' => $opcache ? round(count($cachedFiles) * 100 / count(get_included_files())) . "\u{202f}% cached" : '–', - 'JIT' => empty($jit['buffer_size']) ? '–' : round(($jit['buffer_size'] - $jit['buffer_free']) / $jit['buffer_size'] * 100) . "\u{202f}% used", + 'JIT' => empty($jit['buffer_size']) + ? '–' + : round(($jit['buffer_size'] - $jit['buffer_free']) / $jit['buffer_size'] * 100) . "\u{202f}% used", 'Your IP' => $ipFormatter($_SERVER['REMOTE_ADDR'] ?? null), 'Server IP' => $ipFormatter($_SERVER['SERVER_ADDR'] ?? null), 'HTTP method / response code' => isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] . ' / ' . http_response_code() : null, @@ -50,7 +57,7 @@ $info = array_map('strval', array_filter($info + (array) $this->data)); $packages = $devPackages = []; -if (class_exists(ClassLoader::class, autoload: false)) { +if (class_exists(ClassLoader::class, false)) { $baseDir = (function () { $baseDir = null; @include dirname((string) (new \ReflectionClass(ClassLoader::class))->getFileName()) . '/autoload_psr4.php'; // @ may not exist diff --git a/core/vendor/tracy/tracy/src/Tracy/BlueScreen/BlueScreen.php b/core/vendor/tracy/tracy/src/Tracy/BlueScreen/BlueScreen.php index a670f58f0c..f1df4a7d47 100644 --- a/core/vendor/tracy/tracy/src/Tracy/BlueScreen/BlueScreen.php +++ b/core/vendor/tracy/tracy/src/Tracy/BlueScreen/BlueScreen.php @@ -7,8 +7,8 @@ namespace Tracy; -use function count, in_array; -use const ARRAY_FILTER_USE_KEY, ENT_IGNORE, PHP_VERSION_ID; +use function count, function_exists, in_array; +use const INFO_CONFIGURATION, INFO_MODULES, PHP_VERSION_ID; /** @@ -72,7 +72,7 @@ public function __construct() public function addPanel(callable $panel): static { $panel = $panel(...); - if (!in_array($panel, $this->panels, strict: true)) { + if (!in_array($panel, $this->panels, true)) { $this->panels[] = $panel; } @@ -135,7 +135,10 @@ public function renderAgent(\Throwable $exception, ?array $logLocation = null): /** @internal */ public function renderToAjax(\Throwable $exception, DeferredContent $defer): void { - $defer->addSetup('Tracy.BlueScreen.loadAjax', Helpers::capture(fn() => $this->renderTemplate($exception, __DIR__ . '/dist/content.phtml'))); + $defer->addSetup( + 'Tracy.BlueScreen.loadAjax', + Helpers::capture(fn() => $this->renderTemplate($exception, __DIR__ . '/dist/content.phtml')), + ); if (Helpers::isAgent()) { $defer->addSetup('console.error', $this->renderAgent($exception)); } @@ -148,7 +151,7 @@ public function renderToAjax(\Throwable $exception, DeferredContent $defer): voi */ public function renderToFile(\Throwable $exception, string $file, ?array $logLocation = null): bool { - if ($handle = @fopen($file, 'x')) { + if ($handle = @fopen($file, 'x')) { // @ file may already exist ob_start(); // double buffer prevents sending HTTP headers in some PHP ob_start(function ($buffer) use ($handle) { fwrite($handle, $buffer); @@ -159,7 +162,7 @@ public function renderToFile(\Throwable $exception, string $file, ?array $logLoc ob_end_clean(); fclose($handle); - if ($handle = @fopen(substr($file, 0, -5) . '.md', 'x')) { + if ($handle = @fopen(substr($file, 0, -5) . '.md', 'x')) { // @ file may already exist fwrite($handle, $this->renderAgent($exception, $logLocation)); fclose($handle); } @@ -195,7 +198,10 @@ private function renderTemplate( $httpHeaders = apache_request_headers(); } else { $httpHeaders = array_filter($_SERVER, fn($k) => str_starts_with($k, 'HTTP_'), ARRAY_FILTER_USE_KEY); - $httpHeaders = array_combine(array_map(fn($k) => strtolower(strtr(substr($k, 5), '_', '-')), array_keys($httpHeaders)), $httpHeaders); + $httpHeaders = array_combine( + array_map(fn($k) => strtolower(strtr(substr($k, 5), '_', '-')), array_keys($httpHeaders)), + $httpHeaders, + ); } $this->snapshot = []; @@ -203,14 +209,15 @@ private function renderTemplate( $dump = $this->getDumper(); $agentDump = $this->getAgentDumper(); - $css = array_map(file_get_contents(...), array_merge([ + $css = array_map(file_get_contents(...), [ __DIR__ . '/../assets/reset.css', __DIR__ . '/assets/bluescreen.css', __DIR__ . '/../assets/toggle.css', __DIR__ . '/../assets/table-sort.css', __DIR__ . '/../assets/tabs.css', __DIR__ . '/../Dumper/assets/dumper-light.css', - ], Debugger::$customCssFiles)); + ...Debugger::$customCssFiles, + ]); $css = Helpers::minifyCss(implode('', $css)); $js = array_map(fn($file) => '(function(){' . file_get_contents($file) . '})();', [ @@ -290,7 +297,7 @@ private function renderActions(\Throwable $ex): array if (preg_match('# ([\'"])(\w{3,}(?:\\\\\w{2,})+)\1#i', $ex->getMessage(), $m)) { $class = $m[2]; if ( - !class_exists($class, autoload: false) && !interface_exists($class, autoload: false) && !trait_exists($class, autoload: false) + !class_exists($class, false) && !interface_exists($class, autoload: false) && !trait_exists($class, autoload: false) && ($file = Helpers::guessClassFile($class)) && !@is_file($file) // @ - may trigger error ) { [$content, $line] = $this->generateNewFileContents($file, $class); @@ -368,10 +375,17 @@ public static function highlightFile( $source = $php ? CodeHighlighter::highlightPhp($source, $line, $column) - : '
' . CodeHighlighter::highlightLine(htmlspecialchars($source, ENT_IGNORE, 'UTF-8'), $line, $column) . '
'; + : '
' + . CodeHighlighter::highlightLine(htmlspecialchars($source, ENT_IGNORE, 'UTF-8'), $line, $column) + . '
'; if ($editor = Helpers::editorUri($file, $line)) { - $source = substr_replace($source, ' title="Ctrl-Click to open in editor" data-tracy-href="' . Helpers::escapeHtml($editor) . '"', 4, 0); + $source = substr_replace( + $source, + ' title="Ctrl-Click to open in editor" data-tracy-href="' . Helpers::escapeHtml($editor) . '"', + 4, + 0, + ); } return $source; @@ -417,10 +431,14 @@ public function isCollapsed(string $file): bool public function prepareStack(\Throwable $ex): array { $stack = $ex->getTrace(); - while ($stack && ( - in_array($stack[0]['class'] ?? null, [DevelopmentStrategy::class, ProductionStrategy::class], true) - || (($stack[0]['class'] ?? null) === Debugger::class && in_array($stack[0]['function'], ['shutdownHandler', 'errorHandler'], true)) - )) { + while ( + $stack + && ( + in_array($stack[0]['class'] ?? null, [DevelopmentStrategy::class, ProductionStrategy::class], true) + || (($stack[0]['class'] ?? null) === Debugger::class + && in_array($stack[0]['function'], ['shutdownHandler', 'errorHandler'], true)) + ) + ) { array_shift($stack); } @@ -429,7 +447,10 @@ public function prepareStack(\Throwable $ex): array !$ex instanceof \ErrorException || in_array($ex->getSeverity(), [E_USER_NOTICE, E_USER_WARNING, E_USER_DEPRECATED], true) ) { - $n = Helpers::countTransparentFrames([['file' => $ex->getFile(), 'line' => $ex->getLine()], ...$stack], [...$this->collapsePaths, ...Debugger::$transparentPaths]); + $n = Helpers::countTransparentFrames( + [['file' => $ex->getFile(), 'line' => $ex->getLine()], ...$stack], + [...$this->collapsePaths, ...Debugger::$transparentPaths], + ); $expanded = $n > 0 && $n <= count($stack) ? $n - 1 : null; } @@ -455,7 +476,10 @@ public function getDumper(): \Closure } - /** @return \Closure(mixed, int|string): string */ + /** + * @return \Closure(mixed, int|string): string + * @internal + */ public function getAgentDumper(): \Closure { return fn($v, $k = null): string => Dumper::toText($v, [ @@ -481,7 +505,7 @@ public function formatMessage(\Throwable $exception): string $msg = preg_replace_callback( '#(\w+\\\[\w\\\]+\w)(?:::(\w+))?#', function ($m) { - $classLike = class_exists($m[1], autoload: false) || interface_exists($m[1], autoload: false) || trait_exists($m[1], autoload: false); + $classLike = class_exists($m[1], false) || interface_exists($m[1], autoload: false) || trait_exists($m[1], autoload: false); if ($classLike && isset($m[2]) && method_exists($m[1], $m[2])) { $r = new \ReflectionMethod($m[1], $m[2]); } elseif ($classLike) { @@ -492,7 +516,11 @@ function ($m) { return $m[0]; } - return '' . $m[0] . ''; + return '' + . $m[0] + . ''; }, $msg, ); @@ -501,7 +529,11 @@ function ($m) { $msg = preg_replace_callback( '#([\w\\\/.:-]+\.(?:php|phpt|phtml|latte|neon))(?|:(\d+)| on line (\d+))?#', fn($m) => @is_file($m[1]) // @ - may trigger error - ? '' . $m[0] . '' + ? '' + . $m[0] + . '' : $m[0], $msg, ); diff --git a/core/vendor/tracy/tracy/src/Tracy/BlueScreen/CodeHighlighter.php b/core/vendor/tracy/tracy/src/Tracy/BlueScreen/CodeHighlighter.php index 0820b4a4ac..f343ffebe6 100644 --- a/core/vendor/tracy/tracy/src/Tracy/BlueScreen/CodeHighlighter.php +++ b/core/vendor/tracy/tracy/src/Tracy/BlueScreen/CodeHighlighter.php @@ -7,8 +7,7 @@ namespace Tracy; -use function array_pop, array_shift, array_unshift, count, explode, floor, implode, max, min, preg_replace, preg_replace_callback, rtrim, sprintf, str_replace, strip_tags, strlen, strtr; -use const T_CLASS_C, T_CLOSE_TAG, T_COMMENT, T_CONSTANT_ENCAPSED_STRING, T_DIR, T_DNUMBER, T_DOC_COMMENT, T_ENCAPSED_AND_WHITESPACE, T_FILE, T_FUNC_C, T_INLINE_HTML, T_LINE, T_LNUMBER, T_METHOD_C, T_NAME_FULLY_QUALIFIED, T_NAME_QUALIFIED, T_NAME_RELATIVE, T_NS_C, T_OPEN_TAG, T_OPEN_TAG_WITH_ECHO, T_STRING, T_TRAIT_C, T_VARIABLE, T_WHITESPACE; +use function count, strlen; /** @internal */ @@ -87,7 +86,7 @@ private static function highlightPhpCode(string $code): string $code = str_replace("\r\n", "\n", $code); $code = preg_replace('#(__halt_compiler\s*\(\)\s*;).*#is', '$1', $code); $code = rtrim($code); - $code = preg_replace('#/\*sensitive\{\*/.*?/\*}\*/#s', Dumper\Describer::HiddenValue, $code); + $code = self::hideSensitive($code); $last = $out = ''; foreach (\PhpToken::tokenize($code) as $token) { @@ -121,6 +120,19 @@ private static function highlightPhpCode(string $code): string } + /** + * Replaces sensitive regions with a placeholder, keeping the line numbering intact. + */ + public static function hideSensitive(string $code): string + { + return preg_replace_callback( + '#/\*sensitive\{\*/.*?/\*}\*/#s', + fn($m) => Dumper\Describer::HiddenValue . str_repeat("\n", substr_count($m[0], "\n")), + $code, + ); + } + + /** * Returns syntax highlighted source code to Terminal. */ diff --git a/core/vendor/tracy/tracy/src/Tracy/BlueScreen/dist/agent.phtml b/core/vendor/tracy/tracy/src/Tracy/BlueScreen/dist/agent.phtml index d0ab024516..9d82e64e7c 100644 --- a/core/vendor/tracy/tracy/src/Tracy/BlueScreen/dist/agent.phtml +++ b/core/vendor/tracy/tracy/src/Tracy/BlueScreen/dist/agent.phtml @@ -1,5 +1,6 @@ $srcLine) /* pos 24:2 */ { - echo sprintf('%s%4d | %s', $i + 1 === $line ? '► ' : ' ', $i + 1, rtrim($srcLine)) /* pos 25:3 */; + foreach (array_slice($srcLines, $start, $end - $start, true) as $i => $srcLine) /* pos 26:2 */ { + echo sprintf('%s%4d | %s', $i + 1 === $line ? '► ' : ' ', $i + 1, rtrim($srcLine)) /* pos 27:3 */; echo "\n"; } echo '``` '; - $mapped = Debugger::mapSource($file, $line) /* pos 28:2 */; - if (!$mapped || !@is_file($mapped['file'])) /* pos 29:2 */ return; - $mLine = $mapped['line'] /* pos 30:2 */; + $mapped = Debugger::mapSource($file, $line) /* pos 30:2 */; + if (!$mapped || !@is_file($mapped['file'])) /* pos 31:2 */ return; + $mLine = $mapped['line'] /* pos 32:2 */; echo ' Mapped source: '; - echo Tracy\Helpers::escapeMd($mapped['file'] . ($mLine ? ':' . $mLine : '')) /* pos 32:17 */; + echo Tracy\Helpers::escapeMd($mapped['file'] . ($mLine ? ':' . $mLine : '')) /* pos 34:17 */; echo "\n"; - $mappedLines = @file($mapped['file']) /* pos 33:2 */; - if ($mappedLines && $mLine) /* pos 34:2 */ { - $mStart = (int) max(0, $mLine - 4) /* pos 35:3 */; - $mEnd = (int) min(count($mappedLines), $mLine + 3) /* pos 36:3 */; + $mappedLines = @file($mapped['file']) /* pos 35:2 */; + if ($mappedLines && $mLine) /* pos 36:2 */ { + $mStart = (int) max(0, $mLine - 4) /* pos 37:3 */; + $mEnd = (int) min(count($mappedLines), $mLine + 3) /* pos 38:3 */; echo ' ``` '; - foreach (array_slice($mappedLines, $mStart, $mEnd - $mStart, true) as $i => $srcLine) /* pos 39:3 */ { - echo sprintf('%s%4d | %s', $i + 1 === $mLine ? '► ' : ' ', $i + 1, rtrim($srcLine)) /* pos 40:4 */; + foreach (array_slice($mappedLines, $mStart, $mEnd - $mStart, true) as $i => $srcLine) /* pos 41:3 */ { + echo sprintf('%s%4d | %s', $i + 1 === $mLine ? '► ' : ' ', $i + 1, rtrim($srcLine)) /* pos 42:4 */; echo "\n"; } @@ -61,107 +64,107 @@ Mapped source: '; } }; echo "\n"; -foreach (Helpers::getExceptionChain($exception) as $i => $ex) /* pos 46:1 */ { - $title = $blueScreen->getExceptionTitle($ex) /* pos 47:2 */; - $code = $ex->getCode() ? ' #' . $ex->getCode() : null /* pos 48:2 */; - if ($i === 0) /* pos 49:2 */ { +foreach (Helpers::getExceptionChain($exception) as $i => $ex) /* pos 48:1 */ { + $title = $blueScreen->getExceptionTitle($ex) /* pos 49:2 */; + $code = $ex->getCode() ? ' #' . $ex->getCode() : null /* pos 50:2 */; + if ($i === 0) /* pos 51:2 */ { echo '# '; - echo Tracy\Helpers::escapeMd($title) /* pos 50:5 */; + echo Tracy\Helpers::escapeMd($title) /* pos 52:5 */; echo ': '; - echo Tracy\Helpers::escapeMd($ex->getMessage()) /* pos 50:15 */; - echo Tracy\Helpers::escapeMd($code) /* pos 50:34 */; + echo Tracy\Helpers::escapeMd($ex->getMessage()) /* pos 52:15 */; + echo Tracy\Helpers::escapeMd($code) /* pos 52:34 */; echo "\n"; - } else /* pos 51:2 */ { + } else /* pos 53:2 */ { echo ' ## Caused by: '; - echo Tracy\Helpers::escapeMd($title) /* pos 53:17 */; + echo Tracy\Helpers::escapeMd($title) /* pos 55:17 */; echo ': '; - echo Tracy\Helpers::escapeMd($ex->getMessage()) /* pos 53:27 */; - echo Tracy\Helpers::escapeMd($code) /* pos 53:46 */; + echo Tracy\Helpers::escapeMd($ex->getMessage()) /* pos 55:27 */; + echo Tracy\Helpers::escapeMd($code) /* pos 55:46 */; echo "\n"; } echo ' in '; - echo Tracy\Helpers::escapeMd($ex->getFile()) /* pos 56:5 */; + echo Tracy\Helpers::escapeMd($ex->getFile()) /* pos 58:5 */; echo ':'; - echo Tracy\Helpers::escapeMd($ex->getLine()) /* pos 56:22 */; + echo Tracy\Helpers::escapeMd($ex->getLine()) /* pos 58:22 */; echo "\n"; $_blocks['renderSource']($ex->getFile(),$ex->getLine()); echo "\n"; - $base = new \Exception /* pos 59:2 */; - if (count(get_mangled_object_vars($ex)) > count(get_mangled_object_vars($base))) /* pos 60:2 */ { + $base = new \Exception /* pos 61:2 */; + if (count(get_mangled_object_vars($ex)) > count(get_mangled_object_vars($base))) /* pos 62:2 */ { echo ' ## Exception Properties '; - echo Tracy\Helpers::escapeMd($agentDump($ex)) /* pos 64:3 */; + echo Tracy\Helpers::escapeMd($agentDump($ex)) /* pos 66:3 */; echo "\n"; } echo "\n"; - [$stack] = $blueScreen->prepareStack($ex) /* pos 67:2 */; - if ($stack) /* pos 68:2 */ { + [$stack] = $blueScreen->prepareStack($ex) /* pos 69:2 */; + if ($stack) /* pos 70:2 */ { echo ' ## Stack Trace '; - foreach ($stack as $j => $row) /* pos 72:3 */ { - $funcName = isset($row['class']) ? $row['class'] . ($row['type'] ?? '::') . $row['function'] : $row['function'] /* pos 73:4 */; - $location = isset($row['file']) ? $row['file'] . ':' . ($row['line'] ?? '?') : 'inner-code' /* pos 74:4 */; - $inlineArgs = '' /* pos 75:4 */; - $complexArgs = [] /* pos 76:4 */; - if (!empty($row['args'])) /* pos 77:4 */ { + foreach ($stack as $j => $row) /* pos 74:3 */ { + $funcName = isset($row['class']) ? $row['class'] . ($row['type'] ?? '::') . $row['function'] : $row['function'] /* pos 75:4 */; + $location = isset($row['file']) ? $row['file'] . ':' . ($row['line'] ?? '?') : 'inner-code' /* pos 76:4 */; + $inlineArgs = '' /* pos 77:4 */; + $complexArgs = [] /* pos 78:4 */; + if (!empty($row['args'])) /* pos 79:4 */ { try { - $params = isset($row['class']) ? (new \ReflectionMethod($row['class'], $row['function']))->getParameters() : (new \ReflectionFunction($row['function']))->getParameters() /* pos 79:6 */; + $params = isset($row['class']) ? (new \ReflectionMethod($row['class'], $row['function']))->getParameters() : (new \ReflectionFunction($row['function']))->getParameters() /* pos 81:6 */; } catch (\Throwable) { - $params = [] /* pos 81:6 */; + $params = [] /* pos 83:6 */; } - $allSimple = true /* pos 83:5 */; - $argParts = [] /* pos 84:5 */; - foreach ($row['args'] as $k => $v) /* pos 85:5 */ { - $name = isset($params[$k]) && !$params[$k]->isVariadic() ? '$' . $params[$k]->getName() : '#' . $k /* pos 86:6 */; - $dump = rtrim($agentDump($v)) /* pos 87:6 */; + $allSimple = true /* pos 85:5 */; + $argParts = [] /* pos 86:5 */; + foreach ($row['args'] as $k => $v) /* pos 87:5 */ { + $name = isset($params[$k]) && !$params[$k]->isVariadic() ? '$' . $params[$k]->getName() : '#' . $k /* pos 88:6 */; + $dump = rtrim($agentDump($v)) /* pos 89:6 */; if (str_contains($dump, ' -') || strlen($dump) > 50) /* pos 88:6 */ { - $allSimple = false /* pos 89:7 */; - } else /* pos 90:6 */ { - $argParts[] = $name . ' = ' . $dump /* pos 91:7 */; +') || strlen($dump) > 50) /* pos 90:6 */ { + $allSimple = false /* pos 91:7 */; + } else /* pos 92:6 */ { + $argParts[] = $name . ' = ' . $dump /* pos 93:7 */; } - $complexArgs[] = $name . ' = ' . $dump /* pos 93:6 */; + $complexArgs[] = $name . ' = ' . $dump /* pos 95:6 */; } - if ($allSimple) /* pos 95:5 */ { - $inlineArgs = implode(', ', $argParts) /* pos 96:6 */; + if ($allSimple) /* pos 97:5 */ { + $inlineArgs = implode(', ', $argParts) /* pos 98:6 */; } } - if ($inlineArgs) /* pos 99:4 */ { - echo Tracy\Helpers::escapeMd($j + 1) /* pos 100:5 */; + if ($inlineArgs) /* pos 101:4 */ { + echo Tracy\Helpers::escapeMd($j + 1) /* pos 102:5 */; echo '. '; - echo Tracy\Helpers::escapeMd($funcName) /* pos 100:16 */; + echo Tracy\Helpers::escapeMd($funcName) /* pos 102:16 */; echo '('; - echo Tracy\Helpers::escapeMd($inlineArgs) /* pos 100:28 */; + echo Tracy\Helpers::escapeMd($inlineArgs) /* pos 102:28 */; echo ') '; - } elseif ($complexArgs) /* pos 101:4 */ { - echo Tracy\Helpers::escapeMd($j + 1) /* pos 102:5 */; + } elseif ($complexArgs) /* pos 103:4 */ { + echo Tracy\Helpers::escapeMd($j + 1) /* pos 104:5 */; echo '. '; - echo Tracy\Helpers::escapeMd($funcName) /* pos 102:16 */; + echo Tracy\Helpers::escapeMd($funcName) /* pos 104:16 */; echo '('; echo Tracy\Helpers::escapeMd(implode(', -', $complexArgs)) /* pos 102:28 */; +', $complexArgs)) /* pos 104:28 */; echo ') '; - } else /* pos 103:4 */ { - echo Tracy\Helpers::escapeMd($j + 1) /* pos 104:5 */; + } else /* pos 105:4 */ { + echo Tracy\Helpers::escapeMd($j + 1) /* pos 106:5 */; echo '. '; - echo Tracy\Helpers::escapeMd($funcName) /* pos 104:16 */; + echo Tracy\Helpers::escapeMd($funcName) /* pos 106:16 */; echo '() '; } echo ' in '; - echo Tracy\Helpers::escapeMd($location) /* pos 106:10 */; + echo Tracy\Helpers::escapeMd($location) /* pos 108:10 */; echo "\n"; - if (isset($row['file'], $row['line']) && @is_file($row['file'])) /* pos 107:4 */ { + if (isset($row['file'], $row['line']) && @is_file($row['file'])) /* pos 109:4 */ { $_blocks['renderSource']($row['file'],$row['line']); } echo "\n"; @@ -173,106 +176,106 @@ in '; } echo "\n"; -if ($lastError) /* pos 115:1 */ { +if ($lastError) /* pos 117:1 */ { echo ' ## Last Muted Error '; - echo Tracy\Helpers::escapeMd(Helpers::errorTypeToString($lastError['type'])) /* pos 119:2 */; + echo Tracy\Helpers::escapeMd(Helpers::errorTypeToString($lastError['type'])) /* pos 121:2 */; echo ': '; - echo Tracy\Helpers::escapeMd($lastError['message']) /* pos 119:51 */; + echo Tracy\Helpers::escapeMd($lastError['message']) /* pos 121:51 */; echo ' in '; - echo Tracy\Helpers::escapeMd($lastError['file']) /* pos 120:5 */; + echo Tracy\Helpers::escapeMd($lastError['file']) /* pos 122:5 */; echo ':'; - echo Tracy\Helpers::escapeMd($lastError['line']) /* pos 120:24 */; + echo Tracy\Helpers::escapeMd($lastError['line']) /* pos 122:24 */; echo "\n"; - if (@is_file($lastError['file'])) /* pos 121:2 */ { + if (@is_file($lastError['file'])) /* pos 123:2 */ { $_blocks['renderSource']($lastError['file'],$lastError['line']); } } echo "\n"; -if (!Helpers::isCli() && isset($_SERVER['REQUEST_METHOD'])) /* pos 126:1 */ { +if (!Helpers::isCli() && isset($_SERVER['REQUEST_METHOD'])) /* pos 128:1 */ { echo ' ## HTTP Request '; - echo Tracy\Helpers::escapeMd($_SERVER['REQUEST_METHOD']) /* pos 130:2 */; + echo Tracy\Helpers::escapeMd($_SERVER['REQUEST_METHOD']) /* pos 132:2 */; echo ' '; - echo Tracy\Helpers::escapeMd($source) /* pos 130:31 */; + echo Tracy\Helpers::escapeMd($source) /* pos 132:31 */; echo "\n"; - if ($httpHeaders) /* pos 131:2 */ { + if ($httpHeaders) /* pos 133:2 */ { echo ' ### Headers '; - foreach ($httpHeaders as $k => $v) /* pos 135:3 */ { + foreach ($httpHeaders as $k => $v) /* pos 137:3 */ { echo '- '; - echo Tracy\Helpers::escapeMd($k) /* pos 136:6 */; + echo Tracy\Helpers::escapeMd($k) /* pos 138:6 */; echo ': '; - echo Tracy\Helpers::escapeMd($v) /* pos 136:12 */; + echo Tracy\Helpers::escapeMd($v) /* pos 138:12 */; echo "\n"; } } echo "\n"; - if (!empty($_GET)) /* pos 140:2 */ { + if (!empty($_GET)) /* pos 142:2 */ { echo ' ### $_GET '; - echo Tracy\Helpers::escapeMd($agentDump($_GET)) /* pos 144:3 */; + echo Tracy\Helpers::escapeMd($agentDump($_GET)) /* pos 146:3 */; echo "\n"; } echo "\n"; - if (!empty($_POST)) /* pos 147:2 */ { + if (!empty($_POST)) /* pos 149:2 */ { echo ' ### $_POST '; - echo Tracy\Helpers::escapeMd($agentDump($_POST)) /* pos 151:3 */; + echo Tracy\Helpers::escapeMd($agentDump($_POST)) /* pos 153:3 */; echo "\n"; } echo "\n"; - if (!empty($_COOKIE)) /* pos 154:2 */ { + if (!empty($_COOKIE)) /* pos 156:2 */ { echo ' ### $_COOKIE '; - echo Tracy\Helpers::escapeMd($agentDump($_COOKIE)) /* pos 158:3 */; + echo Tracy\Helpers::escapeMd($agentDump($_COOKIE)) /* pos 160:3 */; echo "\n"; } } echo "\n"; -if ($showEnvironment) /* pos 162:1 */ { +if ($showEnvironment) /* pos 164:1 */ { echo ' ## Environment | Key | Value | |-----|-------| | PHP | '; - echo Tracy\Helpers::escapeMd(\PHP_VERSION . ' (' . (\PHP_ZTS ? 'TS' : 'NTS') . ')') /* pos 168:10 */; + echo Tracy\Helpers::escapeMd(\PHP_VERSION . ' (' . (\PHP_ZTS ? 'TS' : 'NTS') . ')') /* pos 170:10 */; echo ' | '; - if (isset($_SERVER['REQUEST_METHOD'])) /* pos 169:2 */ { + if (isset($_SERVER['REQUEST_METHOD'])) /* pos 171:2 */ { echo '| Method | '; - echo Tracy\Helpers::escapeMd($_SERVER['REQUEST_METHOD']) /* pos 170:14 */; + echo Tracy\Helpers::escapeMd($_SERVER['REQUEST_METHOD']) /* pos 172:14 */; echo ' | '; } echo '| Source | '; - echo Tracy\Helpers::escapeMd($source) /* pos 172:13 */; + echo Tracy\Helpers::escapeMd($source) /* pos 174:13 */; echo ' | '; - if (isset($_SERVER['SERVER_SOFTWARE'])) /* pos 173:2 */ { + if (isset($_SERVER['SERVER_SOFTWARE'])) /* pos 175:2 */ { echo '| Server | '; - echo Tracy\Helpers::escapeMd($_SERVER['SERVER_SOFTWARE']) /* pos 174:14 */; + echo Tracy\Helpers::escapeMd($_SERVER['SERVER_SOFTWARE']) /* pos 176:14 */; echo ' | '; } echo '| Tracy | '; - echo Tracy\Helpers::escapeMd(Debugger::Version) /* pos 176:12 */; + echo Tracy\Helpers::escapeMd(Debugger::Version) /* pos 178:12 */; echo ' | '; } diff --git a/core/vendor/tracy/tracy/src/Tracy/Debugger/Debugger.php b/core/vendor/tracy/tracy/src/Tracy/Debugger/Debugger.php index b48dbb75ec..51956ed3ab 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Debugger/Debugger.php +++ b/core/vendor/tracy/tracy/src/Tracy/Debugger/Debugger.php @@ -8,7 +8,7 @@ namespace Tracy; use ErrorException; -use function in_array, is_bool, is_int, is_string; +use function dirname, function_exists, in_array, ini_get, is_bool, is_int, is_string; use const PHP_VERSION; @@ -17,7 +17,7 @@ */ class Debugger { - public const Version = '2.12.1'; + public const Version = '2.12.2'; /** server modes for Debugger::enable() */ public const @@ -67,7 +67,7 @@ class Debugger /** determines whether any error will cause immediate death in development mode; if integer that it's matched against error severity */ public static bool|int $strictMode = false; - /** disables the @ (shut-up) operator so that notices and warnings are no longer hidden; if integer than it's matched against error severity */ + /** disables the @ (shut-up) operator so notices and warnings are not hidden; if integer than it's matched against error severity */ public static bool|int $scream = false; /** @var array functions that are automatically called after fatal error */ @@ -139,7 +139,7 @@ class Debugger /** @var string[] */ public static array $customJsFiles = []; - /** @var string[] path prefixes of "transparent" packages whose frames are skipped when locating user code and collapsed in stack traces */ + /** @var string[] path prefixes of "transparent" packages whose frames are skipped when locating user code and collapsed in traces */ public static array $transparentPaths = []; /** @var array<\Closure(string, int): ?array{file: string, label: string, line?: int, column?: int, active?: bool}> */ @@ -167,7 +167,7 @@ final public function __construct() /** * Enables displaying or logging errors and exceptions. - * @param bool|string|string[] $mode use constant Debugger::Production, Development, Detect (autodetection) or IP address(es) whitelist. + * @param bool|string|string[] $mode Debugger::Production, Development, Detect (autodetection) or IP address(es) whitelist * @param ?string $logDirectory error log directory * @param string|string[]|null $email administrator email; enables email sending in production mode */ @@ -280,7 +280,7 @@ public static function isEnabled(): bool public static function shutdownHandler(): void { $error = error_get_last(); - if (in_array($error['type'] ?? null, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE, E_RECOVERABLE_ERROR, E_USER_ERROR], strict: true)) { + if (in_array($error['type'] ?? null, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE, E_RECOVERABLE_ERROR, E_USER_ERROR], true)) { $e = new ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line']); if (!empty($error['trace'])) { (new \ReflectionClass(\Exception::class))->getProperty('trace')->setValue($e, $error['trace']); @@ -314,7 +314,7 @@ public static function exceptionHandler(\Throwable $exception): void self::$reserved = null; self::$obStatus = ob_get_status(full_status: true); - @http_response_code(500); + @http_response_code(500); // @ headers may already be sent Helpers::improveException($exception); self::removeOutputBuffers(errorOccurred: true); @@ -368,7 +368,7 @@ public static function removeOutputBuffers(bool $errorOccurred): void { while (ob_get_level() > self::$obLevel) { $status = ob_get_status(); - if (in_array($status['name'], ['ob_gzhandler', 'zlib output compression'], strict: true)) { + if (in_array($status['name'], ['ob_gzhandler', 'zlib output compression'], true)) { break; } @@ -458,9 +458,9 @@ public static function setSessionStorage(SessionStorage $storage): void public static function getSessionStorage(): SessionStorage { if (empty(self::$sessionStorage)) { - self::$sessionStorage = @is_dir($dir = (string) session_save_path()) - || @is_dir($dir = (string) ini_get('upload_tmp_dir')) - || @is_dir($dir = sys_get_temp_dir()) + self::$sessionStorage = @is_dir($dir = (string) session_save_path()) // @ the path may be too long or outside open_basedir + || @is_dir($dir = (string) ini_get('upload_tmp_dir')) // @ the path may be too long or outside open_basedir + || @is_dir($dir = sys_get_temp_dir()) // @ the path may be too long or outside open_basedir || ($dir = (string) self::$logDirectory) ? new FileSession($dir) : new NativeSession; @@ -632,7 +632,7 @@ public static function detectDebugMode(string|array|null $list = null): bool $list[] = '[::1]'; // workaround for PHP < 7.3.4 } - return in_array($addr, $list, strict: true) || in_array("$secret@$addr", $list, strict: true); + return in_array($addr, $list, true) || in_array("$secret@$addr", $list, true); } diff --git a/core/vendor/tracy/tracy/src/Tracy/Debugger/DeferredContent.php b/core/vendor/tracy/tracy/src/Tracy/Debugger/DeferredContent.php index cb40be1982..08c3f5fa9b 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Debugger/DeferredContent.php +++ b/core/vendor/tracy/tracy/src/Tracy/Debugger/DeferredContent.php @@ -7,7 +7,7 @@ namespace Tracy; -use function array_slice, is_string, strlen; +use function is_string, strlen; /** @@ -122,7 +122,7 @@ public function sendAssets(): bool private function buildJsCss(): string { - $css = array_map(file_get_contents(...), array_merge([ + $css = array_map(file_get_contents(...), [ __DIR__ . '/../assets/reset.css', __DIR__ . '/../Bar/assets/bar.css', __DIR__ . '/../assets/toggle.css', @@ -131,7 +131,8 @@ private function buildJsCss(): string __DIR__ . '/../Dumper/assets/dumper-light.css', __DIR__ . '/../Dumper/assets/dumper-dark.css', __DIR__ . '/../BlueScreen/assets/bluescreen.css', - ], Debugger::$customCssFiles)); + ...Debugger::$customCssFiles, + ]); $js1 = array_map(fn($file) => '(function() {' . file_get_contents($file) . '})();', [ __DIR__ . '/../Bar/assets/bar.js', diff --git a/core/vendor/tracy/tracy/src/Tracy/Debugger/DevelopmentStrategy.php b/core/vendor/tracy/tracy/src/Tracy/Debugger/DevelopmentStrategy.php index 8f81711fab..689f16a2d7 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Debugger/DevelopmentStrategy.php +++ b/core/vendor/tracy/tracy/src/Tracy/Debugger/DevelopmentStrategy.php @@ -8,7 +8,7 @@ namespace Tracy; use ErrorException; -use function is_bool; +use function function_exists, is_bool; /** @@ -72,7 +72,7 @@ private function renderExceptionCli(\Throwable $exception): void return; } - if (Helpers::detectColors() && @is_file($exception->getFile())) { + if (Helpers::detectColors() && @is_file($exception->getFile())) { // @ the path may be too long or outside open_basedir echo "\n\n" . CodeHighlighter::highlightPhpCli((string) file_get_contents($exception->getFile()), $exception->getLine()) . "\n"; } @@ -99,7 +99,7 @@ public function handleError( && !isset($_GET['_tracy_skip_error']) ) { $e = new ErrorException($message, 0, $severity, $file, $line); - @$e->skippable = true; // dynamic properties are deprecated since PHP 8.2 + @$e->skippable = true; // @ dynamic properties are deprecated Debugger::exceptionHandler($e); exit(255); } diff --git a/core/vendor/tracy/tracy/src/Tracy/Debugger/ProductionStrategy.php b/core/vendor/tracy/tracy/src/Tracy/Debugger/ProductionStrategy.php index ed760e00a0..32b01d9887 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Debugger/ProductionStrategy.php +++ b/core/vendor/tracy/tracy/src/Tracy/Debugger/ProductionStrategy.php @@ -8,8 +8,7 @@ namespace Tracy; use ErrorException; -use function is_resource; -use const STDERR; +use function function_exists, ini_get, is_resource; /** diff --git a/core/vendor/tracy/tracy/src/Tracy/Dumper/Describer.php b/core/vendor/tracy/tracy/src/Tracy/Dumper/Describer.php index 76a938c36d..b3f5436f26 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Dumper/Describer.php +++ b/core/vendor/tracy/tracy/src/Tracy/Dumper/Describer.php @@ -8,7 +8,7 @@ namespace Tracy\Dumper; use Tracy\Helpers; -use function array_map, array_slice, class_exists, count, file, get_debug_type, get_resource_type, htmlspecialchars, implode, interface_exists, is_array, is_bool, is_float, is_int, is_object, is_resource, is_string, is_subclass_of, json_encode, method_exists, preg_match, spl_object_id, str_replace, strlen, strpos, strtolower, trim, uksort; +use function count, is_array, is_bool, is_float, is_int, is_object, is_resource, is_string, strlen; /** @@ -55,7 +55,7 @@ public function describe(mixed $var): \stdClass { // exposers are sorted from the most specific type to the most general; '' acts as the universal supertype $isSubtypeOf = fn(string $type, string $parent): bool => $parent === '' - || ((class_exists($type, autoload: false) || interface_exists($type, autoload: false)) && is_subclass_of($type, $parent)); + || ((class_exists($type, false) || interface_exists($type, autoload: false)) && is_subclass_of($type, $parent)); uksort($this->objectExposers, fn($a, $b): int => $isSubtypeOf($b, $a) <=> $isSubtypeOf($a, $b)); try { @@ -202,7 +202,14 @@ private function describeObject(object $obj, int $depth = 0): Value $described = $this->isSensitive((string) $k, $v, get_debug_type($obj)) // props come from user callbacks (__debugInfo, custom exposers) ? new Value(Value::TypeText, self::hideValue($v)) : null; - $this->addPropertyTo($value, (string) $k, $v, Value::PropertyVirtual, $this->getReferenceId($props ?? [], $k), described: $described); + $this->addPropertyTo( + $value, + (string) $k, + $v, + Value::PropertyVirtual, + $this->getReferenceId($props ?? [], $k), + described: $described, + ); } } diff --git a/core/vendor/tracy/tracy/src/Tracy/Dumper/Dumper.php b/core/vendor/tracy/tracy/src/Tracy/Dumper/Dumper.php index 552967c9a4..971859bf40 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Dumper/Dumper.php +++ b/core/vendor/tracy/tracy/src/Tracy/Dumper/Dumper.php @@ -9,12 +9,8 @@ use Dom; use Ds; -use Tracy\Dumper\Describer; -use Tracy\Dumper\Exposer; -use Tracy\Dumper\Renderer; +use Tracy\Dumper\{Describer, Exposer, Renderer}; use Uri; -use function array_flip, array_map, file_get_contents, fwrite, str_replace; -use const STDOUT; /** @@ -105,8 +101,8 @@ class Dumper /** @var array */ private static array $enumProperties = []; - private Describer $describer; - private Renderer $renderer; + private readonly Describer $describer; + private readonly Renderer $renderer; /** diff --git a/core/vendor/tracy/tracy/src/Tracy/Dumper/Exposer.php b/core/vendor/tracy/tracy/src/Tracy/Dumper/Exposer.php index 87106d7f35..abc5f5a1ff 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Dumper/Exposer.php +++ b/core/vendor/tracy/tracy/src/Tracy/Dumper/Exposer.php @@ -10,7 +10,7 @@ use Dom; use Ds; use Uri; -use function array_diff_key, array_key_exists, count, end, explode, get_debug_type, get_mangled_object_vars, implode, iterator_to_array, sort; +use function array_key_exists, count; use const PHP_VERSION_ID; diff --git a/core/vendor/tracy/tracy/src/Tracy/Dumper/Renderer.php b/core/vendor/tracy/tracy/src/Tracy/Dumper/Renderer.php index e849c6f79f..d713159949 100644 --- a/core/vendor/tracy/tracy/src/Tracy/Dumper/Renderer.php +++ b/core/vendor/tracy/tracy/src/Tracy/Dumper/Renderer.php @@ -8,7 +8,7 @@ namespace Tracy\Dumper; use Tracy\Helpers; -use function count, htmlspecialchars, is_array, is_bool, is_float, is_int, is_object, is_string, str_repeat, str_replace, strlen, strrpos, substr, substr_count; +use function count, is_array, is_bool, is_float, is_int, is_object, is_string, strlen; /** @@ -53,7 +53,7 @@ public function renderAsHtml(\stdClass $model): string $html = $this->renderVar($value); $json = $snapshot = null; - } elseif ($this->lazy && (is_array($value) && $value || is_object($value))) { // full lazy-loading + } elseif ($this->lazy && ((is_array($value) && $value) || is_object($value))) { // full lazy-loading $html = ''; $snapshot = $this->collectingMode ? null : $this->snapshot; $json = $value; @@ -82,10 +82,10 @@ public function renderAsHtml(\stdClass $model): string } return '
 100 ? "\n" : '')
+			. ($json && $this->collapseTop === true ? ' tracy-collapsed' : '') . '"'
+			. ($snapshot !== null ? " data-tracy-snapshot='" . Helpers::jsonEncode($snapshot) . "'" : '')
+			. ($json ? " data-tracy-dump='" . Helpers::jsonEncode($json) . "'" : '')
+			. ($location || strlen($html) > 100 ? "\n" : '')
 			. '>'
 			. $location
 			. $html
@@ -231,7 +231,11 @@ private function renderArray(array|Value $array, int $depth): string
 				if ($this->lazy !== false) {
 					$ref = new Value(Value::TypeRef, $array->id);
 					$this->copySnapshot($ref);
-					return '" . $out . '';
+					return '"
+						. $out
+						. '';
 
 				} elseif ($this->hash) {
 					return $out . (isset($this->above[$array->id]) ? ' see above' : ' see below');
@@ -314,7 +318,11 @@ private function renderObject(Value $object, int $depth): string
 			if ($this->lazy !== false) {
 				$ref = new Value(Value::TypeRef, $object->id);
 				$this->copySnapshot($ref);
-				return '" . $out . '';
+				return '"
+					. $out
+					. '';
 
 			} elseif ($this->hash) {
 				return $out . (isset($this->above[$object->id]) ? ' see above' : ' see below');
@@ -368,7 +376,11 @@ private function renderResource(Value $resource, int $depth): string
 			if ($this->lazy !== false) {
 				$ref = new Value(Value::TypeRef, $resource->id);
 				$this->copySnapshot($ref);
-				return '" . $out . '';
+				return '"
+					. $out
+					. '';
 			}
 
 			return $out . ' see above';
diff --git a/core/vendor/tracy/tracy/src/Tracy/Helpers.php b/core/vendor/tracy/tracy/src/Tracy/Helpers.php
index 96258dd709..5c7a8da1b3 100644
--- a/core/vendor/tracy/tracy/src/Tracy/Helpers.php
+++ b/core/vendor/tracy/tracy/src/Tracy/Helpers.php
@@ -7,8 +7,8 @@
 
 namespace Tracy;
 
-use function array_filter, array_map, array_merge, array_pop, array_slice, array_unique, basename, bin2hex, class_exists, constant, count, dechex, defined, dirname, end, escapeshellarg, explode, extension_loaded, func_get_args, function_exists, get_class_methods, get_declared_classes, get_defined_functions, getenv, getmypid, headers_list, htmlspecialchars, htmlspecialchars_decode, iconv_strlen, implode, in_array, ini_set, is_a, is_array, is_callable, is_file, is_object, is_string, json_encode, levenshtein, ltrim, mb_strlen, mb_substr, method_exists, ob_end_clean, ob_get_clean, ob_start, ord, preg_match, preg_replace, preg_replace_callback, random_bytes, rawurlencode, rtrim, sapi_windows_vt100_support, spl_object_id, str_contains, str_pad, str_replace, strcasecmp, stream_isatty, strip_tags, strlen, strtoupper, strtr, substr, trait_exists, utf8_decode;
-use const DIRECTORY_SEPARATOR, ENT_HTML5, ENT_QUOTES, ENT_SUBSTITUTE, JSON_HEX_AMP, JSON_HEX_APOS, JSON_HEX_TAG, JSON_INVALID_UTF8_SUBSTITUTE, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE, PHP_EOL, PHP_SAPI, STDOUT, STR_PAD_LEFT;
+use function count, defined, extension_loaded, func_get_args, function_exists, is_array, is_object, is_string, strlen;
+use const ENT_HTML5, ENT_QUOTES, ENT_SUBSTITUTE, PHP_SAPI;
 
 
 /**
@@ -40,6 +40,7 @@ public static function editorLink(string $file, ?int $line = null): string
 				basename($file),
 				$line ? ":$line" : '',
 			);
+
 		} else {
 			return self::formatHtml('%', $file . ($line ? ":$line" : ''));
 		}
@@ -141,7 +142,10 @@ public static function countTransparentFrames(array $trace, ?array $paths = null
 			$next = $trace[$key + 1] ?? null;
 			$nextReflection = match (true) {
 				$next === null => null,
-				isset($next['class'], $next['function']) && method_exists($next['class'], $next['function']) => new \ReflectionMethod($next['class'], $next['function']),
+				isset($next['class'], $next['function']) && method_exists($next['class'], $next['function']) => new \ReflectionMethod(
+					$next['class'],
+					$next['function'],
+				),
 				isset($next['function']) && function_exists($next['function']) => new \ReflectionFunction($next['function']),
 				default => null,
 			};
@@ -256,15 +260,19 @@ public static function improveException(\Throwable $e): void
 			} elseif (is_array($arg) && !method_exists($arg[0], $arg[1])) {
 				$hint = self::getSuggestion(get_class_methods($arg[0]) ?: [], $arg[1]);
 				$class = is_object($arg[0]) ? $arg[0]::class : $arg[0];
-				$message = str_replace($m[2], "but method $class::$arg[1]() does not exist" . ($hint ? " (did you mean $hint?)" : ''), $message);
+				$message = str_replace(
+					$m[2],
+					"but method $class::$arg[1]() does not exist" . ($hint ? " (did you mean $hint?)" : ''),
+					$message,
+				);
 			} elseif (is_string($arg) && !function_exists($arg)) {
-				$funcs = array_merge(get_defined_functions()['internal'], get_defined_functions()['user']);
+				$funcs = [...get_defined_functions()['internal'], ...get_defined_functions()['user']];
 				$hint = self::getSuggestion($funcs, $arg);
 				$message = str_replace($m[2], "but function '$arg' does not exist" . ($hint ? " (did you mean $hint?)" : ''), $message);
 			}
 
 		} elseif (preg_match('#^Call to undefined function (\S+\\\)?(\w+)\(#', $message, $m)) {
-			$funcs = array_merge(get_defined_functions()['internal'], get_defined_functions()['user']);
+			$funcs = [...get_defined_functions()['internal'], ...get_defined_functions()['user']];
 			if ($hint = self::getSuggestion($funcs, $m[1] . $m[2]) ?: self::getSuggestion($funcs, $m[2])) {
 				$message = "Call to undefined function $m[2](), did you mean $hint()?";
 				$replace = ["$m[2](", "$hint("];
@@ -303,7 +311,7 @@ public static function improveException(\Throwable $e): void
 
 		if (isset($replace)) {
 			$loc = Debugger::mapSource($e->getFile(), $e->getLine()) ?? ['file' => $e->getFile(), 'line' => $e->getLine()];
-			@$e->tracyAction = [ // dynamic properties are deprecated since PHP 8.2
+			@$e->tracyAction = [ // @ dynamic properties are deprecated
 				'link' => self::editorUri($loc['file'], $loc['line'], 'fix', $replace[0], $replace[1]),
 				'label' => 'fix it',
 			];
@@ -341,7 +349,7 @@ public static function guessClassFile(string $class): ?string
 
 			if ($i > $max && $i < count($segments) && ($file = (new \ReflectionClass($class))->getFileName())) {
 				$max = $i;
-				$res = array_merge(array_slice(explode(DIRECTORY_SEPARATOR, $file), 0, $i - count($parts)), array_slice($segments, $i));
+				$res = [...array_slice(explode(DIRECTORY_SEPARATOR, $file), 0, $i - count($parts)), ...array_slice($segments, $i)];
 				$res = implode(DIRECTORY_SEPARATOR, $res) . '.php';
 			}
 		}
@@ -359,7 +367,10 @@ public static function getSuggestion(array $items, string $value): ?string
 	{
 		$best = null;
 		$min = (strlen($value) / 4 + 1) * 10 + .1;
-		$items = array_map(fn($item) => $item instanceof \ReflectionMethod || $item instanceof \ReflectionProperty ? $item->getName() : (string) $item, $items);
+		$items = array_map(
+			fn($item) => $item instanceof \ReflectionMethod || $item instanceof \ReflectionProperty ? $item->getName() : (string) $item,
+			$items,
+		);
 		foreach (array_unique($items) as $item) {
 			if (($len = levenshtein($item, $value, 10, 11, 10)) > 0 && $len < $min) {
 				$min = $len;
@@ -421,7 +432,11 @@ public static function isCli(): bool
 	/** @internal */
 	public static function getNonce(bool $attr = false): ?string
 	{
-		return preg_match('#^Content-Security-Policy(?:-Report-Only)?:.*\sscript-src(?:-elem)?\s+(?:[^;]+\s)?\'nonce-([\w+/]+=*)\'#mi', implode("\n", headers_list()), $m)
+		return preg_match(
+			'#^Content-Security-Policy(?:-Report-Only)?:.*\sscript-src(?:-elem)?\s+(?:[^;]+\s)?\'nonce-([\w+/]+=*)\'#mi',
+			implode("\n", headers_list()),
+			$m,
+		)
 			? ($attr ? ' nonce="' . self::escapeHtml($m[1]) . '"' : $m[1])
 			: null;
 	}
@@ -496,8 +511,8 @@ private static function doEncodeString(string $s, bool $utf8, bool $showWhitespa
 		$s = preg_replace_callback(
 			$utf8 ? '#[\p{C}<&]#u' : '#[\x00-\x1F\x7F-\xFF<&]#',
 			fn($m) => $special[$m[0]] ?? (strlen($m[0]) === 1
-					? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) . ''
-					: '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}'),
+				? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) . ''
+				: '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}'),
 			$s,
 		);
 		$s = str_replace('', '', $s);
@@ -527,7 +542,7 @@ public static function utf8Length(string $s): int
 		return match (true) {
 			extension_loaded('mbstring') => mb_strlen($s, 'UTF-8'),
 			extension_loaded('iconv') => iconv_strlen($s, 'UTF-8') ?: strlen($s),
-			default => strlen(@utf8_decode($s)), // deprecated
+			default => strlen(@utf8_decode($s)), // @ utf8_decode() is deprecated
 		};
 	}
 
@@ -682,7 +697,7 @@ public static function detectColors(): bool
 	public static function getExceptionChain(\Throwable $ex): array
 	{
 		$res = [$ex];
-		while (($ex = $ex->getPrevious()) && !in_array($ex, $res, strict: true)) {
+		while (($ex = $ex->getPrevious()) && !in_array($ex, $res, true)) {
 			$res[] = $ex;
 		}
 
@@ -759,7 +774,15 @@ public static function jsonEncode(mixed $value, bool $inScript = false): string
 	{
 		$old = @ini_set('serialize_precision', '-1'); // @ may be disabled
 		try {
-			return json_encode($value, ($inScript ? JSON_HEX_TAG : 0) | JSON_HEX_APOS | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
+			return json_encode(
+				$value,
+				($inScript ? JSON_HEX_TAG : 0)
+				| JSON_HEX_APOS
+				| JSON_HEX_AMP
+				| JSON_UNESCAPED_UNICODE
+				| JSON_UNESCAPED_SLASHES
+				| JSON_INVALID_UTF8_SUBSTITUTE,
+			);
 		} finally {
 			if ($old !== false) {
 				ini_set('serialize_precision', $old);
diff --git a/core/vendor/tracy/tracy/src/Tracy/Logger/Logger.php b/core/vendor/tracy/tracy/src/Tracy/Logger/Logger.php
index 39b5d458cb..c77d9f7039 100644
--- a/core/vendor/tracy/tracy/src/Tracy/Logger/Logger.php
+++ b/core/vendor/tracy/tracy/src/Tracy/Logger/Logger.php
@@ -7,8 +7,8 @@
 
 namespace Tracy;
 
-use function in_array, is_string;
-use const DIRECTORY_SEPARATOR, FILE_APPEND, LOCK_EX, LOCK_UN, PHP_EOL;
+use function is_string;
+use const DIRECTORY_SEPARATOR, FILE_APPEND, LOCK_EX;
 
 
 /**
@@ -74,7 +74,7 @@ public function log(mixed $message, string $level = self::INFO)
 			$this->logException($message, $exceptionFile);
 		}
 
-		if (in_array($level, [self::ERROR, self::EXCEPTION, self::CRITICAL], strict: true)) {
+		if (in_array($level, [self::ERROR, self::EXCEPTION, self::CRITICAL], true)) {
 			$this->sendEmail($message);
 		}
 
@@ -92,7 +92,10 @@ public static function formatMessage($message): string
 			foreach (Helpers::getExceptionChain($message) as $exception) {
 				$tmp[] = ($exception instanceof \ErrorException
 					? Helpers::errorTypeToString($exception->getSeverity()) . ': ' . $exception->getMessage()
-					: get_debug_type($exception) . ': ' . $exception->getMessage() . ($exception->getCode() ? ' #' . $exception->getCode() : '')
+					: get_debug_type($exception)
+						. ': '
+						. $exception->getMessage()
+						. ($exception->getCode() ? ' #' . $exception->getCode() : '')
 				) . ' in ' . $exception->getFile() . ':' . $exception->getLine();
 			}
 
diff --git a/core/vendor/tracy/tracy/src/Tracy/OutputDebugger/OutputDebugger.php b/core/vendor/tracy/tracy/src/Tracy/OutputDebugger/OutputDebugger.php
index 87b21605e3..a4841ac46d 100644
--- a/core/vendor/tracy/tracy/src/Tracy/OutputDebugger/OutputDebugger.php
+++ b/core/vendor/tracy/tracy/src/Tracy/OutputDebugger/OutputDebugger.php
@@ -7,8 +7,7 @@
 
 namespace Tracy;
 
-use function array_slice, count;
-use const PHP_OUTPUT_HANDLER_FINAL;
+use function count;
 
 
 /**
diff --git a/core/vendor/tracy/tracy/src/Tracy/Session/FileSession.php b/core/vendor/tracy/tracy/src/Tracy/Session/FileSession.php
index 2c13182f48..f778f6df2a 100644
--- a/core/vendor/tracy/tracy/src/Tracy/Session/FileSession.php
+++ b/core/vendor/tracy/tracy/src/Tracy/Session/FileSession.php
@@ -8,7 +8,6 @@
 namespace Tracy;
 
 use function is_string;
-use const LOCK_EX, LOCK_UN;
 
 
 /**
@@ -53,7 +52,7 @@ private function open(): void
 		if (
 			!is_string($id)
 			|| !preg_match('#^\w{10}\z#i', $id)
-			|| !($file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'r+')) // intentionally @
+			|| !($file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'r+')) // @ file may not exist
 		) {
 			$id = Helpers::createId();
 			setcookie($this->cookieName, $id, [
@@ -64,7 +63,7 @@ private function open(): void
 				'samesite' => 'Lax',
 			]);
 
-			$file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'c+'); // intentionally @
+			$file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'c+'); // @ is escalated to exception
 			if ($file === false) {
 				throw new \RuntimeException("Unable to create file '$path'. " . (error_get_last()['message'] ?? ''));
 			}
@@ -72,7 +71,7 @@ private function open(): void
 			@chmod($path, 0o600); // @ - may not be supported by the filesystem
 		}
 
-		if (!@flock($file, LOCK_EX)) { // intentionally @
+		if (!@flock($file, LOCK_EX)) { // @ is escalated to exception
 			throw new \RuntimeException("Unable to acquire exclusive lock on '$path'. " . (error_get_last()['message'] ?? ''));
 		}
 
diff --git a/core/vendor/tracy/tracy/src/Tracy/Session/NativeSession.php b/core/vendor/tracy/tracy/src/Tracy/Session/NativeSession.php
index b662ed2e82..9020ae2c24 100644
--- a/core/vendor/tracy/tracy/src/Tracy/Session/NativeSession.php
+++ b/core/vendor/tracy/tracy/src/Tracy/Session/NativeSession.php
@@ -7,8 +7,6 @@
 
 namespace Tracy;
 
-use const PHP_SESSION_ACTIVE;
-
 
 /**
  * Session storage backed by native PHP session ($_SESSION).
diff --git a/core/vendor/tracy/tracy/tools/create-phar/create-phar.php b/core/vendor/tracy/tracy/tools/create-phar/create-phar.php
index 8905778ccf..e08e291436 100644
--- a/core/vendor/tracy/tracy/tools/create-phar/create-phar.php
+++ b/core/vendor/tracy/tracy/tools/create-phar/create-phar.php
@@ -16,7 +16,10 @@
 ");
 
 $phar->startBuffering();
-foreach ($iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/../../src', RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
+foreach ($iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
+	__DIR__ . '/../../src',
+	RecursiveDirectoryIterator::SKIP_DOTS,
+)) as $file) {
 	if (!in_array($file->getExtension(), ['php', 'phtml', 'js', 'css'], true)) {
 		continue;
 	}
diff --git a/core/vendor/vlucas/phpdotenv/composer.json b/core/vendor/vlucas/phpdotenv/composer.json
index 60aa7a58fd..db6e40b9e8 100644
--- a/core/vendor/vlucas/phpdotenv/composer.json
+++ b/core/vendor/vlucas/phpdotenv/composer.json
@@ -54,7 +54,7 @@
             "forward-command": false
         },
         "branch-alias": {
-            "dev-master": "5.6-dev"
+            "dev-master": "5.8-dev"
         }
     }
 }
diff --git a/core/vendor/vlucas/phpdotenv/src/Parser/Lines.php b/core/vendor/vlucas/phpdotenv/src/Parser/Lines.php
index de290c5a4b..e2a212892e 100644
--- a/core/vendor/vlucas/phpdotenv/src/Parser/Lines.php
+++ b/core/vendor/vlucas/phpdotenv/src/Parser/Lines.php
@@ -43,6 +43,11 @@ public static function process(array $lines)
             }
         }
 
+        // Emit an unterminated multiline value so the parser reports the missing quote.
+        if ($multiline) {
+            $output[] = \implode("\n", $multilineBuffer);
+        }
+
         return $output;
     }
 
@@ -111,11 +116,7 @@ private static function looksLikeMultilineStart(string $line)
      */
     private static function looksLikeMultilineStop(string $line, bool $started)
     {
-        if ($line === '"') {
-            return true;
-        }
-
-        return Regex::occurrences('/(?=([^\\\\]"))/', \str_replace('\\\\', '', $line))->map(static function (int $count) use ($started) {
+        return Regex::occurrences('/(?map(static function (int $count) use ($started) {
             return $started ? $count > 1 : $count >= 1;
         })->success()->getOrElse(false);
     }
diff --git a/manager/actions/files.dynamic.php b/manager/actions/files.dynamic.php
index 537a330915..e9b2614711 100755
--- a/manager/actions/files.dynamic.php
+++ b/manager/actions/files.dynamic.php
@@ -7,6 +7,7 @@
 }
 $token_check = checkToken();
 $newToken = makeToken();
+$tokenRefusedMessage = '' . e(\EvolutionCMS\Support\CsrfMessage::text()) . '

'; // settings $theme_image_path = EVO_MANAGER_URL . 'media/style/' . evo()->getConfig('manager_theme') . '/images/'; $excludes = [ @@ -157,11 +158,11 @@ function fileManagerDeleteDirectoryZip(array $zipPaths): void fileManagerDeleteDirectoryZip($directoryZipPaths); $directoryZipMessage = '' . $_lang['files_zip_deleted'] . '

'; } else { - $directoryZipMessage = 'Invalid token

'; + $directoryZipMessage = $tokenRefusedMessage; } } elseif (get_by_key($_REQUEST, 'mode') == 'downloadzip') { if (!$token_check) { - $directoryZipMessage = 'Invalid token

'; + $directoryZipMessage = $tokenRefusedMessage; } elseif ($directoryZipBlocked) { $directoryZipMessage = '' . $_lang['files_zip_unavailable'] . '

'; } elseif (fileManagerDirectoryZipExists($directoryZipPaths)) { @@ -333,19 +334,19 @@ function renameFile(file) { if ($token_check) { $information = fileupload(); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } elseif (get_by_key($_POST, 'mode') == 'save') { if ($token_check) { echo textsave(); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } elseif (get_by_key($_REQUEST, 'mode') == 'delete') { if ($token_check) { echo delete_file(); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } elseif (get_by_key($_POST, 'mode') == 'savegroups') { if ($token_check) { @@ -421,7 +422,7 @@ function renameFile(file) { } } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } echo $directoryZipMessage; @@ -521,7 +522,7 @@ function renameFile(file) { } } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // End Unzip - Raymond @@ -549,7 +550,7 @@ function renameFile(file) { \EvolutionCMS\Support\FileManagerAccess::forgetRestrictions(fileManagerAclKey($folderTarget['relative'])); } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Create folder here @@ -572,7 +573,7 @@ function renameFile(file) { } umask($old_umask); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Create file here @@ -595,7 +596,7 @@ function renameFile(file) { umask($old_umask); } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Duplicate file here @@ -634,7 +635,7 @@ function renameFile(file) { } } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Copy folder here, beside the original @@ -715,7 +716,7 @@ function renameFile(file) { } umask($old_umask); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Delete a link here: the link itself, never what it leads to @@ -741,7 +742,7 @@ function renameFile(file) { echo '' . $_lang['files_link_deleted'] . '

'; } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Rename folder here @@ -782,7 +783,7 @@ function renameFile(file) { umask($old_umask); } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Rename file here @@ -820,7 +821,7 @@ function renameFile(file) { } } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } } // End New Folder - Raymond diff --git a/manager/index.php b/manager/index.php index 79169f1bac..b032b2ca25 100755 --- a/manager/index.php +++ b/manager/index.php @@ -116,6 +116,8 @@ header('Pragma: no-cache'); header('X-UA-Compatible: IE=edge;FF=3;OtherUA=4'); header('X-XSS-Protection: 0'); +// The manager's own POSTs need a Referer (validate_referer), whatever policy a proxy sets; other sites get none +header('Referrer-Policy: same-origin'); $php_ver_comp = version_compare(phpversion(), "8.3"); // -1 if left is less, 0 if equal, +1 if left is higher diff --git a/manager/media/browser/mcpuk/browse.php b/manager/media/browser/mcpuk/browse.php index 14a8b90962..ff7d19d1b1 100755 --- a/manager/media/browser/mcpuk/browse.php +++ b/manager/media/browser/mcpuk/browse.php @@ -49,7 +49,7 @@ function returnNoPermissionsMessage($role) { if ($token === '' || !hash_equals(csrf_token(), $token)) { header('HTTP/1.1 403 Forbidden'); header('Content-Type: text/plain; charset=utf-8'); - die(json_encode(['error' => 'Invalid CSRF token.'])); + die(json_encode(['error' => \EvolutionCMS\Support\CsrfMessage::text()], JSON_UNESCAPED_UNICODE)); } } diff --git a/manager/media/browser/mcpuk/tpl/tpl_browser.php b/manager/media/browser/mcpuk/tpl/tpl_browser.php index b6810b692e..4660b0afad 100755 --- a/manager/media/browser/mcpuk/tpl/tpl_browser.php +++ b/manager/media/browser/mcpuk/tpl/tpl_browser.php @@ -5,6 +5,7 @@ KCFinder: / <?php echo $this->session['dir'] ?> + diff --git a/manager/media/script/tests/document-save-helper.test.js b/manager/media/script/tests/document-save-helper.test.js index 643f0f241c..cde0bbe487 100644 --- a/manager/media/script/tests/document-save-helper.test.js +++ b/manager/media/script/tests/document-save-helper.test.js @@ -52,8 +52,8 @@ test('parseResponse tells a save apart from a refusal and from a non-JSON answer const denied = helper.parseResponse(422, '{"success":false,"message":"duplicate alias"}'); assert.deepEqual(denied, { ok: false, message: 'duplicate alias', unconfirmed: false }); - const csrf = helper.parseResponse(403, '{"error":"CSRF token mismatch","code":"csrf_token_mismatch"}'); - assert.deepEqual(csrf, { ok: false, message: '', unconfirmed: false }); + const csrf = helper.parseResponse(403, '{"error":"CSRF token mismatch","code":"csrf_token_mismatch","message":"Reload the page"}'); + assert.deepEqual(csrf, { ok: false, message: 'Reload the page', unconfirmed: false }); // the login page, a plugin that exited, a fatal error: the save may have run and is never replayed assert.deepEqual(helper.parseResponse(200, 'login'), { ok: false, message: '', unconfirmed: true }); diff --git a/manager/media/style/common/login.tpl b/manager/media/style/common/login.tpl index 71e99bd584..27da22f2d2 100755 --- a/manager/media/style/common/login.tpl +++ b/manager/media/style/common/login.tpl @@ -3,6 +3,7 @@ [+site_name_text+] (Evolution CMS Manager Login) + @@ -91,6 +92,23 @@ window.location = self.document.location; } var form = document.loginfrm; + // A refusal can come back as JSON (CSRF checks) or as a whole page; alert only its text. + function loginResponseMessage(xhr) { + var text = String(xhr.responseText || ''); + try { + var body = JSON.parse(text); + if (body && typeof body.message === 'string' && body.message !== '') { + return body.message; + } + } catch (err) {} + if (/^\s* [+site_name_text+] (Evolution CMS Content Manager) +