diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index dddf77995b3..6a6d4be4913 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -27,6 +27,7 @@ Yii Framework 2 Change Log - Enh #8752: Allow specify `$attributeNames` as a string for `yii\base\Model` `validate()` method (developeruz) - Enh #9137: Added `Access-Control-Allow-Method` header for the OPTIONS request (developeruz) - Enh #9253: Allow `variations` to be a string for `yii\filters\PageCache` and `yii\widgets\FragmentCache` (schojniak, developeruz) +- Enh #12623: Added `yii\helpers\StringHelper::matchWildcard()` replacing usage of `fnmatch()`, which may be unreliable (klimov-paul) - Enh #14043: Added `yii\helpers\IpHelper` (silverfire, cebe) - Enh #7996: Short syntax for verb in GroupUrlRule (schojniak, developeruz) - Enh #14568: Refactored migration templates to use `safeUp()` and `safeDown()` methods (Kolyunya) diff --git a/framework/base/ActionFilter.php b/framework/base/ActionFilter.php index 0b9b974cbf3..eb7fb095a8b 100644 --- a/framework/base/ActionFilter.php +++ b/framework/base/ActionFilter.php @@ -6,6 +6,7 @@ */ namespace yii\base; +use yii\helpers\StringHelper; /** * ActionFilter is the base class for action filters. @@ -149,7 +150,7 @@ protected function isActive($action) } else { $onlyMatch = false; foreach ($this->only as $pattern) { - if (fnmatch($pattern, $id)) { + if (StringHelper::matchWildcard($pattern, $id)) { $onlyMatch = true; break; } @@ -158,7 +159,7 @@ protected function isActive($action) $exceptMatch = false; foreach ($this->except as $pattern) { - if (fnmatch($pattern, $id)) { + if (StringHelper::matchWildcard($pattern, $id)) { $exceptMatch = true; break; } diff --git a/framework/filters/AccessRule.php b/framework/filters/AccessRule.php index 12a213b2de2..a89ef59371d 100644 --- a/framework/filters/AccessRule.php +++ b/framework/filters/AccessRule.php @@ -12,6 +12,7 @@ use yii\base\Component; use yii\base\Controller; use yii\base\InvalidConfigException; +use yii\helpers\StringHelper; use yii\web\Request; use yii\web\User; @@ -198,7 +199,7 @@ protected function matchController($controller) $id = $controller->getUniqueId(); foreach ($this->controllers as $pattern) { - if (fnmatch($pattern, $id)) { + if (StringHelper::matchWildcard($pattern, $id)) { return true; } } diff --git a/framework/filters/HostControl.php b/framework/filters/HostControl.php index 2d7a423f88b..a76b819d241 100644 --- a/framework/filters/HostControl.php +++ b/framework/filters/HostControl.php @@ -9,6 +9,7 @@ use Yii; use yii\base\ActionFilter; +use yii\helpers\StringHelper; use yii\web\NotFoundHttpException; /** @@ -135,7 +136,7 @@ public function beforeAction($action) $currentHost = Yii::$app->getRequest()->getHostName(); foreach ($allowedHosts as $allowedHost) { - if (fnmatch($allowedHost, $currentHost)) { + if (StringHelper::matchWildcard($allowedHost, $currentHost)) { return true; } } diff --git a/framework/filters/auth/AuthMethod.php b/framework/filters/auth/AuthMethod.php index 453f8514d75..2c2a2906db5 100644 --- a/framework/filters/auth/AuthMethod.php +++ b/framework/filters/auth/AuthMethod.php @@ -10,6 +10,7 @@ use Yii; use yii\base\Action; use yii\base\ActionFilter; +use yii\helpers\StringHelper; use yii\web\Request; use yii\web\Response; use yii\web\UnauthorizedHttpException; @@ -104,7 +105,7 @@ protected function isOptional($action) { $id = $this->getActionId($action); foreach ($this->optional as $pattern) { - if (fnmatch($pattern, $id)) { + if (StringHelper::matchWildcard($pattern, $id)) { return true; } } diff --git a/framework/helpers/BaseFileHelper.php b/framework/helpers/BaseFileHelper.php index df611754c98..61cf82c3bd3 100644 --- a/framework/helpers/BaseFileHelper.php +++ b/framework/helpers/BaseFileHelper.php @@ -591,12 +591,12 @@ private static function matchBasename($baseName, $pattern, $firstWildcard, $flag } } - $fnmatchFlags = 0; + $matchOptions = []; if ($flags & self::PATTERN_CASE_INSENSITIVE) { - $fnmatchFlags |= FNM_CASEFOLD; + $matchOptions['caseSensitive'] = false; } - return fnmatch($pattern, $baseName, $fnmatchFlags); + return StringHelper::matchWildcard($pattern, $baseName, $matchOptions); } /** @@ -645,12 +645,14 @@ private static function matchPathname($path, $basePath, $pattern, $firstWildcard } } - $fnmatchFlags = FNM_PATHNAME; + $matchOptions = [ + 'filePath' => true + ]; if ($flags & self::PATTERN_CASE_INSENSITIVE) { - $fnmatchFlags |= FNM_CASEFOLD; + $matchOptions['caseSensitive'] = false; } - return fnmatch($pattern, $name, $fnmatchFlags); + return StringHelper::matchWildcard($pattern, $name, $matchOptions); } /** diff --git a/framework/helpers/BaseStringHelper.php b/framework/helpers/BaseStringHelper.php index f7b1a4059cd..f7469c14cc4 100644 --- a/framework/helpers/BaseStringHelper.php +++ b/framework/helpers/BaseStringHelper.php @@ -365,4 +365,57 @@ public static function floatToString($number) // so its safe to call str_replace here return str_replace(',', '.', (string) $number); } + + /** + * Checks if the passed string would match the given shell wildcard pattern. + * This function emulates [[fnmatch()]], which may be unavailable at certain environment, using PCRE. + * @param string $pattern the shell wildcard pattern. + * @param string $string the tested string. + * @param array $options options for matching. Valid options are: + * + * - caseSensitive: bool, whether pattern should be case sensitive. Defaults to `true`. + * - escape: bool, whether backslash escaping is enabled. Defaults to `true`. + * - filePath: bool, whether slashes in string only matches slashes in the given pattern. Defaults to `false`. + * + * @return bool whether the string matches pattern or not. + * @since 2.0.14 + */ + public static function matchWildcard($pattern, $string, $options = []) + { + if ($pattern === '*' && empty($options['filePath'])) { + return true; + } + + $replacements = [ + '\\\\\\\\' => '\\\\', + '\\\\\\*' => '[*]', + '\\\\\\?' => '[?]', + '\*' => '.*', + '\?' => '.', + '\[\!' => '[^', + '\[' => '[', + '\]' => ']', + '\-' => '-', + ]; + + if (isset($options['escape']) && !$options['escape']) { + unset($replacements['\\\\\\\\']); + unset($replacements['\\\\\\*']); + unset($replacements['\\\\\\?']); + } + + if (!empty($options['filePath'])) { + $replacements['\*'] = '[^/\\\\]*'; + $replacements['\?'] = '[^/\\\\]'; + } + + $pattern = strtr(preg_quote($pattern, '#'), $replacements); + $pattern = '#^' . $pattern . '$#us'; + + if (isset($options['caseSensitive']) && !$options['caseSensitive']) { + $pattern .= 'i'; + } + + return preg_match($pattern, $string) === 1; + } } diff --git a/tests/framework/console/controllers/MigrateControllerTestTrait.php b/tests/framework/console/controllers/MigrateControllerTestTrait.php index 663c5f5bca7..e09a1880bd4 100644 --- a/tests/framework/console/controllers/MigrateControllerTestTrait.php +++ b/tests/framework/console/controllers/MigrateControllerTestTrait.php @@ -10,6 +10,7 @@ use Yii; use yii\console\controllers\BaseMigrateController; use yii\helpers\FileHelper; +use yii\helpers\StringHelper; use yiiunit\TestCase; /** @@ -190,7 +191,7 @@ protected function assertMigrationHistory(array $expectedMigrations, $message = $appliedMigrations = $migrationHistory; foreach ($expectedMigrations as $expectedMigrationName) { $appliedMigration = array_shift($appliedMigrations); - if (!fnmatch(strtr($expectedMigrationName, ['\\' => DIRECTORY_SEPARATOR]), strtr($appliedMigration['version'], ['\\' => DIRECTORY_SEPARATOR]))) { + if (!StringHelper::matchWildcard(strtr($expectedMigrationName, ['\\' => DIRECTORY_SEPARATOR]), strtr($appliedMigration['version'], ['\\' => DIRECTORY_SEPARATOR]))) { $success = false; break; } diff --git a/tests/framework/helpers/StringHelperTest.php b/tests/framework/helpers/StringHelperTest.php index fbe3d28e10c..419c1e0b850 100644 --- a/tests/framework/helpers/StringHelperTest.php +++ b/tests/framework/helpers/StringHelperTest.php @@ -312,4 +312,91 @@ public function base64UrlEncodedStringsProvider() ['Это закодированная строка', '0K3RgtC-INC30LDQutC-0LTQuNGA0L7QstCw0L3QvdCw0Y8g0YHRgtGA0L7QutCw'], ]; } + + /** + * Data provider for [[testMatchWildcard()]] + * @return array test data. + */ + public function dataProviderMatchWildcard() + { + return [ + // * + ['*', 'any', true], + ['*', '', true], + ['begin*end', 'begin-middle-end', true], + ['begin*end', 'beginend', true], + ['begin*end', 'begin-d', false], + ['*end', 'beginend', true], + ['*end', 'begin', false], + ['begin*', 'begin-end', true], + ['begin*', 'end', false], + ['begin*', 'before-begin', false], + // ? + ['begin?end', 'begin1end', true], + ['begin?end', 'beginend', false], + ['begin??end', 'begin12end', true], + ['begin??end', 'begin1end', false], + // [] + ['gr[ae]y', 'gray', true], + ['gr[ae]y', 'grey', true], + ['gr[ae]y', 'groy', false], + ['a[2-8]', 'a1', false], + ['a[2-8]', 'a3', true], + ['[][!]', ']', true], + ['[-1]', '-', true], + // [!] + ['gr[!ae]y', 'gray', false], + ['gr[!ae]y', 'grey', false], + ['gr[!ae]y', 'groy', true], + ['a[!2-8]', 'a1', true], + ['a[!2-8]', 'a3', false], + // - + ['a-z', 'a-z', true], + ['a-z', 'a-c', false], + // slashes + ['begin/*/end', 'begin/middle/end', true], + ['begin/*/end', 'begin/two/steps/end', true], + ['begin/*/end', 'begin/end', false], + ['begin\\\\*\\\\end', 'begin\middle\end', true], + ['begin\\\\*\\\\end', 'begin\two\steps\end', true], + ['begin\\\\*\\\\end', 'begin\end', false], + // dots + ['begin.*.end', 'begin.middle.end', true], + ['begin.*.end', 'begin.two.steps.end', true], + ['begin.*.end', 'begin.end', false], + // case + ['begin*end', 'BEGIN-middle-END', false], + ['begin*end', 'BEGIN-middle-END', true, ['caseSensitive' => false]], + // file path + ['begin/*/end', 'begin/middle/end', true, ['filePath' => true]], + ['begin/*/end', 'begin/two/steps/end', false, ['filePath' => true]], + ['begin\\\\*\\\\end', 'begin\middle\end', true, ['filePath' => true]], + ['begin\\\\*\\\\end', 'begin\two\steps\end', false, ['filePath' => true]], + ['*', 'any', true, ['filePath' => true]], + ['*', 'any/path', false, ['filePath' => true]], + ['[.-0]', 'any/path', false, ['filePath' => true]], + ['*', '.dotenv', true, ['filePath' => true]], + // escaping + ['\*\?', '*?', true], + ['\*\?', 'zz', false], + ['begin\*\end', 'begin\middle\end', true, ['escape' => false]], + ['begin\*\end', 'begin\two\steps\end', true, ['escape' => false]], + ['begin\*\end', 'begin\end', false, ['escape' => false]], + ['begin\*\end', 'begin\middle\end', true, ['filePath' => true, 'escape' => false]], + ['begin\*\end', 'begin\two\steps\end', false, ['filePath' => true, 'escape' => false]], + ]; + } + + /** + * @dataProvider dataProviderMatchWildcard + * + * @param string $pattern + * @param string $string + * @param bool $expectedResult + * @param array $options + */ + public function testMatchWildcard($pattern, $string, $expectedResult, $options = []) + { + $this->assertSame($expectedResult, StringHelper::matchWildcard($pattern, $string, $options)); + } }