From 5ca4bd39e39626df4854227afcf1035e6f89327f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0pa=C4=8Dek?= Date: Sun, 20 Oct 2024 05:38:43 +0200 Subject: [PATCH 1/3] Install composer/pcre for better regexp static analysis --- app/composer.json | 1 + app/composer.lock | 81 +++- app/vendor/composer/autoload_psr4.php | 1 + app/vendor/composer/autoload_static.php | 5 + app/vendor/composer/installed.json | 82 ++++ app/vendor/composer/installed.php | 9 + app/vendor/composer/pcre/LICENSE | 19 + app/vendor/composer/pcre/README.md | 189 ++++++++ app/vendor/composer/pcre/composer.json | 54 +++ app/vendor/composer/pcre/extension.neon | 22 + .../composer/pcre/src/MatchAllResult.php | 46 ++ .../pcre/src/MatchAllStrictGroupsResult.php | 46 ++ .../pcre/src/MatchAllWithOffsetsResult.php | 48 ++ app/vendor/composer/pcre/src/MatchResult.php | 39 ++ .../pcre/src/MatchStrictGroupsResult.php | 39 ++ .../pcre/src/MatchWithOffsetsResult.php | 41 ++ .../src/PHPStan/InvalidRegexPatternRule.php | 142 ++++++ .../pcre/src/PHPStan/PregMatchFlags.php | 70 +++ .../PregMatchParameterOutTypeExtension.php | 65 +++ .../PregMatchTypeSpecifyingExtension.php | 105 +++++ ...regReplaceCallbackClosureTypeExtension.php | 91 ++++ .../PHPStan/UnsafeStrictGroupsCallRule.php | 112 +++++ .../composer/pcre/src/PcreException.php | 60 +++ app/vendor/composer/pcre/src/Preg.php | 430 ++++++++++++++++++ app/vendor/composer/pcre/src/Regex.php | 176 +++++++ .../composer/pcre/src/ReplaceResult.php | 43 ++ .../pcre/src/UnexpectedNullMatchException.php | 20 + 27 files changed, 2035 insertions(+), 1 deletion(-) create mode 100644 app/vendor/composer/pcre/LICENSE create mode 100644 app/vendor/composer/pcre/README.md create mode 100644 app/vendor/composer/pcre/composer.json create mode 100644 app/vendor/composer/pcre/extension.neon create mode 100644 app/vendor/composer/pcre/src/MatchAllResult.php create mode 100644 app/vendor/composer/pcre/src/MatchAllStrictGroupsResult.php create mode 100644 app/vendor/composer/pcre/src/MatchAllWithOffsetsResult.php create mode 100644 app/vendor/composer/pcre/src/MatchResult.php create mode 100644 app/vendor/composer/pcre/src/MatchStrictGroupsResult.php create mode 100644 app/vendor/composer/pcre/src/MatchWithOffsetsResult.php create mode 100644 app/vendor/composer/pcre/src/PHPStan/InvalidRegexPatternRule.php create mode 100644 app/vendor/composer/pcre/src/PHPStan/PregMatchFlags.php create mode 100644 app/vendor/composer/pcre/src/PHPStan/PregMatchParameterOutTypeExtension.php create mode 100644 app/vendor/composer/pcre/src/PHPStan/PregMatchTypeSpecifyingExtension.php create mode 100644 app/vendor/composer/pcre/src/PHPStan/PregReplaceCallbackClosureTypeExtension.php create mode 100644 app/vendor/composer/pcre/src/PHPStan/UnsafeStrictGroupsCallRule.php create mode 100644 app/vendor/composer/pcre/src/PcreException.php create mode 100644 app/vendor/composer/pcre/src/Preg.php create mode 100644 app/vendor/composer/pcre/src/Regex.php create mode 100644 app/vendor/composer/pcre/src/ReplaceResult.php create mode 100644 app/vendor/composer/pcre/src/UnexpectedNullMatchException.php diff --git a/app/composer.json b/app/composer.json index 3b3af8cd9..4a2555d95 100644 --- a/app/composer.json +++ b/app/composer.json @@ -17,6 +17,7 @@ "ext-pdo": "*", "ext-simplexml": "*", "ext-sodium": "*", + "composer/pcre": "^3.3.1", "contributte/translation": "^2.0", "latte/latte": "^3.0.3", "nette/application": "^3.1.10", diff --git a/app/composer.lock b/app/composer.lock index 99cddacd5..2110711ea 100644 --- a/app/composer.lock +++ b/app/composer.lock @@ -4,8 +4,87 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0df116706adcf062cbafc6a78517affd", + "content-hash": "d88e081f8984668ddeaed1f2a1cb4c9a", "packages": [ + { + "name": "composer/pcre", + "version": "3.3.1", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4", + "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.11.10", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-08-27T18:44:43+00:00" + }, { "name": "contributte/translation", "version": "v2.0.5", diff --git a/app/vendor/composer/autoload_psr4.php b/app/vendor/composer/autoload_psr4.php index 587670b4c..eb55fd94b 100644 --- a/app/vendor/composer/autoload_psr4.php +++ b/app/vendor/composer/autoload_psr4.php @@ -41,4 +41,5 @@ 'JetBrains\\PhpStorm\\' => array($vendorDir . '/jetbrains/phpstorm-attributes/src'), 'Efabrica\\PHPStanLatte\\' => array($vendorDir . '/efabrica/phpstan-latte/src'), 'Contributte\\Translation\\' => array($vendorDir . '/contributte/translation/src'), + 'Composer\\Pcre\\' => array($vendorDir . '/composer/pcre/src'), ); diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php index 8adf35650..e0f82843b 100644 --- a/app/vendor/composer/autoload_static.php +++ b/app/vendor/composer/autoload_static.php @@ -66,6 +66,7 @@ class ComposerStaticInit247de957f14f643f393d210a332dd05b 'C' => array ( 'Contributte\\Translation\\' => 24, + 'Composer\\Pcre\\' => 14, ), ); @@ -211,6 +212,10 @@ class ComposerStaticInit247de957f14f643f393d210a332dd05b array ( 0 => __DIR__ . '/..' . '/contributte/translation/src', ), + 'Composer\\Pcre\\' => + array ( + 0 => __DIR__ . '/..' . '/composer/pcre/src', + ), ); public static $classMap = array ( diff --git a/app/vendor/composer/installed.json b/app/vendor/composer/installed.json index 8bc385f56..2a3b3caef 100644 --- a/app/vendor/composer/installed.json +++ b/app/vendor/composer/installed.json @@ -1,5 +1,87 @@ { "packages": [ + { + "name": "composer/pcre", + "version": "3.3.1", + "version_normalized": "3.3.1.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4", + "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.11.10", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8 || ^9" + }, + "time": "2024-08-27T18:44:43+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "install-path": "./pcre" + }, { "name": "contributte/translation", "version": "v2.0.5", diff --git a/app/vendor/composer/installed.php b/app/vendor/composer/installed.php index 5b4f47ba1..7cb2c8c23 100644 --- a/app/vendor/composer/installed.php +++ b/app/vendor/composer/installed.php @@ -10,6 +10,15 @@ 'dev' => true, ), 'versions' => array( + 'composer/pcre' => array( + 'pretty_version' => '3.3.1', + 'version' => '3.3.1.0', + 'reference' => '63aaeac21d7e775ff9bc9d45021e1745c97521c4', + 'type' => 'library', + 'install_path' => __DIR__ . '/./pcre', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'contributte/translation' => array( 'pretty_version' => 'v2.0.5', 'version' => '2.0.5.0', diff --git a/app/vendor/composer/pcre/LICENSE b/app/vendor/composer/pcre/LICENSE new file mode 100644 index 000000000..c5a282ff4 --- /dev/null +++ b/app/vendor/composer/pcre/LICENSE @@ -0,0 +1,19 @@ +Copyright (C) 2021 Composer + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/vendor/composer/pcre/README.md b/app/vendor/composer/pcre/README.md new file mode 100644 index 000000000..490651499 --- /dev/null +++ b/app/vendor/composer/pcre/README.md @@ -0,0 +1,189 @@ +composer/pcre +============= + +PCRE wrapping library that offers type-safe `preg_*` replacements. + +This library gives you a way to ensure `preg_*` functions do not fail silently, returning +unexpected `null`s that may not be handled. + +As of 3.0 this library enforces [`PREG_UNMATCHED_AS_NULL`](#preg_unmatched_as_null) usage +for all matching and replaceCallback functions, [read more below](#preg_unmatched_as_null) +to understand the implications. + +It thus makes it easier to work with static analysis tools like PHPStan or Psalm as it +simplifies and reduces the possible return values from all the `preg_*` functions which +are quite packed with edge cases. As of v2.2.0 / v3.2.0 the library also comes with a +[PHPStan extension](#phpstan-extension) for parsing regular expressions and giving you even better output types. + +This library is a thin wrapper around `preg_*` functions with [some limitations](#restrictions--limitations). +If you are looking for a richer API to handle regular expressions have a look at +[rawr/t-regx](https://packagist.org/packages/rawr/t-regx) instead. + +[![Continuous Integration](https://github.com/composer/pcre/workflows/Continuous%20Integration/badge.svg?branch=main)](https://github.com/composer/pcre/actions) + + +Installation +------------ + +Install the latest version with: + +```bash +$ composer require composer/pcre +``` + + +Requirements +------------ + +* PHP 7.4.0 is required for 3.x versions +* PHP 7.2.0 is required for 2.x versions +* PHP 5.3.2 is required for 1.x versions + + +Basic usage +----------- + +Instead of: + +```php +if (preg_match('{fo+}', $string, $matches)) { ... } +if (preg_match('{fo+}', $string, $matches, PREG_OFFSET_CAPTURE)) { ... } +if (preg_match_all('{fo+}', $string, $matches)) { ... } +$newString = preg_replace('{fo+}', 'bar', $string); +$newString = preg_replace_callback('{fo+}', function ($match) { return strtoupper($match[0]); }, $string); +$newString = preg_replace_callback_array(['{fo+}' => fn ($match) => strtoupper($match[0])], $string); +$filtered = preg_grep('{[a-z]}', $elements); +$array = preg_split('{[a-z]+}', $string); +``` + +You can now call these on the `Preg` class: + +```php +use Composer\Pcre\Preg; + +if (Preg::match('{fo+}', $string, $matches)) { ... } +if (Preg::matchWithOffsets('{fo+}', $string, $matches)) { ... } +if (Preg::matchAll('{fo+}', $string, $matches)) { ... } +$newString = Preg::replace('{fo+}', 'bar', $string); +$newString = Preg::replaceCallback('{fo+}', function ($match) { return strtoupper($match[0]); }, $string); +$newString = Preg::replaceCallbackArray(['{fo+}' => fn ($match) => strtoupper($match[0])], $string); +$filtered = Preg::grep('{[a-z]}', $elements); +$array = Preg::split('{[a-z]+}', $string); +``` + +The main difference is if anything fails to match/replace/.., it will throw a `Composer\Pcre\PcreException` +instead of returning `null` (or false in some cases), so you can now use the return values safely relying on +the fact that they can only be strings (for replace), ints (for match) or arrays (for grep/split). + +Additionally the `Preg` class provides match methods that return `bool` rather than `int`, for stricter type safety +when the number of pattern matches is not useful: + +```php +use Composer\Pcre\Preg; + +if (Preg::isMatch('{fo+}', $string, $matches)) // bool +if (Preg::isMatchAll('{fo+}', $string, $matches)) // bool +``` + +Finally the `Preg` class provides a few `*StrictGroups` method variants that ensure match groups +are always present and thus non-nullable, making it easier to write type-safe code: + +```php +use Composer\Pcre\Preg; + +// $matches is guaranteed to be an array of strings, if a subpattern does not match and produces a null it will throw +if (Preg::matchStrictGroups('{fo+}', $string, $matches)) +if (Preg::matchAllStrictGroups('{fo+}', $string, $matches)) +``` + +**Note:** This is generally safe to use as long as you do not have optional subpatterns (i.e. `(something)?` +or `(something)*` or branches with a `|` that result in some groups not being matched at all). +A subpattern that can match an empty string like `(.*)` is **not** optional, it will be present as an +empty string in the matches. A non-matching subpattern, even if optional like `(?:foo)?` will anyway not be present in +matches so it is also not a problem to use these with `*StrictGroups` methods. + +If you would prefer a slightly more verbose usage, replacing by-ref arguments by result objects, you can use the `Regex` class: + +```php +use Composer\Pcre\Regex; + +// this is useful when you are just interested in knowing if something matched +// as it returns a bool instead of int(1/0) for match +$bool = Regex::isMatch('{fo+}', $string); + +$result = Regex::match('{fo+}', $string); +if ($result->matched) { something($result->matches); } + +$result = Regex::matchWithOffsets('{fo+}', $string); +if ($result->matched) { something($result->matches); } + +$result = Regex::matchAll('{fo+}', $string); +if ($result->matched && $result->count > 3) { something($result->matches); } + +$newString = Regex::replace('{fo+}', 'bar', $string)->result; +$newString = Regex::replaceCallback('{fo+}', function ($match) { return strtoupper($match[0]); }, $string)->result; +$newString = Regex::replaceCallbackArray(['{fo+}' => fn ($match) => strtoupper($match[0])], $string)->result; +``` + +Note that `preg_grep` and `preg_split` are only callable via the `Preg` class as they do not have +complex return types warranting a specific result object. + +See the [MatchResult](src/MatchResult.php), [MatchWithOffsetsResult](src/MatchWithOffsetsResult.php), [MatchAllResult](src/MatchAllResult.php), +[MatchAllWithOffsetsResult](src/MatchAllWithOffsetsResult.php), and [ReplaceResult](src/ReplaceResult.php) class sources for more details. + +Restrictions / Limitations +-------------------------- + +Due to type safety requirements a few restrictions are in place. + +- matching using `PREG_OFFSET_CAPTURE` is made available via `matchWithOffsets` and `matchAllWithOffsets`. + You cannot pass the flag to `match`/`matchAll`. +- `Preg::split` will also reject `PREG_SPLIT_OFFSET_CAPTURE` and you should use `splitWithOffsets` + instead. +- `matchAll` rejects `PREG_SET_ORDER` as it also changes the shape of the returned matches. There + is no alternative provided as you can fairly easily code around it. +- `preg_filter` is not supported as it has a rather crazy API, most likely you should rather + use `Preg::grep` in combination with some loop and `Preg::replace`. +- `replace`, `replaceCallback` and `replaceCallbackArray` do not support an array `$subject`, + only simple strings. +- As of 2.0, the library always uses `PREG_UNMATCHED_AS_NULL` for matching, which offers [much + saner/more predictable results](#preg_unmatched_as_null). As of 3.0 the flag is also set for + `replaceCallback` and `replaceCallbackArray`. + +#### PREG_UNMATCHED_AS_NULL + +As of 2.0, this library always uses PREG_UNMATCHED_AS_NULL for all `match*` and `isMatch*` +functions. As of 3.0 it is also done for `replaceCallback` and `replaceCallbackArray`. + +This means your matches will always contain all matching groups, either as null if unmatched +or as string if it matched. + +The advantages in clarity and predictability are clearer if you compare the two outputs of +running this with and without PREG_UNMATCHED_AS_NULL in $flags: + +```php +preg_match('/(a)(b)*(c)(d)*/', 'ac', $matches, $flags); +``` + +| no flag | PREG_UNMATCHED_AS_NULL | +| --- | --- | +| array (size=4) | array (size=5) | +| 0 => string 'ac' (length=2) | 0 => string 'ac' (length=2) | +| 1 => string 'a' (length=1) | 1 => string 'a' (length=1) | +| 2 => string '' (length=0) | 2 => null | +| 3 => string 'c' (length=1) | 3 => string 'c' (length=1) | +| | 4 => null | +| group 2 (any unmatched group preceding one that matched) is set to `''`. You cannot tell if it matched an empty string or did not match at all | group 2 is `null` when unmatched and a string if it matched, easy to check for | +| group 4 (any optional group without a matching one following) is missing altogether. So you have to check with `isset()`, but really you want `isset($m[4]) && $m[4] !== ''` for safety unless you are very careful to check that a non-optional group follows it | group 4 is always set, and null in this case as there was no match, easy to check for with `$m[4] !== null` | + +PHPStan Extension +----------------- + +To use the PHPStan extension if you do not use `phpstan/extension-installer` you can include `vendor/composer/pcre/extension.neon` in your PHPStan config. + +The extension provides much better type information for $matches as well as regex validation where possible. + +License +------- + +composer/pcre is licensed under the MIT License, see the LICENSE file for details. diff --git a/app/vendor/composer/pcre/composer.json b/app/vendor/composer/pcre/composer.json new file mode 100644 index 000000000..30a2c953d --- /dev/null +++ b/app/vendor/composer/pcre/composer.json @@ -0,0 +1,54 @@ +{ + "name": "composer/pcre", + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "type": "library", + "license": "MIT", + "keywords": [ + "pcre", + "regex", + "preg", + "regular expression" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8 || ^9", + "phpstan/phpstan": "^1.11.10", + "phpstan/phpstan-strict-rules": "^1.1" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Composer\\Pcre\\": "tests" + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "scripts": { + "test": "@php vendor/bin/phpunit", + "phpstan": "@php phpstan analyse" + } +} diff --git a/app/vendor/composer/pcre/extension.neon b/app/vendor/composer/pcre/extension.neon new file mode 100644 index 000000000..b9cea113f --- /dev/null +++ b/app/vendor/composer/pcre/extension.neon @@ -0,0 +1,22 @@ +# composer/pcre PHPStan extensions +# +# These can be reused by third party packages by including 'vendor/composer/pcre/extension.neon' +# in your phpstan config + +services: + - + class: Composer\Pcre\PHPStan\PregMatchParameterOutTypeExtension + tags: + - phpstan.staticMethodParameterOutTypeExtension + - + class: Composer\Pcre\PHPStan\PregMatchTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension + - + class: Composer\Pcre\PHPStan\PregReplaceCallbackClosureTypeExtension + tags: + - phpstan.staticMethodParameterClosureTypeExtension + +rules: + - Composer\Pcre\PHPStan\UnsafeStrictGroupsCallRule + - Composer\Pcre\PHPStan\InvalidRegexPatternRule diff --git a/app/vendor/composer/pcre/src/MatchAllResult.php b/app/vendor/composer/pcre/src/MatchAllResult.php new file mode 100644 index 000000000..b22b52d6e --- /dev/null +++ b/app/vendor/composer/pcre/src/MatchAllResult.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +final class MatchAllResult +{ + /** + * An array of match group => list of matched strings + * + * @readonly + * @var array> + */ + public $matches; + + /** + * @readonly + * @var 0|positive-int + */ + public $count; + + /** + * @readonly + * @var bool + */ + public $matched; + + /** + * @param 0|positive-int $count + * @param array> $matches + */ + public function __construct(int $count, array $matches) + { + $this->matches = $matches; + $this->matched = (bool) $count; + $this->count = $count; + } +} diff --git a/app/vendor/composer/pcre/src/MatchAllStrictGroupsResult.php b/app/vendor/composer/pcre/src/MatchAllStrictGroupsResult.php new file mode 100644 index 000000000..b7ec39743 --- /dev/null +++ b/app/vendor/composer/pcre/src/MatchAllStrictGroupsResult.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +final class MatchAllStrictGroupsResult +{ + /** + * An array of match group => list of matched strings + * + * @readonly + * @var array> + */ + public $matches; + + /** + * @readonly + * @var 0|positive-int + */ + public $count; + + /** + * @readonly + * @var bool + */ + public $matched; + + /** + * @param 0|positive-int $count + * @param array> $matches + */ + public function __construct(int $count, array $matches) + { + $this->matches = $matches; + $this->matched = (bool) $count; + $this->count = $count; + } +} diff --git a/app/vendor/composer/pcre/src/MatchAllWithOffsetsResult.php b/app/vendor/composer/pcre/src/MatchAllWithOffsetsResult.php new file mode 100644 index 000000000..032a02cd3 --- /dev/null +++ b/app/vendor/composer/pcre/src/MatchAllWithOffsetsResult.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +final class MatchAllWithOffsetsResult +{ + /** + * An array of match group => list of matches, every match being a pair of string matched + offset in bytes (or -1 if no match) + * + * @readonly + * @var array> + * @phpstan-var array}>> + */ + public $matches; + + /** + * @readonly + * @var 0|positive-int + */ + public $count; + + /** + * @readonly + * @var bool + */ + public $matched; + + /** + * @param 0|positive-int $count + * @param array> $matches + * @phpstan-param array}>> $matches + */ + public function __construct(int $count, array $matches) + { + $this->matches = $matches; + $this->matched = (bool) $count; + $this->count = $count; + } +} diff --git a/app/vendor/composer/pcre/src/MatchResult.php b/app/vendor/composer/pcre/src/MatchResult.php new file mode 100644 index 000000000..e951a5ee5 --- /dev/null +++ b/app/vendor/composer/pcre/src/MatchResult.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +final class MatchResult +{ + /** + * An array of match group => string matched + * + * @readonly + * @var array + */ + public $matches; + + /** + * @readonly + * @var bool + */ + public $matched; + + /** + * @param 0|positive-int $count + * @param array $matches + */ + public function __construct(int $count, array $matches) + { + $this->matches = $matches; + $this->matched = (bool) $count; + } +} diff --git a/app/vendor/composer/pcre/src/MatchStrictGroupsResult.php b/app/vendor/composer/pcre/src/MatchStrictGroupsResult.php new file mode 100644 index 000000000..126ee6293 --- /dev/null +++ b/app/vendor/composer/pcre/src/MatchStrictGroupsResult.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +final class MatchStrictGroupsResult +{ + /** + * An array of match group => string matched + * + * @readonly + * @var array + */ + public $matches; + + /** + * @readonly + * @var bool + */ + public $matched; + + /** + * @param 0|positive-int $count + * @param array $matches + */ + public function __construct(int $count, array $matches) + { + $this->matches = $matches; + $this->matched = (bool) $count; + } +} diff --git a/app/vendor/composer/pcre/src/MatchWithOffsetsResult.php b/app/vendor/composer/pcre/src/MatchWithOffsetsResult.php new file mode 100644 index 000000000..ba4d4bc4d --- /dev/null +++ b/app/vendor/composer/pcre/src/MatchWithOffsetsResult.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +final class MatchWithOffsetsResult +{ + /** + * An array of match group => pair of string matched + offset in bytes (or -1 if no match) + * + * @readonly + * @var array + * @phpstan-var array}> + */ + public $matches; + + /** + * @readonly + * @var bool + */ + public $matched; + + /** + * @param 0|positive-int $count + * @param array $matches + * @phpstan-param array}> $matches + */ + public function __construct(int $count, array $matches) + { + $this->matches = $matches; + $this->matched = (bool) $count; + } +} diff --git a/app/vendor/composer/pcre/src/PHPStan/InvalidRegexPatternRule.php b/app/vendor/composer/pcre/src/PHPStan/InvalidRegexPatternRule.php new file mode 100644 index 000000000..8a05fb24a --- /dev/null +++ b/app/vendor/composer/pcre/src/PHPStan/InvalidRegexPatternRule.php @@ -0,0 +1,142 @@ + + */ +class InvalidRegexPatternRule implements Rule +{ + public function getNodeType(): string + { + return StaticCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $patterns = $this->extractPatterns($node, $scope); + + $errors = []; + foreach ($patterns as $pattern) { + $errorMessage = $this->validatePattern($pattern); + if ($errorMessage === null) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('Regex pattern is invalid: %s', $errorMessage))->identifier('regexp.pattern')->build(); + } + + return $errors; + } + + /** + * @return string[] + */ + private function extractPatterns(StaticCall $node, Scope $scope): array + { + if (!$node->class instanceof FullyQualified) { + return []; + } + $isRegex = $node->class->toString() === Regex::class; + $isPreg = $node->class->toString() === Preg::class; + if (!$isRegex && !$isPreg) { + return []; + } + if (!$node->name instanceof Node\Identifier || !Preg::isMatch('{^(match|isMatch|grep|replace|split)}', $node->name->name)) { + return []; + } + + $functionName = $node->name->name; + if (!isset($node->getArgs()[0])) { + return []; + } + + $patternNode = $node->getArgs()[0]->value; + $patternType = $scope->getType($patternNode); + + $patternStrings = []; + + foreach ($patternType->getConstantStrings() as $constantStringType) { + if ($functionName === 'replaceCallbackArray') { + continue; + } + + $patternStrings[] = $constantStringType->getValue(); + } + + foreach ($patternType->getConstantArrays() as $constantArrayType) { + if ( + in_array($functionName, [ + 'replace', + 'replaceCallback', + ], true) + ) { + foreach ($constantArrayType->getValueTypes() as $arrayKeyType) { + foreach ($arrayKeyType->getConstantStrings() as $constantString) { + $patternStrings[] = $constantString->getValue(); + } + } + } + + if ($functionName !== 'replaceCallbackArray') { + continue; + } + + foreach ($constantArrayType->getKeyTypes() as $arrayKeyType) { + foreach ($arrayKeyType->getConstantStrings() as $constantString) { + $patternStrings[] = $constantString->getValue(); + } + } + } + + return $patternStrings; + } + + private function validatePattern(string $pattern): ?string + { + try { + $msg = null; + $prev = set_error_handler(function (int $severity, string $message, string $file) use (&$msg): bool { + $msg = preg_replace("#^preg_match(_all)?\\(.*?\\): #", '', $message); + + return true; + }); + + if ($pattern === '') { + return 'Empty string is not a valid regular expression'; + } + + Preg::match($pattern, ''); + if ($msg !== null) { + return $msg; + } + } catch (PcreException $e) { + if ($e->getCode() === PREG_INTERNAL_ERROR && $msg !== null) { + return $msg; + } + + return preg_replace('{.*? failed executing ".*": }', '', $e->getMessage()); + } finally { + restore_error_handler(); + } + + return null; + } + +} diff --git a/app/vendor/composer/pcre/src/PHPStan/PregMatchFlags.php b/app/vendor/composer/pcre/src/PHPStan/PregMatchFlags.php new file mode 100644 index 000000000..aa30ab347 --- /dev/null +++ b/app/vendor/composer/pcre/src/PHPStan/PregMatchFlags.php @@ -0,0 +1,70 @@ +getType($flagsArg->value); + + $constantScalars = $flagsType->getConstantScalarValues(); + if ($constantScalars === []) { + return null; + } + + $internalFlagsTypes = []; + foreach ($flagsType->getConstantScalarValues() as $constantScalarValue) { + if (!is_int($constantScalarValue)) { + return null; + } + + $internalFlagsTypes[] = new ConstantIntegerType($constantScalarValue | PREG_UNMATCHED_AS_NULL); + } + return TypeCombinator::union(...$internalFlagsTypes); + } + + static public function removeNullFromMatches(Type $matchesType): Type + { + return TypeTraverser::map($matchesType, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof ConstantArrayType) { + return new ConstantArrayType( + $type->getKeyTypes(), + array_map(static function (Type $valueType) use ($traverse): Type { + return $traverse($valueType); + }, $type->getValueTypes()), + $type->getNextAutoIndexes(), + [], + $type->isList() + ); + } + + if ($type instanceof ArrayType) { + return new ArrayType($type->getKeyType(), $traverse($type->getItemType())); + } + + return TypeCombinator::removeNull($type); + }); + } + +} diff --git a/app/vendor/composer/pcre/src/PHPStan/PregMatchParameterOutTypeExtension.php b/app/vendor/composer/pcre/src/PHPStan/PregMatchParameterOutTypeExtension.php new file mode 100644 index 000000000..e0d60208e --- /dev/null +++ b/app/vendor/composer/pcre/src/PHPStan/PregMatchParameterOutTypeExtension.php @@ -0,0 +1,65 @@ +regexShapeMatcher = $regexShapeMatcher; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return + $methodReflection->getDeclaringClass()->getName() === Preg::class + && in_array($methodReflection->getName(), [ + 'match', 'isMatch', 'matchStrictGroups', 'isMatchStrictGroups', + 'matchAll', 'isMatchAll', 'matchAllStrictGroups', 'isMatchAllStrictGroups' + ], true) + && $parameter->getName() === 'matches'; + } + + public function getParameterOutTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + $patternArg = $args[0] ?? null; + $matchesArg = $args[2] ?? null; + $flagsArg = $args[3] ?? null; + + if ( + $patternArg === null || $matchesArg === null + ) { + return null; + } + + $flagsType = PregMatchFlags::getType($flagsArg, $scope); + if ($flagsType === null) { + return null; + } + + if (stripos($methodReflection->getName(), 'matchAll') !== false) { + return $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); + } + + return $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); + } + +} diff --git a/app/vendor/composer/pcre/src/PHPStan/PregMatchTypeSpecifyingExtension.php b/app/vendor/composer/pcre/src/PHPStan/PregMatchTypeSpecifyingExtension.php new file mode 100644 index 000000000..cf22f6020 --- /dev/null +++ b/app/vendor/composer/pcre/src/PHPStan/PregMatchTypeSpecifyingExtension.php @@ -0,0 +1,105 @@ +regexShapeMatcher = $regexShapeMatcher; + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + + public function getClass(): string + { + return Preg::class; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection, StaticCall $node, TypeSpecifierContext $context): bool + { + return in_array($methodReflection->getName(), [ + 'match', 'isMatch', 'matchStrictGroups', 'isMatchStrictGroups', + 'matchAll', 'isMatchAll', 'matchAllStrictGroups', 'isMatchAllStrictGroups' + ], true) + && !$context->null(); + } + + public function specifyTypes(MethodReflection $methodReflection, StaticCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $args = $node->getArgs(); + $patternArg = $args[0] ?? null; + $matchesArg = $args[2] ?? null; + $flagsArg = $args[3] ?? null; + + if ( + $patternArg === null || $matchesArg === null + ) { + return new SpecifiedTypes(); + } + + $flagsType = PregMatchFlags::getType($flagsArg, $scope); + if ($flagsType === null) { + return new SpecifiedTypes(); + } + + if (stripos($methodReflection->getName(), 'matchAll') !== false) { + $matchedType = $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); + } else { + $matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); + } + + if ($matchedType === null) { + return new SpecifiedTypes(); + } + + if ( + in_array($methodReflection->getName(), ['matchStrictGroups', 'isMatchStrictGroups', 'matchAllStrictGroups', 'isMatchAllStrictGroups'], true) + ) { + $matchedType = PregMatchFlags::removeNullFromMatches($matchedType); + } + + $overwrite = false; + if ($context->false()) { + $overwrite = true; + $context = $context->negate(); + } + + return $this->typeSpecifier->create( + $matchesArg->value, + $matchedType, + $context, + $overwrite, + $scope, + $node + ); + } +} diff --git a/app/vendor/composer/pcre/src/PHPStan/PregReplaceCallbackClosureTypeExtension.php b/app/vendor/composer/pcre/src/PHPStan/PregReplaceCallbackClosureTypeExtension.php new file mode 100644 index 000000000..7b9536725 --- /dev/null +++ b/app/vendor/composer/pcre/src/PHPStan/PregReplaceCallbackClosureTypeExtension.php @@ -0,0 +1,91 @@ +regexShapeMatcher = $regexShapeMatcher; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return in_array($methodReflection->getDeclaringClass()->getName(), [Preg::class, Regex::class], true) + && in_array($methodReflection->getName(), ['replaceCallback', 'replaceCallbackStrictGroups'], true) + && $parameter->getName() === 'replacement'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + $patternArg = $args[0] ?? null; + $flagsArg = $args[5] ?? null; + + if ( + $patternArg === null + ) { + return null; + } + + $flagsType = PregMatchFlags::getType($flagsArg, $scope); + + $matchesType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope); + if ($matchesType === null) { + return null; + } + + if ($methodReflection->getName() === 'replaceCallbackStrictGroups' && count($matchesType->getConstantArrays()) === 1) { + $matchesType = $matchesType->getConstantArrays()[0]; + $matchesType = new ConstantArrayType( + $matchesType->getKeyTypes(), + array_map(static function (Type $valueType): Type { + if (count($valueType->getConstantArrays()) === 1) { + $valueTypeArray = $valueType->getConstantArrays()[0]; + return new ConstantArrayType( + $valueTypeArray->getKeyTypes(), + array_map(static function (Type $valueType): Type { + return TypeCombinator::removeNull($valueType); + }, $valueTypeArray->getValueTypes()), + $valueTypeArray->getNextAutoIndexes(), + [], + $valueTypeArray->isList() + ); + } + return TypeCombinator::removeNull($valueType); + }, $matchesType->getValueTypes()), + $matchesType->getNextAutoIndexes(), + [], + $matchesType->isList() + ); + } + + return new ClosureType( + [ + new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $matchesType, $parameter->passedByReference(), $parameter->isVariadic(), $parameter->getDefaultValue()), + ], + new StringType() + ); + } +} diff --git a/app/vendor/composer/pcre/src/PHPStan/UnsafeStrictGroupsCallRule.php b/app/vendor/composer/pcre/src/PHPStan/UnsafeStrictGroupsCallRule.php new file mode 100644 index 000000000..5bced5070 --- /dev/null +++ b/app/vendor/composer/pcre/src/PHPStan/UnsafeStrictGroupsCallRule.php @@ -0,0 +1,112 @@ + + */ +final class UnsafeStrictGroupsCallRule implements Rule +{ + /** + * @var RegexArrayShapeMatcher + */ + private $regexShapeMatcher; + + public function __construct(RegexArrayShapeMatcher $regexShapeMatcher) + { + $this->regexShapeMatcher = $regexShapeMatcher; + } + + public function getNodeType(): string + { + return StaticCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof FullyQualified) { + return []; + } + $isRegex = $node->class->toString() === Regex::class; + $isPreg = $node->class->toString() === Preg::class; + if (!$isRegex && !$isPreg) { + return []; + } + if (!$node->name instanceof Node\Identifier || !in_array($node->name->name, ['matchStrictGroups', 'isMatchStrictGroups', 'matchAllStrictGroups', 'isMatchAllStrictGroups'], true)) { + return []; + } + + $args = $node->getArgs(); + if (!isset($args[0])) { + return []; + } + + $patternArg = $args[0] ?? null; + if ($isPreg) { + if (!isset($args[2])) { // no matches set, skip as the matches won't be used anyway + return []; + } + $flagsArg = $args[3] ?? null; + } else { + $flagsArg = $args[2] ?? null; + } + + if ($patternArg === null) { + return []; + } + + $flagsType = PregMatchFlags::getType($flagsArg, $scope); + if ($flagsType === null) { + return []; + } + + $matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope); + if ($matchedType === null) { + return [ + RuleErrorBuilder::message(sprintf('The %s call is potentially unsafe as $matches\' type could not be inferred.', $node->name->name)) + ->identifier('composerPcre.maybeUnsafeStrictGroups') + ->build(), + ]; + } + + if (count($matchedType->getConstantArrays()) === 1) { + $matchedType = $matchedType->getConstantArrays()[0]; + $nullableGroups = []; + foreach ($matchedType->getValueTypes() as $index => $type) { + if (TypeCombinator::containsNull($type)) { + $nullableGroups[] = $matchedType->getKeyTypes()[$index]->getValue(); + } + } + + if (\count($nullableGroups) > 0) { + return [ + RuleErrorBuilder::message(sprintf( + 'The %s call is unsafe as match group%s "%s" %s optional and may be null.', + $node->name->name, + \count($nullableGroups) > 1 ? 's' : '', + implode('", "', $nullableGroups), + \count($nullableGroups) > 1 ? 'are' : 'is' + ))->identifier('composerPcre.unsafeStrictGroups')->build(), + ]; + } + } + + return []; + } +} diff --git a/app/vendor/composer/pcre/src/PcreException.php b/app/vendor/composer/pcre/src/PcreException.php new file mode 100644 index 000000000..218b2f2d8 --- /dev/null +++ b/app/vendor/composer/pcre/src/PcreException.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +class PcreException extends \RuntimeException +{ + /** + * @param string $function + * @param string|string[] $pattern + * @return self + */ + public static function fromFunction($function, $pattern) + { + $code = preg_last_error(); + + if (is_array($pattern)) { + $pattern = implode(', ', $pattern); + } + + return new PcreException($function.'(): failed executing "'.$pattern.'": '.self::pcreLastErrorMessage($code), $code); + } + + /** + * @param int $code + * @return string + */ + private static function pcreLastErrorMessage($code) + { + if (function_exists('preg_last_error_msg')) { + return preg_last_error_msg(); + } + + // older php versions did not set the code properly in all cases + if (PHP_VERSION_ID < 70201 && $code === 0) { + return 'UNDEFINED_ERROR'; + } + + $constants = get_defined_constants(true); + if (!isset($constants['pcre'])) { + return 'UNDEFINED_ERROR'; + } + + foreach ($constants['pcre'] as $const => $val) { + if ($val === $code && substr($const, -6) === '_ERROR') { + return $const; + } + } + + return 'UNDEFINED_ERROR'; + } +} diff --git a/app/vendor/composer/pcre/src/Preg.php b/app/vendor/composer/pcre/src/Preg.php new file mode 100644 index 000000000..400abbfec --- /dev/null +++ b/app/vendor/composer/pcre/src/Preg.php @@ -0,0 +1,430 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +class Preg +{ + /** @internal */ + public const ARRAY_MSG = '$subject as an array is not supported. You can use \'foreach\' instead.'; + /** @internal */ + public const INVALID_TYPE_MSG = '$subject must be a string, %s given.'; + + /** + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * @return 0|1 + * + * @param-out array $matches + */ + public static function match(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int + { + self::checkOffsetCapture($flags, 'matchWithOffsets'); + + $result = preg_match($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL, $offset); + if ($result === false) { + throw PcreException::fromFunction('preg_match', $pattern); + } + + return $result; + } + + /** + * Variant of `match()` which outputs non-null matches (or throws) + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * @return 0|1 + * @throws UnexpectedNullMatchException + * + * @param-out array $matches + */ + public static function matchStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int + { + $result = self::match($pattern, $subject, $matchesInternal, $flags, $offset); + $matches = self::enforceNonNullMatches($pattern, $matchesInternal, 'match'); + + return $result; + } + + /** + * Runs preg_match with PREG_OFFSET_CAPTURE + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL and PREG_OFFSET_CAPTURE are always set, no other flags are supported + * @return 0|1 + * + * @param-out array}> $matches + */ + public static function matchWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): int + { + $result = preg_match($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE, $offset); + if ($result === false) { + throw PcreException::fromFunction('preg_match', $pattern); + } + + return $result; + } + + /** + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * @return 0|positive-int + * + * @param-out array> $matches + */ + public static function matchAll(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int + { + self::checkOffsetCapture($flags, 'matchAllWithOffsets'); + self::checkSetOrder($flags); + + $result = preg_match_all($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL, $offset); + if (!is_int($result)) { // PHP < 8 may return null, 8+ returns int|false + throw PcreException::fromFunction('preg_match_all', $pattern); + } + + return $result; + } + + /** + * Variant of `match()` which outputs non-null matches (or throws) + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * @return 0|positive-int + * @throws UnexpectedNullMatchException + * + * @param-out array> $matches + */ + public static function matchAllStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int + { + $result = self::matchAll($pattern, $subject, $matchesInternal, $flags, $offset); + $matches = self::enforceNonNullMatchAll($pattern, $matchesInternal, 'matchAll'); + + return $result; + } + + /** + * Runs preg_match_all with PREG_OFFSET_CAPTURE + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL and PREG_MATCH_OFFSET are always set, no other flags are supported + * @return 0|positive-int + * + * @param-out array}>> $matches + */ + public static function matchAllWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): int + { + self::checkSetOrder($flags); + + $result = preg_match_all($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE, $offset); + if (!is_int($result)) { // PHP < 8 may return null, 8+ returns int|false + throw PcreException::fromFunction('preg_match_all', $pattern); + } + + return $result; + } + + /** + * @param string|string[] $pattern + * @param string|string[] $replacement + * @param string $subject + * @param int $count Set by method + * + * @param-out int<0, max> $count + */ + public static function replace($pattern, $replacement, $subject, int $limit = -1, ?int &$count = null): string + { + if (!is_scalar($subject)) { + if (is_array($subject)) { + throw new \InvalidArgumentException(static::ARRAY_MSG); + } + + throw new \TypeError(sprintf(static::INVALID_TYPE_MSG, gettype($subject))); + } + + $result = preg_replace($pattern, $replacement, $subject, $limit, $count); + if ($result === null) { + throw PcreException::fromFunction('preg_replace', $pattern); + } + + return $result; + } + + /** + * @param string|string[] $pattern + * @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array}>): string) : callable(array): string) $replacement + * @param string $subject + * @param int $count Set by method + * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set + * + * @param-out int<0, max> $count + */ + public static function replaceCallback($pattern, callable $replacement, $subject, int $limit = -1, ?int &$count = null, int $flags = 0): string + { + if (!is_scalar($subject)) { + if (is_array($subject)) { + throw new \InvalidArgumentException(static::ARRAY_MSG); + } + + throw new \TypeError(sprintf(static::INVALID_TYPE_MSG, gettype($subject))); + } + + $result = preg_replace_callback($pattern, $replacement, $subject, $limit, $count, $flags | PREG_UNMATCHED_AS_NULL); + if ($result === null) { + throw PcreException::fromFunction('preg_replace_callback', $pattern); + } + + return $result; + } + + /** + * Variant of `replaceCallback()` which outputs non-null matches (or throws) + * + * @param string $pattern + * @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array}>): string) : callable(array): string) $replacement + * @param string $subject + * @param int $count Set by method + * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set + * + * @param-out int<0, max> $count + */ + public static function replaceCallbackStrictGroups(string $pattern, callable $replacement, $subject, int $limit = -1, ?int &$count = null, int $flags = 0): string + { + return self::replaceCallback($pattern, function (array $matches) use ($pattern, $replacement) { + return $replacement(self::enforceNonNullMatches($pattern, $matches, 'replaceCallback')); + }, $subject, $limit, $count, $flags); + } + + /** + * @param ($flags is PREG_OFFSET_CAPTURE ? (array}>): string>) : array): string>) $pattern + * @param string $subject + * @param int $count Set by method + * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set + * + * @param-out int<0, max> $count + */ + public static function replaceCallbackArray(array $pattern, $subject, int $limit = -1, ?int &$count = null, int $flags = 0): string + { + if (!is_scalar($subject)) { + if (is_array($subject)) { + throw new \InvalidArgumentException(static::ARRAY_MSG); + } + + throw new \TypeError(sprintf(static::INVALID_TYPE_MSG, gettype($subject))); + } + + $result = preg_replace_callback_array($pattern, $subject, $limit, $count, $flags | PREG_UNMATCHED_AS_NULL); + if ($result === null) { + $pattern = array_keys($pattern); + throw PcreException::fromFunction('preg_replace_callback_array', $pattern); + } + + return $result; + } + + /** + * @param int-mask $flags PREG_SPLIT_NO_EMPTY or PREG_SPLIT_DELIM_CAPTURE + * @return list + */ + public static function split(string $pattern, string $subject, int $limit = -1, int $flags = 0): array + { + if (($flags & PREG_SPLIT_OFFSET_CAPTURE) !== 0) { + throw new \InvalidArgumentException('PREG_SPLIT_OFFSET_CAPTURE is not supported as it changes the type of $matches, use splitWithOffsets() instead'); + } + + $result = preg_split($pattern, $subject, $limit, $flags); + if ($result === false) { + throw PcreException::fromFunction('preg_split', $pattern); + } + + return $result; + } + + /** + * @param int-mask $flags PREG_SPLIT_NO_EMPTY or PREG_SPLIT_DELIM_CAPTURE, PREG_SPLIT_OFFSET_CAPTURE is always set + * @return list + * @phpstan-return list}> + */ + public static function splitWithOffsets(string $pattern, string $subject, int $limit = -1, int $flags = 0): array + { + $result = preg_split($pattern, $subject, $limit, $flags | PREG_SPLIT_OFFSET_CAPTURE); + if ($result === false) { + throw PcreException::fromFunction('preg_split', $pattern); + } + + return $result; + } + + /** + * @template T of string|\Stringable + * @param string $pattern + * @param array $array + * @param int-mask $flags PREG_GREP_INVERT + * @return array + */ + public static function grep(string $pattern, array $array, int $flags = 0): array + { + $result = preg_grep($pattern, $array, $flags); + if ($result === false) { + throw PcreException::fromFunction('preg_grep', $pattern); + } + + return $result; + } + + /** + * Variant of match() which returns a bool instead of int + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * + * @param-out array $matches + */ + public static function isMatch(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool + { + return (bool) static::match($pattern, $subject, $matches, $flags, $offset); + } + + /** + * Variant of `isMatch()` which outputs non-null matches (or throws) + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * @throws UnexpectedNullMatchException + * + * @param-out array $matches + */ + public static function isMatchStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool + { + return (bool) self::matchStrictGroups($pattern, $subject, $matches, $flags, $offset); + } + + /** + * Variant of matchAll() which returns a bool instead of int + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * + * @param-out array> $matches + */ + public static function isMatchAll(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool + { + return (bool) static::matchAll($pattern, $subject, $matches, $flags, $offset); + } + + /** + * Variant of `isMatchAll()` which outputs non-null matches (or throws) + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * + * @param-out array> $matches + */ + public static function isMatchAllStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool + { + return (bool) self::matchAllStrictGroups($pattern, $subject, $matches, $flags, $offset); + } + + /** + * Variant of matchWithOffsets() which returns a bool instead of int + * + * Runs preg_match with PREG_OFFSET_CAPTURE + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * + * @param-out array}> $matches + */ + public static function isMatchWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): bool + { + return (bool) static::matchWithOffsets($pattern, $subject, $matches, $flags, $offset); + } + + /** + * Variant of matchAllWithOffsets() which returns a bool instead of int + * + * Runs preg_match_all with PREG_OFFSET_CAPTURE + * + * @param non-empty-string $pattern + * @param array $matches Set by method + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * + * @param-out array}>> $matches + */ + public static function isMatchAllWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): bool + { + return (bool) static::matchAllWithOffsets($pattern, $subject, $matches, $flags, $offset); + } + + private static function checkOffsetCapture(int $flags, string $useFunctionName): void + { + if (($flags & PREG_OFFSET_CAPTURE) !== 0) { + throw new \InvalidArgumentException('PREG_OFFSET_CAPTURE is not supported as it changes the type of $matches, use ' . $useFunctionName . '() instead'); + } + } + + private static function checkSetOrder(int $flags): void + { + if (($flags & PREG_SET_ORDER) !== 0) { + throw new \InvalidArgumentException('PREG_SET_ORDER is not supported as it changes the type of $matches'); + } + } + + /** + * @param array $matches + * @return array + * @throws UnexpectedNullMatchException + */ + private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod) + { + foreach ($matches as $group => $match) { + if (is_string($match) || (is_array($match) && is_string($match[0]))) { + continue; + } + + throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.'); + } + + /** @var array */ + return $matches; + } + + /** + * @param array> $matches + * @return array> + * @throws UnexpectedNullMatchException + */ + private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod) + { + foreach ($matches as $group => $groupMatches) { + foreach ($groupMatches as $match) { + if (null === $match) { + throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.'); + } + } + } + + /** @var array> */ + return $matches; + } +} diff --git a/app/vendor/composer/pcre/src/Regex.php b/app/vendor/composer/pcre/src/Regex.php new file mode 100644 index 000000000..038cf0696 --- /dev/null +++ b/app/vendor/composer/pcre/src/Regex.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +class Regex +{ + /** + * @param non-empty-string $pattern + */ + public static function isMatch(string $pattern, string $subject, int $offset = 0): bool + { + return (bool) Preg::match($pattern, $subject, $matches, 0, $offset); + } + + /** + * @param non-empty-string $pattern + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + */ + public static function match(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchResult + { + self::checkOffsetCapture($flags, 'matchWithOffsets'); + + $count = Preg::match($pattern, $subject, $matches, $flags, $offset); + + return new MatchResult($count, $matches); + } + + /** + * Variant of `match()` which returns non-null matches (or throws) + * + * @param non-empty-string $pattern + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * @throws UnexpectedNullMatchException + */ + public static function matchStrictGroups(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchStrictGroupsResult + { + // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups + $count = Preg::matchStrictGroups($pattern, $subject, $matches, $flags, $offset); + + return new MatchStrictGroupsResult($count, $matches); + } + + /** + * Runs preg_match with PREG_OFFSET_CAPTURE + * + * @param non-empty-string $pattern + * @param int-mask $flags PREG_UNMATCHED_AS_NULL and PREG_MATCH_OFFSET are always set, no other flags are supported + */ + public static function matchWithOffsets(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchWithOffsetsResult + { + $count = Preg::matchWithOffsets($pattern, $subject, $matches, $flags, $offset); + + return new MatchWithOffsetsResult($count, $matches); + } + + /** + * @param non-empty-string $pattern + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + */ + public static function matchAll(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchAllResult + { + self::checkOffsetCapture($flags, 'matchAllWithOffsets'); + self::checkSetOrder($flags); + + $count = Preg::matchAll($pattern, $subject, $matches, $flags, $offset); + + return new MatchAllResult($count, $matches); + } + + /** + * Variant of `matchAll()` which returns non-null matches (or throws) + * + * @param non-empty-string $pattern + * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported + * @throws UnexpectedNullMatchException + */ + public static function matchAllStrictGroups(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchAllStrictGroupsResult + { + self::checkOffsetCapture($flags, 'matchAllWithOffsets'); + self::checkSetOrder($flags); + + // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups + $count = Preg::matchAllStrictGroups($pattern, $subject, $matches, $flags, $offset); + + return new MatchAllStrictGroupsResult($count, $matches); + } + + /** + * Runs preg_match_all with PREG_OFFSET_CAPTURE + * + * @param non-empty-string $pattern + * @param int-mask $flags PREG_UNMATCHED_AS_NULL and PREG_MATCH_OFFSET are always set, no other flags are supported + */ + public static function matchAllWithOffsets(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchAllWithOffsetsResult + { + self::checkSetOrder($flags); + + $count = Preg::matchAllWithOffsets($pattern, $subject, $matches, $flags, $offset); + + return new MatchAllWithOffsetsResult($count, $matches); + } + /** + * @param string|string[] $pattern + * @param string|string[] $replacement + * @param string $subject + */ + public static function replace($pattern, $replacement, $subject, int $limit = -1): ReplaceResult + { + $result = Preg::replace($pattern, $replacement, $subject, $limit, $count); + + return new ReplaceResult($count, $result); + } + + /** + * @param string|string[] $pattern + * @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array}>): string) : callable(array): string) $replacement + * @param string $subject + * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set + */ + public static function replaceCallback($pattern, callable $replacement, $subject, int $limit = -1, int $flags = 0): ReplaceResult + { + $result = Preg::replaceCallback($pattern, $replacement, $subject, $limit, $count, $flags); + + return new ReplaceResult($count, $result); + } + + /** + * Variant of `replaceCallback()` which outputs non-null matches (or throws) + * + * @param string $pattern + * @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array}>): string) : callable(array): string) $replacement + * @param string $subject + * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set + */ + public static function replaceCallbackStrictGroups($pattern, callable $replacement, $subject, int $limit = -1, int $flags = 0): ReplaceResult + { + $result = Preg::replaceCallbackStrictGroups($pattern, $replacement, $subject, $limit, $count, $flags); + + return new ReplaceResult($count, $result); + } + + /** + * @param ($flags is PREG_OFFSET_CAPTURE ? (array}>): string>) : array): string>) $pattern + * @param string $subject + * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set + */ + public static function replaceCallbackArray(array $pattern, $subject, int $limit = -1, int $flags = 0): ReplaceResult + { + $result = Preg::replaceCallbackArray($pattern, $subject, $limit, $count, $flags); + + return new ReplaceResult($count, $result); + } + + private static function checkOffsetCapture(int $flags, string $useFunctionName): void + { + if (($flags & PREG_OFFSET_CAPTURE) !== 0) { + throw new \InvalidArgumentException('PREG_OFFSET_CAPTURE is not supported as it changes the return type, use '.$useFunctionName.'() instead'); + } + } + + private static function checkSetOrder(int $flags): void + { + if (($flags & PREG_SET_ORDER) !== 0) { + throw new \InvalidArgumentException('PREG_SET_ORDER is not supported as it changes the return type'); + } + } +} diff --git a/app/vendor/composer/pcre/src/ReplaceResult.php b/app/vendor/composer/pcre/src/ReplaceResult.php new file mode 100644 index 000000000..33847712d --- /dev/null +++ b/app/vendor/composer/pcre/src/ReplaceResult.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +final class ReplaceResult +{ + /** + * @readonly + * @var string + */ + public $result; + + /** + * @readonly + * @var 0|positive-int + */ + public $count; + + /** + * @readonly + * @var bool + */ + public $matched; + + /** + * @param 0|positive-int $count + */ + public function __construct(int $count, string $result) + { + $this->count = $count; + $this->matched = (bool) $count; + $this->result = $result; + } +} diff --git a/app/vendor/composer/pcre/src/UnexpectedNullMatchException.php b/app/vendor/composer/pcre/src/UnexpectedNullMatchException.php new file mode 100644 index 000000000..f123828bf --- /dev/null +++ b/app/vendor/composer/pcre/src/UnexpectedNullMatchException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Pcre; + +class UnexpectedNullMatchException extends PcreException +{ + public static function fromFunction($function, $pattern) + { + throw new \LogicException('fromFunction should not be called on '.self::class.', use '.PcreException::class); + } +} From daab22d2f1888dd0eb068587e1475dcff26c58ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0pa=C4=8Dek?= Date: Sun, 20 Oct 2024 05:46:10 +0200 Subject: [PATCH 2/3] Check cookie value if there's at least one, previously the condition was never true --- app/tests/EasterEgg/CrLfUrlInjectionsTest.phpt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/tests/EasterEgg/CrLfUrlInjectionsTest.phpt b/app/tests/EasterEgg/CrLfUrlInjectionsTest.phpt index df1a0bab5..d830ea8d6 100644 --- a/app/tests/EasterEgg/CrLfUrlInjectionsTest.phpt +++ b/app/tests/EasterEgg/CrLfUrlInjectionsTest.phpt @@ -64,7 +64,7 @@ class CrLfUrlInjectionsTest extends TestCase Assert::same(IResponse::S204_NoContent, $this->response->getCode()); Assert::same('U WOT M8', $this->response->getReason()); Assert::count($cookies, $this->response->getCookie('crlfinjection')); - if ($cookies > 1) { + if ($cookies > 0) { Assert::same('1337', $this->response->getCookie('crlfinjection')[0]->getValue()); } } else { From a9a92be78e3e3376c77660a06d61259c373c0792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0pa=C4=8Dek?= Date: Sun, 20 Oct 2024 05:44:43 +0200 Subject: [PATCH 3/3] Use composer/preg's Regex class instead of preg_*() and Strings::*() Also use the provided PHPStan extension to check regular expressions. Close #401 --- app/disallowed-calls.neon | 9 +++ app/phpstan-vendor.neon | 13 ++++ app/phpstan.neon | 1 + app/psalm-baseline.xml | 5 -- app/src/EasterEgg/CrLfUrlInjections.php | 8 +- app/src/EasterEgg/WinterIsComing.php | 4 +- app/src/Form/SignInHoneypotFormFactory.php | 5 +- app/src/Formatter/TexyFormatter.php | 10 +-- app/src/Formatter/TexyPhraseHandler.php | 15 +++- .../TrainingApplicationFormSpam.php | 3 +- .../TrainingApplicationSources.php | 7 +- app/src/UpcKeys/Technicolor.php | 9 ++- app/src/UpcKeys/UpcKeys.php | 4 +- .../Form/SignInHoneypotFormFactoryTest.phpt | 78 +++++++++++++++++++ app/tests/UpcKeys/UpcKeysTest.phpt | 16 ++++ 15 files changed, 155 insertions(+), 32 deletions(-) create mode 100644 app/tests/Form/SignInHoneypotFormFactoryTest.phpt diff --git a/app/disallowed-calls.neon b/app/disallowed-calls.neon index 424ab74bb..0d38fa247 100644 --- a/app/disallowed-calls.neon +++ b/app/disallowed-calls.neon @@ -15,12 +15,21 @@ parameters: - function: 'setcookie()' message: 'use methods from MichalSpacekCz\Http\Cookies' + - + function: 'preg_*()' + message: 'use the Preg class from composer/pcre' disallowedStaticCalls: - method: 'Tester\Environment::skip()' message: 'use TestCaseRunner::skip() instead, it can ignore skipping with an environment variable' allowInMethods: - 'MichalSpacekCz\Test\TestCaseRunner::skip()' + - + method: + - 'Nette\Utils\Strings::match()' + - 'Nette\Utils\Strings::matchAll()' + - 'Nette\Utils\Strings::replace()' + message: 'use the Preg or Regex class from composer/pcre for better static analysis' disallowedMethodCalls: - method: diff --git a/app/phpstan-vendor.neon b/app/phpstan-vendor.neon index c8c35c34d..388bbdf3b 100644 --- a/app/phpstan-vendor.neon +++ b/app/phpstan-vendor.neon @@ -32,6 +32,7 @@ parameters: - vendor/slevomat/coding-standard/* - vendor/squizlabs/php_codesniffer/* # Throws "No error to ignore is reported on line", which can't be ignored, probably because only custom ruleset is used + - vendor/composer/pcre/src/Regex.php - vendor/contributte/translation/src/DI/TranslationExtension.php - vendor/contributte/translation/src/Latte/Macros.php - vendor/metisfw/phpstan-nette-links/src/Nette/PresenterResolver.php @@ -58,6 +59,10 @@ parameters: - vendor/latte/latte/src/Tools/Linter.php - vendor/nette/tester/src/Runner/CliTester.php - vendor/nette/tester/src/Runner/Runner.php + - + function: 'preg_*()' + allowIn: + - vendor/*.php # bundled disallowed-dangerous-calls.neon - function: 'eval()' @@ -208,6 +213,14 @@ parameters: message: 'use TestCaseRunner::skip() instead, it can ignore skipping with an environment variable' allowIn: - vendor/nette/tester/src/Framework/TestCase.php + - + method: + - 'Nette\Utils\Strings::match()' + - 'Nette\Utils\Strings::matchAll()' + - 'Nette\Utils\Strings::replace()' + allowIn: + - vendor/contributte/*.php + - vendor/nette/*.php disallowedSuperglobals: - superglobal: '$_SERVER' diff --git a/app/phpstan.neon b/app/phpstan.neon index ce0a118fa..c217bd9a4 100644 --- a/app/phpstan.neon +++ b/app/phpstan.neon @@ -22,3 +22,4 @@ includes: - vendor/phpstan/phpstan-deprecation-rules/rules.neon - vendor/metisfw/phpstan-nette-links/extension.neon - vendor/metisfw/phpstan-nette-links/rules.neon + - vendor/composer/pcre/extension.neon diff --git a/app/psalm-baseline.xml b/app/psalm-baseline.xml index f8f950305..4bf2518b1 100644 --- a/app/psalm-baseline.xml +++ b/app/psalm-baseline.xml @@ -32,11 +32,6 @@ id]]> - - - - - getValue()]]> diff --git a/app/src/EasterEgg/CrLfUrlInjections.php b/app/src/EasterEgg/CrLfUrlInjections.php index de0282f33..b127e5a53 100644 --- a/app/src/EasterEgg/CrLfUrlInjections.php +++ b/app/src/EasterEgg/CrLfUrlInjections.php @@ -3,9 +3,9 @@ namespace MichalSpacekCz\EasterEgg; +use Composer\Pcre\Regex; use Nette\Http\IRequest; use Nette\Http\IResponse; -use Nette\Utils\Strings; readonly class CrLfUrlInjections { @@ -26,12 +26,12 @@ public function detectAttempt(): bool if (!str_contains($url, "\r") && !str_contains($url, "\n")) { return false; } - $matches = Strings::matchAll($url, sprintf('/Set\-Cookie:%s=([a-z0-9]+)/i', self::COOKIE_NAME)); - foreach ($matches as $match) { + $matches = Regex::matchAllStrictGroups(sprintf('/Set\-Cookie:%s=([a-z0-9]+)/i', self::COOKIE_NAME), $url); + foreach ($matches->matches[1] as $match) { // Don't use any cookie name from the request to avoid e.g. session fixation $this->httpResponse->setCookie( self::COOKIE_NAME, - $match[1], + $match, time() - 31337 * 1337, '/expired=31337*1337seconds/(1.3years)/ago', ); diff --git a/app/src/EasterEgg/WinterIsComing.php b/app/src/EasterEgg/WinterIsComing.php index 546642b6f..e6594e5d5 100644 --- a/app/src/EasterEgg/WinterIsComing.php +++ b/app/src/EasterEgg/WinterIsComing.php @@ -3,12 +3,12 @@ namespace MichalSpacekCz\EasterEgg; +use Composer\Pcre\Regex; use MichalSpacekCz\ShouldNotHappenException; use Nette\Application\Responses\TextResponse; use Nette\Application\UI\Presenter; use Nette\Forms\Controls\TextInput; use Nette\Utils\Arrays; -use Nette\Utils\Strings; class WinterIsComing { @@ -42,7 +42,7 @@ public function ruleEmail(): callable is_string($input->getValue()) && ( Arrays::contains(self::EMAILS, $input->getValue()) - || Strings::match($input->getValue(), '/@(' . implode('|', array_map('preg_quote', self::HOSTS)) . ')$/') !== null + || Regex::isMatch('/@(' . implode('|', array_map('preg_quote', self::HOSTS)) . ')$/', $input->getValue()) ) ) { $this->sendSyntaxError($input); diff --git a/app/src/Form/SignInHoneypotFormFactory.php b/app/src/Form/SignInHoneypotFormFactory.php index 9d0fd313a..e0d38a8d8 100644 --- a/app/src/Form/SignInHoneypotFormFactory.php +++ b/app/src/Form/SignInHoneypotFormFactory.php @@ -3,6 +3,7 @@ namespace MichalSpacekCz\Form; +use Composer\Pcre\Regex; use MichalSpacekCz\Form\Controls\FormControlsFactory; use Nette\Http\IRequest; use Nette\Utils\Html; @@ -27,7 +28,7 @@ public function create(): UiForm $values = $form->getFormValues(); Debugger::log("Sign-in attempt: {$values->username}, {$values->password}, {$this->httpRequest->getRemoteAddress()}", 'honeypot'); $creds = $values->username . ':' . $values->password; - if (preg_match('~\slimit\s~i', $creds)) { + if (Regex::isMatch('~\slimit\s~i', $creds)) { $message = Html::el() ->setText("No, no, no, no, no, no, no, no, no, no, no, no there's ") ->addHtml(Html::el('a') @@ -37,7 +38,7 @@ public function create(): UiForm ->addText('!'); } elseif (stripos($creds, 'honeypot') !== false) { $message = 'Jo jo, honeypot, přesně tak'; - } elseif (preg_match('~\sor\s~i', $creds)) { + } elseif (Regex::isMatch('~\sor\s~i', $creds)) { $message = 'Dobrej pokusql!'; } else { $message = 'Špatné uživatelské jméno nebo heslo'; diff --git a/app/src/Formatter/TexyFormatter.php b/app/src/Formatter/TexyFormatter.php index 348e7db6e..b2b771048 100644 --- a/app/src/Formatter/TexyFormatter.php +++ b/app/src/Formatter/TexyFormatter.php @@ -3,12 +3,12 @@ namespace MichalSpacekCz\Formatter; +use Composer\Pcre\Regex; use Contributte\Translation\Exceptions\InvalidArgument; use Contributte\Translation\Translator; use MichalSpacekCz\Formatter\Placeholders\TexyFormatterPlaceholder; use MichalSpacekCz\Utils\Hash; use Nette\Utils\Html; -use Nette\Utils\Strings; use Stringable; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -151,7 +151,7 @@ public function format(string $text): Html { $texy = $this->texy ?? $this->getTexy(); return $this->replace($text . self::CACHE_KEY_DELIMITER . __FUNCTION__, $texy, function () use ($texy, $text): string { - return Strings::replace($texy->process($text), '~^\s*]*>(.*)

\s*$~s', '$1'); + return Regex::replace('~^\s*]*>(.*)

\s*$~s', '$1', $texy->process($text))->result; }); } @@ -188,13 +188,13 @@ private function replace(string $key, Texy $texy, callable $callback): Html $replacements[$placeholder::getId()] = $placeholder->replace(...); } - $result = Strings::replace( - $result, + $result = Regex::replaceCallbackStrictGroups( '~\*\*([^:]+):([^*]+)\*\*~', function (array $matches) use ($replacements): string { return (isset($replacements[$matches[1]]) ? $replacements[$matches[1]]($matches[2]) : ''); }, - ); + $result, + )->result; return Html::el()->setHtml($result); } diff --git a/app/src/Formatter/TexyPhraseHandler.php b/app/src/Formatter/TexyPhraseHandler.php index c03ec5472..365b0b794 100644 --- a/app/src/Formatter/TexyPhraseHandler.php +++ b/app/src/Formatter/TexyPhraseHandler.php @@ -3,6 +3,7 @@ namespace MichalSpacekCz\Formatter; +use Composer\Pcre\Regex; use Contributte\Translation\Translator; use MichalSpacekCz\Application\Locale\LocaleLinkGenerator; use MichalSpacekCz\Articles\Blog\BlogPostLocaleUrls; @@ -61,8 +62,11 @@ public function solve(HandlerInvocation $invocation, string $phrase, string $con } // "title":[link-en_US:Module:Presenter:action params] - if (str_starts_with($url, 'link-') && preg_match("/^link-{$localeRegExp}:(.*)\\z/", $url, $matches)) { - $link->URL = $this->getLink($matches[2], $matches[1]); + if (str_starts_with($url, 'link-')) { + $result = Regex::matchStrictGroups("/^link-{$localeRegExp}:(.*)\\z/", $url); + if ($result->matched) { + $link->URL = $this->getLink($result->matches[2], $result->matches[1]); + } } // "title":[blog:post#fragment] @@ -71,8 +75,11 @@ public function solve(HandlerInvocation $invocation, string $phrase, string $con } // "title":[blog-en_US:post#fragment] - if (str_starts_with($url, 'blog-') && preg_match("/^blog-{$localeRegExp}:(.*)\\z/", $url, $matches)) { - $link->URL = $this->getBlogLink($matches[2], $matches[1]); + if (str_starts_with($url, 'blog-')) { + $result = Regex::matchStrictGroups("/^blog-{$localeRegExp}:(.*)\\z/", $url); + if ($result->matched) { + $link->URL = $this->getBlogLink($result->matches[2], $result->matches[1]); + } } // "title":[inhouse-training:training] diff --git a/app/src/Training/ApplicationForm/TrainingApplicationFormSpam.php b/app/src/Training/ApplicationForm/TrainingApplicationFormSpam.php index 834c461e7..953658619 100644 --- a/app/src/Training/ApplicationForm/TrainingApplicationFormSpam.php +++ b/app/src/Training/ApplicationForm/TrainingApplicationFormSpam.php @@ -3,6 +3,7 @@ namespace MichalSpacekCz\Training\ApplicationForm; +use Composer\Pcre\Regex; use MichalSpacekCz\Training\Exceptions\SpammyApplicationException; use stdClass; @@ -17,7 +18,7 @@ class TrainingApplicationFormSpam public function check(stdClass $values): void { - if (preg_match('~\s+href="\s*https?://~', $values->note ?? '')) { + if (Regex::isMatch('~\s+href="\s*https?://~', $values->note ?? '')) { throw new SpammyApplicationException(); } elseif ( ctype_lower($values->name ?? self::FIELD_MISSING_VALUE) diff --git a/app/src/Training/Applications/TrainingApplicationSources.php b/app/src/Training/Applications/TrainingApplicationSources.php index eeb42bfc8..6ab095243 100644 --- a/app/src/Training/Applications/TrainingApplicationSources.php +++ b/app/src/Training/Applications/TrainingApplicationSources.php @@ -3,6 +3,7 @@ namespace MichalSpacekCz\Training\Applications; +use Composer\Pcre\Regex; use MichalSpacekCz\Database\TypedDatabase; use MichalSpacekCz\Training\Resolver\Vrana; use Nette\Utils\Strings; @@ -60,9 +61,9 @@ public function getDefaultSource(): string */ public function getSourceNameInitials(string $name): string { - $name = Strings::replace($name, '/,? s\.r\.o./', ''); - $matches = Strings::matchAll($name, '/(?<=\s|\b)\pL/u', PREG_PATTERN_ORDER); - return Strings::upper(implode('', current($matches))); + $replace = Regex::replace('/,? s\.r\.o./', '', $name); + $matches = Regex::matchAll('/(?<=\s|\b)\pL/u', $replace->result); + return Strings::upper(implode('', $matches->matches[0])); } } diff --git a/app/src/UpcKeys/Technicolor.php b/app/src/UpcKeys/Technicolor.php index 02919b5c9..21d0a4b51 100644 --- a/app/src/UpcKeys/Technicolor.php +++ b/app/src/UpcKeys/Technicolor.php @@ -3,6 +3,7 @@ namespace MichalSpacekCz\UpcKeys; +use Composer\Pcre\Regex; use DateTime; use MichalSpacekCz\Http\Client\HttpClient; use MichalSpacekCz\Http\Client\HttpClientRequest; @@ -95,10 +96,11 @@ private function generateKeys(string $ssid): array continue; } - if (!preg_match('/([^,]+),([^,]+),(\d+)/', $line, $matches)) { + $result = Regex::matchStrictGroups('/([^,]+),([^,]+),(\d+)/', $line); + if (!$result->matched) { throw new UpcKeysApiIncorrectTokensException($json, $line); } - [, $serial, $key, $type] = $matches; + [, $serial, $key, $type] = $result->matches; $keys["{$type}-{$serial}"] = $this->buildKey($serial, $key, (int)$type); } ksort($keys); @@ -186,8 +188,7 @@ private function storeKeys(string $ssid, array $keys): void */ private function buildKey(string $serial, string $key, int $type): WiFiKey { - preg_match('/^[a-z]+/i', $serial, $matches); - $prefix = current($matches); + $prefix = Regex::match('/^[a-z]+/i', $serial)->matches[0] ?? false; if ($prefix === false || !in_array($prefix, self::PREFIXES)) { throw new UpcKeysApiUnknownPrefixException($serial); } diff --git a/app/src/UpcKeys/UpcKeys.php b/app/src/UpcKeys/UpcKeys.php index 95ad9fe32..927ff2ef3 100644 --- a/app/src/UpcKeys/UpcKeys.php +++ b/app/src/UpcKeys/UpcKeys.php @@ -3,8 +3,8 @@ namespace MichalSpacekCz\UpcKeys; +use Composer\Pcre\Regex; use Nette\Application\Responses\TextResponse; -use Nette\Utils\Strings; class UpcKeys { @@ -101,7 +101,7 @@ public function getValidSsidPattern(): string public function isValidSsid(string $ssid): bool { // Inspired by Nette\Forms\Validator::validatePattern() - return (bool)Strings::match($ssid, sprintf("\x01^(%s)\\z\x01u", self::SSID_VALID_PATTERN)); + return Regex::isMatch(sprintf("\x01^(%s)\\z\x01u", self::SSID_VALID_PATTERN), $ssid); } diff --git a/app/tests/Form/SignInHoneypotFormFactoryTest.phpt b/app/tests/Form/SignInHoneypotFormFactoryTest.phpt new file mode 100644 index 000000000..85390008a --- /dev/null +++ b/app/tests/Form/SignInHoneypotFormFactoryTest.phpt @@ -0,0 +1,78 @@ +form = $signInHoneypotFormFactory->create(); + $presenter = $applicationPresenter->createUiPresenter('Admin:Honeypot', 'foo', 'signIn'); + /** @noinspection PhpInternalEntityUsedInspection */ + $this->form->setParent($presenter); + } + + + #[Override] + protected function tearDown(): void + { + $this->form->cleanErrors(); + } + + + /** + * @return list + */ + public function getCredentials(): array + { + return [ + ['foo', 'bar', 'Špatné uživatelské jméno nebo heslo'], + ['foo LIMIT 1', 'bar', 'No, no, no, no, no, no, no, no, no, no, no, no there\'s no limit!'], + ['foo', 'honeypot', 'Jo jo, honeypot, přesně tak'], + ['foo OR 1', 'bar', 'Dobrej pokusql!'], + ]; + } + + + /** @dataProvider getCredentials */ + public function testCreateOnsuccess(string $username, string $password, string $error): void + { + $this->setValue('username', $username); + $this->setValue('password', $password); + Arrays::invoke($this->form->onSuccess, $this->form); + Assert::same($error, (string)$this->form->getErrors()[0]); + } + + + private function setValue(string $component, string $value): void + { + $field = $this->form->getComponent($component); + if (!$field instanceof TextInput) { + Assert::fail('Field is of a wrong type ' . $field::class); + } else { + $field->setDefaultValue($value); + } + } + +} + +TestCaseRunner::run(SignInHoneypotFormFactoryTest::class); diff --git a/app/tests/UpcKeys/UpcKeysTest.phpt b/app/tests/UpcKeys/UpcKeysTest.phpt index 000b7e883..abc50f527 100644 --- a/app/tests/UpcKeys/UpcKeysTest.phpt +++ b/app/tests/UpcKeys/UpcKeysTest.phpt @@ -19,6 +19,22 @@ class UpcKeysTest extends TestCase } + public function testIsValidSsid(): void + { + Assert::false($this->upcKeys->isValidSsid('ABC1234567')); + Assert::false($this->upcKeys->isValidSsid('UPC 1234567')); + Assert::false($this->upcKeys->isValidSsid('UPC123456')); + Assert::false($this->upcKeys->isValidSsid('UPC 123456')); + Assert::true($this->upcKeys->isValidSsid('upc0000000')); + Assert::true($this->upcKeys->isValidSsid('UPC0000000')); + Assert::true($this->upcKeys->isValidSsid('upc1234567')); + Assert::true($this->upcKeys->isValidSsid('UPC1234567')); + Assert::true($this->upcKeys->isValidSsid('upc9999999')); + Assert::true($this->upcKeys->isValidSsid('UPC9999999')); + Assert::false($this->upcKeys->isValidSsid('UPC12345AF')); + } + + public function testGetTextResponse(): void { Assert::same('', $this->upcKeys->getTextResponse(null, null, [])->getSource());