From e981778383aa1048c1f0a641517cfda746d4d696 Mon Sep 17 00:00:00 2001 From: Floris Luiten Date: Wed, 13 Dec 2023 13:27:35 +0100 Subject: [PATCH 01/25] Fix "Cannot locate enable" with Symfony project Fixes the "Cannot locate enable" error when using psalm-plugin enable with a Symfony project --- src/Psalm/Config.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index 0cd65f165f4..82c696c0bcd 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -1302,7 +1302,17 @@ private static function fromXmlAndPaths( // any paths passed via CLI should be added to the projectFiles // as they're getting analyzed like if they are part of the project // ProjectAnalyzer::getInstance()->check_paths_files is not populated at this point in time - $paths_to_check = CliUtils::getPathsToCheck(null); + + $paths_to_check = null; + + global $argv; + + // Hack for Symfonys own argv resolution. + // @see https://github.com/vimeo/psalm/issues/10465 + if (!isset($argv[0]) || basename($argv[0]) !== 'psalm-plugin') { + $paths_to_check = CliUtils::getPathsToCheck(null); + } + if ($paths_to_check !== null) { $paths_to_add_to_project_files = array(); foreach ($paths_to_check as $path) { From dee555daaf1f474fb3485ee63714b876d530e334 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Mon, 18 Sep 2023 14:48:31 +0200 Subject: [PATCH 02/25] filter_input & filter_var return type more specific --- config.xsd | 1 + dictionaries/CallMap.php | 8 +- dictionaries/CallMap_historical.php | 8 +- docs/running_psalm/issues.md | 1 + docs/running_psalm/issues/RedundantFlag.md | 8 + .../Provider/FunctionReturnTypeProvider.php | 2 + .../FilterInputReturnTypeProvider.php | 251 +++ .../ReturnTypeProvider/FilterUtils.php | 1738 +++++++++++++++++ .../FilterVarReturnTypeProvider.php | 226 +-- src/Psalm/Issue/RedundantFlag.php | 9 + tests/FunctionCallTest.php | 35 + tests/TypeReconciliation/ConditionalTest.php | 5 +- 12 files changed, 2157 insertions(+), 135 deletions(-) create mode 100644 docs/running_psalm/issues/RedundantFlag.md create mode 100644 src/Psalm/Internal/Provider/ReturnTypeProvider/FilterInputReturnTypeProvider.php create mode 100644 src/Psalm/Internal/Provider/ReturnTypeProvider/FilterUtils.php create mode 100644 src/Psalm/Issue/RedundantFlag.php diff --git a/config.xsd b/config.xsd index 4cf075b6ece..5c176821e24 100644 --- a/config.xsd +++ b/config.xsd @@ -418,6 +418,7 @@ + diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index 853b78ed8a0..19d755085ca 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -2847,11 +2847,11 @@ 'FilesystemIterator::setInfoClass' => ['void', 'class='=>'class-string'], 'FilesystemIterator::valid' => ['bool'], 'filetype' => ['string|false', 'filename'=>'string'], -'filter_has_var' => ['bool', 'input_type'=>'int', 'var_name'=>'string'], +'filter_has_var' => ['bool', 'input_type'=>'0|1|2|4|5', 'var_name'=>'string'], 'filter_id' => ['int|false', 'name'=>'string'], -'filter_input' => ['mixed|false', 'type'=>'int', 'var_name'=>'string', 'filter='=>'int', 'options='=>'array|int'], -'filter_input_array' => ['array|false|null', 'type'=>'int', 'options='=>'int|array', 'add_empty='=>'bool'], -'filter_list' => ['array'], +'filter_input' => ['mixed|false|null', 'type'=>'0|1|2|4|5', 'var_name'=>'string', 'filter='=>'int', 'options='=>'array|int'], +'filter_input_array' => ['array|false|null', 'type'=>'0|1|2|4|5', 'options='=>'int|array', 'add_empty='=>'bool'], +'filter_list' => ['non-empty-list'], 'filter_var' => ['mixed|false', 'value'=>'mixed', 'filter='=>'int', 'options='=>'array|int'], 'filter_var_array' => ['array|false|null', 'array'=>'array', 'options='=>'array|int', 'add_empty='=>'bool'], 'FilterIterator::__construct' => ['void', 'iterator'=>'Iterator'], diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index 60e798e09cb..6ca97944a9c 100644 --- a/dictionaries/CallMap_historical.php +++ b/dictionaries/CallMap_historical.php @@ -10434,11 +10434,11 @@ 'filepro_rowcount' => ['int'], 'filesize' => ['int|false', 'filename'=>'string'], 'filetype' => ['string|false', 'filename'=>'string'], - 'filter_has_var' => ['bool', 'input_type'=>'int', 'var_name'=>'string'], + 'filter_has_var' => ['bool', 'input_type'=>'0|1|2|4|5', 'var_name'=>'string'], 'filter_id' => ['int|false', 'name'=>'string'], - 'filter_input' => ['mixed|false', 'type'=>'int', 'var_name'=>'string', 'filter='=>'int', 'options='=>'array|int'], - 'filter_input_array' => ['array|false|null', 'type'=>'int', 'options='=>'int|array', 'add_empty='=>'bool'], - 'filter_list' => ['array'], + 'filter_input' => ['mixed|false|null', 'type'=>'0|1|2|4|5', 'var_name'=>'string', 'filter='=>'int', 'options='=>'array|int'], + 'filter_input_array' => ['array|false|null', 'type'=>'0|1|2|4|5', 'options='=>'int|array', 'add_empty='=>'bool'], + 'filter_list' => ['non-empty-list'], 'filter_var' => ['mixed|false', 'value'=>'mixed', 'filter='=>'int', 'options='=>'array|int'], 'filter_var_array' => ['array|false|null', 'array'=>'array', 'options='=>'array|int', 'add_empty='=>'bool'], 'finfo::__construct' => ['void', 'flags='=>'int', 'magic_database='=>'string'], diff --git a/docs/running_psalm/issues.md b/docs/running_psalm/issues.md index d9b3b4f168a..ac8135c7142 100644 --- a/docs/running_psalm/issues.md +++ b/docs/running_psalm/issues.md @@ -220,6 +220,7 @@ - [RedundantCastGivenDocblockType](issues/RedundantCastGivenDocblockType.md) - [RedundantCondition](issues/RedundantCondition.md) - [RedundantConditionGivenDocblockType](issues/RedundantConditionGivenDocblockType.md) + - [RedundantFlag](issues/RedundantFlag.md) - [RedundantFunctionCall](issues/RedundantFunctionCall.md) - [RedundantFunctionCallGivenDocblockType](issues/RedundantFunctionCallGivenDocblockType.md) - [RedundantIdentityWithTrue](issues/RedundantIdentityWithTrue.md) diff --git a/docs/running_psalm/issues/RedundantFlag.md b/docs/running_psalm/issues/RedundantFlag.md new file mode 100644 index 00000000000..2ec918aefc5 --- /dev/null +++ b/docs/running_psalm/issues/RedundantFlag.md @@ -0,0 +1,8 @@ +# RedundantFlag + +Emitted when a flag is redundant. e.g. FILTER_NULL_ON_FAILURE won't do anything when the default option is specified + +```php + array('default' => 'world.com'), 'flags' => FILTER_NULL_ON_FAILURE)); +``` diff --git a/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php b/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php index 04a5a74d572..546b7d38a02 100644 --- a/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php @@ -25,6 +25,7 @@ use Psalm\Internal\Provider\ReturnTypeProvider\BasenameReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\DateReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\DirnameReturnTypeProvider; +use Psalm\Internal\Provider\ReturnTypeProvider\FilterInputReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\FilterVarReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\FirstArgStringReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\GetClassMethodsReturnTypeProvider; @@ -85,6 +86,7 @@ public function __construct() $this->registerClass(ArrayReverseReturnTypeProvider::class); $this->registerClass(ArrayFillReturnTypeProvider::class); $this->registerClass(ArrayFillKeysReturnTypeProvider::class); + $this->registerClass(FilterInputReturnTypeProvider::class); $this->registerClass(FilterVarReturnTypeProvider::class); $this->registerClass(IteratorToArrayReturnTypeProvider::class); $this->registerClass(ParseUrlReturnTypeProvider::class); diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterInputReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterInputReturnTypeProvider.php new file mode 100644 index 00000000000..09b32b2aa65 --- /dev/null +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterInputReturnTypeProvider.php @@ -0,0 +1,251 @@ + + */ + public static function getFunctionIds(): array + { + return ['filter_input']; + } + + public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Union + { + $statements_analyzer = $event->getStatementsSource(); + if (! $statements_analyzer instanceof StatementsAnalyzer) { + throw new UnexpectedValueException('Expected StatementsAnalyzer not StatementsSource'); + } + + $call_args = $event->getCallArgs(); + $function_id = $event->getFunctionId(); + $code_location = $event->getCodeLocation(); + $codebase = $statements_analyzer->getCodebase(); + + if (! isset($call_args[0]) || ! isset($call_args[1])) { + return FilterUtils::missingFirstArg($codebase); + } + + $first_arg_type = $statements_analyzer->node_data->getType($call_args[0]->value); + if ($first_arg_type && ! $first_arg_type->isInt()) { + if ($codebase->analysis_php_version_id >= 8_00_00) { + // throws + return Type::getNever(); + } + + // default option won't be used in this case + return Type::getNull(); + } + + $filter_int_used = FILTER_DEFAULT; + if (isset($call_args[2])) { + $filter_int_used = FilterUtils::getFilterArgValueOrError( + $call_args[2], + $statements_analyzer, + $codebase, + ); + + if (!is_int($filter_int_used)) { + return $filter_int_used; + } + } + + $options = null; + $flags_int_used = FILTER_FLAG_NONE; + if (isset($call_args[3])) { + $helper = FilterUtils::getOptionsArgValueOrError( + $call_args[3], + $statements_analyzer, + $codebase, + $code_location, + $function_id, + $filter_int_used, + ); + + if (!is_array($helper)) { + return $helper; + } + + $flags_int_used = $helper['flags_int_used']; + $options = $helper['options']; + } + + // if we reach this point with callback, the callback is missing + if ($filter_int_used === FILTER_CALLBACK) { + return FilterUtils::missingFilterCallbackCallable( + $function_id, + $code_location, + $statements_analyzer, + $codebase, + ); + } + + [$default, $min_range, $max_range, $has_range, $regexp] = FilterUtils::getOptions( + $filter_int_used, + $flags_int_used, + $options, + $statements_analyzer, + $code_location, + $codebase, + $function_id, + ); + + // only return now, as we still want to report errors above + if (!$first_arg_type) { + return null; + } + + if (! $first_arg_type->isSingleIntLiteral()) { + // eventually complex cases can be handled too, however practically this is irrelevant + return null; + } + + if (!$default) { + [$fails_type, $not_set_type, $fails_or_not_set_type] = FilterUtils::getFailsNotSetType($flags_int_used); + } else { + $fails_type = $default; + $not_set_type = $default; + $fails_or_not_set_type = $default; + } + + if ($filter_int_used === FILTER_VALIDATE_REGEXP && $regexp === null) { + if ($codebase->analysis_php_version_id >= 8_00_00) { + // throws + return Type::getNever(); + } + + // any "array" flags are ignored by this filter! + return $fails_or_not_set_type; + } + + $possible_types = array( + '$_GET' => INPUT_GET, + '$_POST' => INPUT_POST, + '$_COOKIE' => INPUT_COOKIE, + '$_SERVER' => INPUT_SERVER, + '$_ENV' => INPUT_ENV, + ); + + $first_arg_type_type = $first_arg_type->getSingleIntLiteral(); + $global_name = array_search($first_arg_type_type->value, $possible_types); + if (!$global_name) { + // invalid + if ($codebase->analysis_php_version_id >= 8_00_00) { + // throws + return Type::getNever(); + } + + // the "not set type" is never in an array, even if FILTER_FORCE_ARRAY is set! + return $not_set_type; + } + + $second_arg_type = $statements_analyzer->node_data->getType($call_args[1]->value); + if (!$second_arg_type) { + return null; + } + + if (! $second_arg_type->hasString()) { + // for filter_input there can only be string array keys + return $not_set_type; + } + + if (! $second_arg_type->isString()) { + // already reports an error by default + return null; + } + + // in all these cases it can fail or be not set, depending on whether the variable is set or not + $redundant_error_return_type = FilterUtils::checkRedundantFlags( + $filter_int_used, + $flags_int_used, + $fails_or_not_set_type, + $statements_analyzer, + $code_location, + $codebase, + ); + if ($redundant_error_return_type !== null) { + return $redundant_error_return_type; + } + + if (FilterUtils::hasFlag($flags_int_used, FILTER_REQUIRE_ARRAY) + && in_array($first_arg_type_type->value, array(INPUT_COOKIE, INPUT_SERVER, INPUT_ENV), true)) { + // these globals can never be an array + return $fails_or_not_set_type; + } + + // @todo eventually this needs to be changed when we fully support filter_has_var + $global_type = VariableFetchAnalyzer::getGlobalType($global_name, $codebase->analysis_php_version_id); + + $input_type = null; + if ($global_type->isArray() && $global_type->getArray() instanceof TKeyedArray) { + $array_instance = $global_type->getArray(); + if ($second_arg_type->isSingleStringLiteral()) { + $key = $second_arg_type->getSingleStringLiteral()->value; + + if (isset($array_instance->properties[ $key ])) { + $input_type = $array_instance->properties[ $key ]; + } + } + + if ($input_type === null) { + $input_type = $array_instance->getGenericValueType(); + $input_type = $input_type->setPossiblyUndefined(true); + } + } elseif ($global_type->isArray() + && ($array_atomic = $global_type->getArray()) + && $array_atomic instanceof TArray) { + [$_, $input_type] = $array_atomic->type_params; + $input_type = $input_type->setPossiblyUndefined(true); + } else { + // this is impossible + throw new UnexpectedValueException('This should not happen'); + } + + return FilterUtils::getReturnType( + $filter_int_used, + $flags_int_used, + $input_type, + $fails_type, + $not_set_type, + $statements_analyzer, + $code_location, + $codebase, + $function_id, + $has_range, + $min_range, + $max_range, + $regexp, + ); + } +} diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterUtils.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterUtils.php new file mode 100644 index 00000000000..e0de55bba04 --- /dev/null +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterUtils.php @@ -0,0 +1,1738 @@ +analysis_php_version_id >= 8_00_00) { + // throws + return Type::getNever(); + } + + return Type::getNull(); + } + + /** @return int|Union|null */ + public static function getFilterArgValueOrError( + Arg $filter_arg, + StatementsAnalyzer $statements_analyzer, + Codebase $codebase + ) { + $filter_arg_type = $statements_analyzer->node_data->getType($filter_arg->value); + if (!$filter_arg_type) { + return null; + } + + if (! $filter_arg_type->isInt()) { + // invalid + if ($codebase->analysis_php_version_id >= 8_00_00) { + // throws + return Type::getNever(); + } + + // will return null independent of FILTER_NULL_ON_FAILURE or default option + return Type::getNull(); + } + + if (! $filter_arg_type->isSingleIntLiteral()) { + // too complex for now + return null; + } + + $all_filters = self::getFilters($codebase); + $filter_int_used = $filter_arg_type->getSingleIntLiteral()->value; + if (! isset($all_filters[ $filter_int_used ])) { + // inconsistently, this will always return false, even when FILTER_NULL_ON_FAILURE + // or a default option is set + // and will also not use any default set + return Type::getFalse(); + } + + return $filter_int_used; + } + + /** @return array{flags_int_used: int, options: TKeyedArray|null}|Union|null */ + public static function getOptionsArgValueOrError( + Arg $options_arg, + StatementsAnalyzer $statements_analyzer, + Codebase $codebase, + CodeLocation $code_location, + string $function_id, + int $filter_int_used + ) { + $options_arg_type = $statements_analyzer->node_data->getType($options_arg->value); + if (!$options_arg_type) { + return null; + } + + if ($options_arg_type->isArray()) { + $return_null = false; + $defaults = array( + 'flags_int_used' => FILTER_FLAG_NONE, + 'options' => null, + ); + + $atomic_type = $options_arg_type->getArray(); + if ($atomic_type instanceof TKeyedArray) { + $redundant_keys = array_diff(array_keys($atomic_type->properties), array('flags', 'options')); + if ($redundant_keys !== array()) { + // reported as it's usually an oversight/misunderstanding of how the function works + // it's silently ignored by the function though + IssueBuffer::maybeAdd( + new RedundantFlag( + 'The options array contains unused keys ' + . implode(', ', $redundant_keys), + $code_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + + if (isset($atomic_type->properties['options'])) { + if ($filter_int_used === FILTER_CALLBACK) { + $only_callables = true; + foreach ($atomic_type->properties['options']->getAtomicTypes() as $option_atomic) { + if ($option_atomic->isCallableType()) { + continue; + } + + if (CallableTypeComparator::getCallableFromAtomic( + $codebase, + $option_atomic, + null, + $statements_analyzer, + )) { + continue; + } + + $only_callables = false; + } + if ($atomic_type->properties['options']->possibly_undefined) { + $only_callables = false; + } + + if (!$only_callables) { + return self::missingFilterCallbackCallable( + $function_id, + $code_location, + $statements_analyzer, + $codebase, + ); + } + + // eventually can improve it to return the type from the callback + // there are no flags or other options/flags, so it can be handled here directly + // @todo + $return_type = Type::getMixed(); + return self::addReturnTaint( + $statements_analyzer, + $code_location, + $return_type, + $function_id, + ); + } + + if (! $atomic_type->properties['options']->isArray()) { + // silently ignored by the function, but this usually indicates a bug + IssueBuffer::maybeAdd( + new InvalidArgument( + 'The "options" key in ' . $function_id + . ' must be a an array', + $code_location, + $function_id, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } elseif (($options_array = $atomic_type->properties['options']->getArray()) + && $options_array instanceof TKeyedArray) { + $defaults['options'] = $options_array; + } else { + // cannot infer a 100% correct specific return type + $return_null = true; + } + } + + if (isset($atomic_type->properties['flags'])) { + if ($atomic_type->properties['flags']->isSingleIntLiteral()) { + $defaults['flags_int_used'] = $atomic_type->properties['flags']->getSingleIntLiteral()->value; + } elseif ($atomic_type->properties['flags']->isInt()) { + // cannot infer a 100% correct specific return type + $return_null = true; + } else { + // silently ignored by the function, but this usually indicates a bug + IssueBuffer::maybeAdd( + new InvalidArgument( + 'The "flags" key in ' . + $function_id . ' must be a valid flag', + $code_location, + $function_id, + ), + $statements_analyzer->getSuppressedIssues(), + ); + + $defaults['flags_int_used'] = FILTER_FLAG_NONE; + } + } + + return $return_null ? null : $defaults; + } + + // cannot infer a 100% correct specific return type + return null; + } + + if ($filter_int_used === FILTER_CALLBACK) { + return self::missingFilterCallbackCallable( + $function_id, + $code_location, + $statements_analyzer, + $codebase, + ); + } + + if ($options_arg_type->isSingleIntLiteral()) { + return array( + 'flags_int_used' => $options_arg_type->getSingleIntLiteral()->value, + 'options' => null, + ); + } + + if ($options_arg_type->isInt()) { + // in most cases we cannot infer a 100% correct specific return type though + // unless all int are literal + // @todo could handle all literal int cases + return null; + } + + foreach ($options_arg_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TArray) { + continue; + } + + if ($atomic_type instanceof TInt) { + continue; + } + + if ($atomic_type instanceof TFloat) { + // ignored + continue; + } + + if ($atomic_type instanceof TBool) { + // ignored + continue; + } + + if ($codebase->analysis_php_version_id >= 8_00_00) { + // throws for the invalid type + // for the other types it will still work correctly + // however "never" is a bottom type + // and will be lost, therefore it's better to return it here + // to identify hard to find bugs in the code + return Type::getNever(); + } + // before PHP 8, it's ignored but gives a PHP notice + } + + // array|int type which is too complex for now + // or any other invalid type + return null; + } + + public static function missingFilterCallbackCallable( + string $function_id, + CodeLocation $code_location, + StatementsAnalyzer $statements_analyzer, + Codebase $codebase + ): Union { + IssueBuffer::maybeAdd( + new InvalidArgument( + 'The "options" key in ' . $function_id + . ' must be a callable for FILTER_CALLBACK', + $code_location, + $function_id, + ), + $statements_analyzer->getSuppressedIssues(), + ); + + if ($codebase->analysis_php_version_id >= 8_00_00) { + // throws + return Type::getNever(); + } + + // flags are ignored here + return Type::getNull(); + } + + /** @return array{Union, Union, Union} */ + public static function getFailsNotSetType(int $flags_int_used): array + { + $fails_type = Type::getFalse(); + $not_set_type = Type::getNull(); + if (self::hasFlag($flags_int_used, FILTER_NULL_ON_FAILURE)) { + $fails_type = Type::getNull(); + $not_set_type = Type::getFalse(); + } + + $fails_or_not_set_type = new Union([new TNull(), new TFalse()]); + return array( + $fails_type, + $not_set_type, + $fails_or_not_set_type, + ); + } + + public static function hasFlag(int $flags, int $flag): bool + { + if ($flags === 0) { + return false; + } + + if (($flags & $flag) === $flag) { + return true; + } + + return false; + } + + public static function checkRedundantFlags( + int $filter_int_used, + int $flags_int_used, + Union $fails_type, + StatementsAnalyzer $statements_analyzer, + CodeLocation $code_location, + Codebase $codebase + ): ?Union { + $all_filters = self::getFilters($codebase); + $flags_int_used_rest = $flags_int_used; + foreach ($all_filters[ $filter_int_used ]['flags'] as $flag) { + if ($flags_int_used_rest === 0) { + break; + } + + if (self::hasFlag($flags_int_used_rest, $flag)) { + $flags_int_used_rest = $flags_int_used_rest ^ $flag; + } + } + + if ($flags_int_used_rest !== 0) { + // invalid flags used + // while they are silently ignored + // usually it means there's a mistake and the filter doesn't actually do what one expects + // as otherwise the flag wouldn't have been provided + IssueBuffer::maybeAdd( + new RedundantFlag( + 'Not all flags used are supported by the filter used', + $code_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + + if (self::hasFlag($flags_int_used, FILTER_REQUIRE_ARRAY) + && self::hasFlag($flags_int_used, FILTER_FORCE_ARRAY)) { + IssueBuffer::maybeAdd( + new RedundantFlag( + 'Flag FILTER_FORCE_ARRAY is ignored when using FILTER_REQUIRE_ARRAY', + $code_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + + if ($filter_int_used === FILTER_VALIDATE_REGEXP + && ( + self::hasFlag($flags_int_used, FILTER_REQUIRE_ARRAY) + || self::hasFlag($flags_int_used, FILTER_FORCE_ARRAY) + || self::hasFlag($flags_int_used, FILTER_REQUIRE_SCALAR)) + ) { + IssueBuffer::maybeAdd( + new RedundantFlag( + 'FILTER_VALIDATE_REGEXP will ignore ' . + 'FILTER_REQUIRE_ARRAY/FILTER_FORCE_ARRAY/FILTER_REQUIRE_SCALAR ' . + 'as it only works on scalar types', + $code_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + + if (self::hasFlag($flags_int_used, FILTER_FLAG_STRIP_LOW) + && self::hasFlag($flags_int_used, FILTER_FLAG_ENCODE_LOW)) { + IssueBuffer::maybeAdd( + new RedundantFlag( + 'Using flag FILTER_FLAG_ENCODE_LOW is redundant when using FILTER_FLAG_STRIP_LOW', + $code_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + + if (self::hasFlag($flags_int_used, FILTER_FLAG_STRIP_HIGH) + && self::hasFlag($flags_int_used, FILTER_FLAG_ENCODE_HIGH)) { + IssueBuffer::maybeAdd( + new RedundantFlag( + 'Using flag FILTER_FLAG_ENCODE_HIGH is redundant when using FILTER_FLAG_STRIP_HIGH', + $code_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + + if (self::hasFlag($flags_int_used, FILTER_REQUIRE_ARRAY) + && self::hasFlag($flags_int_used, FILTER_REQUIRE_SCALAR)) { + IssueBuffer::maybeAdd( + new RedundantFlag( + 'You cannot use FILTER_REQUIRE_ARRAY together with FILTER_REQUIRE_SCALAR flag', + $code_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + + // FILTER_REQUIRE_ARRAY will make PHP ignore FILTER_FORCE_ARRAY + return $fails_type; + } + + return null; + } + + /** @return array{Union|null, float|int|null, float|int|null, bool, non-falsy-string|true|null} */ + public static function getOptions( + int $filter_int_used, + int $flags_int_used, + ?TKeyedArray $options, + StatementsAnalyzer $statements_analyzer, + CodeLocation $code_location, + Codebase $codebase, + string $function_id + ): array { + $default = null; + $min_range = null; + $max_range = null; + $has_range = false; + $regexp = null; + + if (!$options) { + return [$default, $min_range, $max_range, $has_range, $regexp]; + } + + $all_filters = self::getFilters($codebase); + foreach ($options->properties as $option => $option_value) { + if (! isset($all_filters[ $filter_int_used ]['options'][ $option ])) { + IssueBuffer::maybeAdd( + new RedundantFlag( + 'The option ' . $option . ' is not valid for the filter used', + $code_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + + continue; + } + + if (! UnionTypeComparator::isContainedBy( + $codebase, + $option_value, + $all_filters[ $filter_int_used ]['options'][ $option ], + )) { + // silently ignored by the function, but it's a bug in the code + // since the filtering/option will not do what you expect + IssueBuffer::maybeAdd( + new InvalidArgument( + 'The option "' . $option . '" of ' . $function_id . ' expects ' + . $all_filters[ $filter_int_used ]['options'][ $option ]->getId() + . ', but ' . $option_value->getId() . ' provided', + $code_location, + $function_id, + ), + $statements_analyzer->getSuppressedIssues(), + ); + + continue; + } + + if ($option === 'default') { + $default = $option_value; + + if (self::hasFlag($flags_int_used, FILTER_NULL_ON_FAILURE)) { + IssueBuffer::maybeAdd( + new RedundantFlag( + 'Redundant flag FILTER_NULL_ON_FAILURE when using the "default" option', + $code_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + + continue; + } + + // currently only int ranges are supported + // must be numeric, otherwise we would have continued above already + if ($option === 'min_range' && $option_value->isSingleLiteral()) { + if ($filter_int_used === FILTER_VALIDATE_INT) { + $min_range = (int) $option_value->getSingleLiteral()->value; + } elseif ($filter_int_used === FILTER_VALIDATE_FLOAT) { + $min_range = (float) $option_value->getSingleLiteral()->value; + } + } + + if ($option === 'max_range' && $option_value->isSingleLiteral()) { + if ($filter_int_used === FILTER_VALIDATE_INT) { + $max_range = (int) $option_value->getSingleLiteral()->value; + } elseif ($filter_int_used === FILTER_VALIDATE_FLOAT) { + $max_range = (float) $option_value->getSingleLiteral()->value; + } + } + + if (($filter_int_used === FILTER_VALIDATE_INT || $filter_int_used === FILTER_VALIDATE_FLOAT) + && ($option === 'min_range' || $option === 'max_range') + ) { + $has_range = true; + } + + if ($filter_int_used === FILTER_VALIDATE_REGEXP + && $option === 'regexp' + ) { + if ($option_value->isSingleStringLiteral()) { + /** + * if it's another type, we would have reported an error above already + * @var non-falsy-string $regexp + */ + $regexp = $option_value->getSingleStringLiteral()->value; + } elseif ($option_value->isString()) { + $regexp = true; + } + } + } + + return [$default, $min_range, $max_range, $has_range, $regexp]; + } + + /** + * @param float|int|null $min_range + * @param float|int|null $max_range + */ + protected static function isRangeValid( + $min_range, + $max_range, + StatementsAnalyzer $statements_analyzer, + CodeLocation $code_location, + string $function_id + ): bool { + if ($min_range !== null && $max_range !== null && $min_range > $max_range) { + IssueBuffer::maybeAdd( + new InvalidArgument( + 'min_range cannot be larger than max_range', + $code_location, + $function_id, + ), + $statements_analyzer->getSuppressedIssues(), + ); + + return false; + } + + return true; + } + + /** + * can't split this because the switch is complex since there are too many possibilities + * + * @psalm-suppress ComplexMethod + * @param Union|null $not_set_type null if undefined filtered variable will return $fails_type + * @param float|int|null $min_range + * @param float|int|null $max_range + * @param non-falsy-string|true|null $regexp + */ + public static function getReturnType( + int $filter_int_used, + int $flags_int_used, + Union $input_type, + Union $fails_type, + ?Union $not_set_type, + StatementsAnalyzer $statements_analyzer, + CodeLocation $code_location, + Codebase $codebase, + string $function_id, + bool $has_range, + $min_range, + $max_range, + $regexp, + bool $in_array_recursion = false + ): Union { + // if we are inside a recursion of e.g. array + // it will never fail or change the type, so we can immediately return + if ($in_array_recursion && $input_type->isNever()) { + return $input_type; + } + + $from_array = []; + // will only handle arrays correctly if either flag is set, otherwise always error + // regexp doesn't work on arrays + if ((self::hasFlag($flags_int_used, FILTER_FORCE_ARRAY) || self::hasFlag($flags_int_used, FILTER_REQUIRE_ARRAY)) + && $filter_int_used !== FILTER_VALIDATE_REGEXP + && !self::hasFlag($flags_int_used, FILTER_REQUIRE_SCALAR) + ) { + foreach ($input_type->getAtomicTypes() as $key => $atomic_type) { + if ($atomic_type instanceof TList) { + $atomic_type = $atomic_type->getKeyedArray(); + } + + if ($atomic_type instanceof TKeyedArray) { + $input_type = $input_type->getBuilder(); + $input_type->removeType($key); + $input_type = $input_type->freeze(); + + $new = []; + foreach ($atomic_type->properties as $k => $property) { + if ($property->isNever()) { + $new[$k] = $property; + continue; + } + + $new[$k] = self::getReturnType( + $filter_int_used, + $flags_int_used, + $property, + $fails_type, + // irrelevant in nested elements + null, + $statements_analyzer, + $code_location, + $codebase, + $function_id, + $has_range, + $min_range, + $max_range, + $regexp, + true, + ); + } + + // false positive error in psalm when we loop over a non-empty array + if ($new === array()) { + throw new UnexpectedValueException('This is impossible'); + } + + $fallback_params = null; + if ($atomic_type->fallback_params) { + [$keys_union, $values_union] = $atomic_type->fallback_params; + $values_union = self::getReturnType( + $filter_int_used, + $flags_int_used, + $values_union, + $fails_type, + // irrelevant in nested elements + null, + $statements_analyzer, + $code_location, + $codebase, + $function_id, + $has_range, + $min_range, + $max_range, + $regexp, + true, + ); + $fallback_params = [$keys_union, $values_union]; + } + + $from_array[] = new TKeyedArray( + $new, + $atomic_type->class_strings, + $fallback_params, + $atomic_type->is_list, + ); + + continue; + } + + if ($atomic_type instanceof TArray) { + $input_type = $input_type->getBuilder(); + $input_type->removeType($key); + $input_type = $input_type->freeze(); + + [$keys_union, $values_union] = $atomic_type->type_params; + $values_union = self::getReturnType( + $filter_int_used, + $flags_int_used, + $values_union, + $fails_type, + // irrelevant in nested elements + null, + $statements_analyzer, + $code_location, + $codebase, + $function_id, + $has_range, + $min_range, + $max_range, + $regexp, + true, + ); + + if ($atomic_type instanceof TNonEmptyArray) { + $from_array[] = new TNonEmptyArray([$keys_union, $values_union]); + } else { + $from_array[] = new TArray([$keys_union, $values_union]); + } + + continue; + } + + // can be an array too + if ($atomic_type instanceof TMixed) { + $from_array[] = new TArray( + [ + new Union([new TArrayKey]), + new Union([new TMixed]), + ], + ); + } + } + } + + $can_fail = false; + $filter_types = array(); + switch ($filter_int_used) { + case FILTER_VALIDATE_FLOAT: + if (!self::isRangeValid( + $min_range, + $max_range, + $statements_analyzer, + $code_location, + $function_id, + )) { + $can_fail = true; + break; + } + + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TLiteralFloat) { + if ($min_range !== null && $min_range > $atomic_type->value) { + $can_fail = true; + continue; + } + + if ($max_range !== null && $max_range < $atomic_type->value) { + $can_fail = true; + continue; + } + + if ($min_range !== null || $max_range !== null || $has_range === false) { + $filter_types[] = $atomic_type; + continue; + } + + // we don't know what the min/max of the range are + // and it might be out of the range too + // float ranges aren't supported yet + $filter_types[] = new TFloat(); + } elseif ($atomic_type instanceof TFloat) { + if ($has_range === false) { + $filter_types[] = $atomic_type; + continue; + } + + // float ranges aren't supported yet + $filter_types[] = new TFloat(); + } + + if ($atomic_type instanceof TLiteralInt) { + if ($min_range !== null && $min_range > $atomic_type->value) { + $can_fail = true; + continue; + } + + if ($max_range !== null && $max_range < $atomic_type->value) { + $can_fail = true; + continue; + } + + if ($min_range !== null || $max_range !== null || $has_range === false) { + $filter_types[] = new TLiteralFloat((float) $atomic_type->value); + continue; + } + + // we don't know what the min/max of the range are + // and it might be out of the range too + $filter_types[] = new TFloat(); + } elseif ($atomic_type instanceof TInt) { + $filter_types[] = new TFloat(); + + if ($has_range === false) { + continue; + } + } + + if ($atomic_type instanceof TLiteralString) { + if (($string_to_float = filter_var($atomic_type->value, FILTER_VALIDATE_FLOAT)) === false) { + $can_fail = true; + continue; + } + + if ($min_range !== null && $min_range > $string_to_float) { + $can_fail = true; + continue; + } + + if ($max_range !== null && $max_range < $string_to_float) { + $can_fail = true; + continue; + } + + if ($min_range !== null || $max_range !== null || $has_range === false) { + $filter_types[] = new TLiteralFloat($string_to_float); + continue; + } + + // we don't know what the min/max of the range are + // and it might be out of the range too + $filter_types[] = new TFloat(); + } elseif ($atomic_type instanceof TString) { + $filter_types[] = new TFloat(); + } + + if ($atomic_type instanceof TBool) { + if ($min_range !== null && $min_range > 1) { + $can_fail = true; + continue; + } + + if ($max_range !== null && $max_range < 1) { + $can_fail = true; + continue; + } + + if ($atomic_type instanceof TFalse) { + $can_fail = true; + continue; + } + + if ($min_range !== null || $max_range !== null || $has_range === false) { + $filter_types[] = new TLiteralFloat(1.0); + + if ($atomic_type instanceof TTrue) { + continue; + } + } + + // we don't know what the min/max of the range are + // and it might be out of the range too + $filter_types[] = new TFloat(); + } + + if ($atomic_type instanceof TMixed) { + $filter_types[] = new TFloat(); + } + + $can_fail = true; + } + break; + case FILTER_VALIDATE_BOOLEAN: + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TBool) { + $filter_types[] = $atomic_type; + continue; + } + + if (($atomic_type instanceof TLiteralInt && $atomic_type->value === 1) + || ($atomic_type instanceof TLiteralFloat && $atomic_type->value === 1.0) + || ($atomic_type instanceof TLiteralString + && in_array(strtolower($atomic_type->value), ['1', 'true', 'on', 'yes'], true)) + ) { + $filter_types[] = new TTrue(); + continue; + } + + if (self::hasFlag($flags_int_used, FILTER_NULL_ON_FAILURE) + && ( + ($atomic_type instanceof TLiteralInt && $atomic_type->value === 0) + || ($atomic_type instanceof TLiteralFloat && $atomic_type->value === 0.0) + || ($atomic_type instanceof TLiteralString + && in_array(strtolower($atomic_type->value), ['0', 'false', 'off', 'no', ''], true) + ) + ) + ) { + $filter_types[] = new TFalse(); + continue; + } + + if ($atomic_type instanceof TLiteralInt + || $atomic_type instanceof TLiteralFloat + || $atomic_type instanceof TLiteralString + ) { + // all other literals will fail + $can_fail = true; + continue; + } + + if ($atomic_type instanceof TMixed + || $atomic_type instanceof TString + || $atomic_type instanceof TInt + || $atomic_type instanceof TFloat) { + $filter_types[] = new TBool(); + } + + $can_fail = true; + } + break; + case FILTER_VALIDATE_INT: + if (!self::isRangeValid( + $min_range, + $max_range, + $statements_analyzer, + $code_location, + $function_id, + )) { + $can_fail = true; + break; + } + + $min_range = $min_range !== null ? (int) $min_range : null; + $max_range = $max_range !== null ? (int) $max_range : null; + + if ($min_range !== null || $max_range !== null) { + $int_type = new TIntRange($min_range, $max_range); + } else { + $int_type = new TInt(); + } + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TLiteralInt) { + if ($min_range !== null && $min_range > $atomic_type->value) { + $can_fail = true; + continue; + } + + if ($max_range !== null && $max_range < $atomic_type->value) { + $can_fail = true; + continue; + } + + if ($min_range !== null || $max_range !== null || $has_range === false) { + $filter_types[] = $atomic_type; + continue; + } + + // we don't know what the min/max of the range are + // and it might be out of the range too + $filter_types[] = new TInt(); + } elseif ($atomic_type instanceof TInt) { + if ($has_range === false) { + $filter_types[] = $atomic_type; + continue; + } + + $filter_types[] = $int_type; + } + + if ($atomic_type instanceof TLiteralFloat) { + if ((float) (int) $atomic_type->value !== $atomic_type->value) { + $can_fail = true; + continue; + } + + if ($min_range !== null && $min_range > $atomic_type->value) { + $can_fail = true; + continue; + } + + if ($max_range !== null && $max_range < $atomic_type->value) { + $can_fail = true; + continue; + } + + if ($min_range !== null || $max_range !== null || $has_range === false) { + $filter_types[] = new TLiteralInt((int) $atomic_type->value); + continue; + } + + // we don't know what the min/max of the range are + // and it might be out of the range too + $filter_types[] = $int_type; + } elseif ($atomic_type instanceof TFloat) { + $filter_types[] = $int_type; + } + + if ($atomic_type instanceof TLiteralString) { + if (($string_to_int = filter_var($atomic_type->value, FILTER_VALIDATE_INT)) === false) { + $can_fail = true; + continue; + } + + if ($min_range !== null && $min_range > $string_to_int) { + $can_fail = true; + continue; + } + + if ($max_range !== null && $max_range < $string_to_int) { + $can_fail = true; + continue; + } + + if ($min_range !== null || $max_range !== null || $has_range === false) { + $filter_types[] = new TLiteralInt($string_to_int); + continue; + } + + // we don't know what the min/max of the range are + // and it might be out of the range too + $filter_types[] = $int_type; + } elseif ($atomic_type instanceof TString) { + $filter_types[] = $int_type; + } + + if ($atomic_type instanceof TBool) { + if ($min_range !== null && $min_range > 1) { + $can_fail = true; + continue; + } + + if ($max_range !== null && $max_range < 1) { + $can_fail = true; + continue; + } + + if ($atomic_type instanceof TFalse) { + $can_fail = true; + continue; + } + + if ($min_range !== null || $max_range !== null || $has_range === false) { + $filter_types[] = new TLiteralInt(1); + + if ($atomic_type instanceof TTrue) { + continue; + } + } + + // we don't know what the min/max of the range are + // and it might be out of the range too + $filter_types[] = $int_type; + } + + if ($atomic_type instanceof TMixed) { + $filter_types[] = $int_type; + } + + $can_fail = true; + } + break; + case FILTER_VALIDATE_IP: + case FILTER_VALIDATE_MAC: + case FILTER_VALIDATE_URL: + case FILTER_VALIDATE_EMAIL: + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TNumericString) { + $can_fail = true; + continue; + } + + if ($atomic_type instanceof TNonFalsyString) { + $filter_types[] = $atomic_type; + } elseif ($atomic_type instanceof TString) { + $filter_types[] = new TNonFalsyString(); + } + + if ($atomic_type instanceof TMixed) { + $filter_types[] = new TNonFalsyString(); + } + + $can_fail = true; + } + break; + case FILTER_VALIDATE_REGEXP: + // the regexp key is mandatory for this filter + // it will only fail if the value exists, therefore it's after the checks above + // this must be (and is) handled BEFORE calling this function though + // since PHP 8+ throws instead of returning the fails case + if ($regexp === null) { + $can_fail = true; + break; + } + + // invalid regex + if ($regexp !== true && @preg_match($regexp, 'placeholder') === false) { + $can_fail = true; + break; + } + + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TString + || $atomic_type instanceof TInt + || $atomic_type instanceof TFloat + || $atomic_type instanceof TNumeric + || $atomic_type instanceof TMixed) { + $filter_types[] = new TString(); + } + + $can_fail = true; + } + + break; + case FILTER_VALIDATE_DOMAIN: + if (self::hasFlag($flags_int_used, FILTER_FLAG_HOSTNAME)) { + $string_type = new TNonEmptyString(); + } else { + $string_type = new TString(); + } + + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TNonEmptyString) { + $filter_types[] = $atomic_type; + } elseif ($atomic_type instanceof TString) { + if (self::hasFlag($flags_int_used, FILTER_FLAG_HOSTNAME)) { + $filter_types[] = $string_type; + } else { + $filter_types[] = $atomic_type; + } + } + + if ($atomic_type instanceof TMixed + || $atomic_type instanceof TInt + || $atomic_type instanceof TFloat) { + $filter_types[] = $string_type; + } + + $can_fail = true; + } + break; + case FILTER_SANITIZE_EMAIL: + case FILTER_SANITIZE_URL: + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TNumericString) { + $filter_types[] = $atomic_type; + continue; + } + + if ($atomic_type instanceof TString) { + $filter_types[] = new TString(); + continue; + } + + if ($atomic_type instanceof TFloat + || $atomic_type instanceof TInt + || $atomic_type instanceof TNumeric) { + $filter_types[] = new TNumericString(); + continue; + } + + if ($atomic_type instanceof TTrue) { + $filter_types[] = Type::getAtomicStringFromLiteral('1'); + continue; + } + + if ($atomic_type instanceof TFalse) { + $filter_types[] = Type::getAtomicStringFromLiteral(''); + continue; + } + + if ($atomic_type instanceof TBool) { + $filter_types[] = Type::getAtomicStringFromLiteral('1'); + $filter_types[] = Type::getAtomicStringFromLiteral(''); + continue; + } + + if ($atomic_type instanceof TMixed) { + $filter_types[] = new TString(); + } + + $can_fail = true; + } + break; + case FILTER_SANITIZE_ENCODED: + case FILTER_SANITIZE_ADD_SLASHES: + case 521: // 8.0.0 FILTER_SANITIZE_MAGIC_QUOTES has been removed. + case FILTER_SANITIZE_SPECIAL_CHARS: + case FILTER_SANITIZE_FULL_SPECIAL_CHARS: + case FILTER_DEFAULT: + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($filter_int_used === FILTER_DEFAULT + && $flags_int_used === 0 + && $atomic_type instanceof TString + ) { + $filter_types[] = $atomic_type; + continue; + } + + if ($atomic_type instanceof TNumericString) { + $filter_types[] = $atomic_type; + continue; + } + + if (in_array( + $filter_int_used, + [FILTER_SANITIZE_ENCODED, FILTER_SANITIZE_SPECIAL_CHARS, FILTER_DEFAULT], + true, + ) + && $atomic_type instanceof TNonEmptyString + && (self::hasFlag($flags_int_used, FILTER_FLAG_STRIP_LOW) + || self::hasFlag($flags_int_used, FILTER_FLAG_STRIP_HIGH) + || self::hasFlag($flags_int_used, FILTER_FLAG_STRIP_BACKTICK) + ) + ) { + $filter_types[] = new TString(); + continue; + } + + if ($atomic_type instanceof TNonFalsyString) { + $filter_types[] = new TNonFalsyString(); + continue; + } + + if ($atomic_type instanceof TNonEmptyString) { + $filter_types[] = new TNonEmptyString(); + continue; + } + + if ($atomic_type instanceof TString) { + $filter_types[] = new TString(); + continue; + } + + if ($atomic_type instanceof TFloat + || $atomic_type instanceof TInt + || $atomic_type instanceof TNumeric) { + $filter_types[] = new TNumericString(); + continue; + } + + if ($atomic_type instanceof TTrue) { + $filter_types[] = Type::getAtomicStringFromLiteral('1'); + continue; + } + + if ($atomic_type instanceof TFalse) { + $filter_types[] = Type::getAtomicStringFromLiteral(''); + continue; + } + + if ($atomic_type instanceof TBool) { + $filter_types[] = Type::getAtomicStringFromLiteral('1'); + $filter_types[] = Type::getAtomicStringFromLiteral(''); + continue; + } + + if ($atomic_type instanceof TMixed) { + $filter_types[] = new TString(); + } + + $can_fail = true; + } + break; + case 513: // 8.1.0 FILTER_SANITIZE_STRING and FILTER_SANITIZE_STRIPPED (alias) have been deprecated. + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TBool + || $atomic_type instanceof TString + || $atomic_type instanceof TInt + || $atomic_type instanceof TFloat + || $atomic_type instanceof TNumeric) { + // only basic checking since it's deprecated anyway and not worth the time + $filter_types[] = new TString(); + continue; + } + + if ($atomic_type instanceof TMixed) { + $filter_types[] = new TString(); + } + + $can_fail = true; + } + break; + case FILTER_SANITIZE_NUMBER_INT: + case FILTER_SANITIZE_NUMBER_FLOAT: + foreach ($input_type->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TLiteralString + || $atomic_type instanceof TLiteralInt + || $atomic_type instanceof TLiteralFloat + ) { + /** @var string|false $literal */ + $literal = filter_var($atomic_type->value, $filter_int_used); + if ($literal === false) { + $can_fail = true; + } else { + $filter_types[] = Type::getAtomicStringFromLiteral($literal); + } + + continue; + } + + if ($atomic_type instanceof TFloat + || $atomic_type instanceof TNumericString + || $atomic_type instanceof TInt + || $atomic_type instanceof TNumeric) { + $filter_types[] = new TNumericString(); + continue; + } + + if ($atomic_type instanceof TString) { + $filter_types[] = new TNumericString(); + // for numeric-string it won't collapse since https://github.com/vimeo/psalm/pull/10459 + // therefore we can add both + $filter_types[] = Type::getAtomicStringFromLiteral(''); + continue; + } + + if ($atomic_type instanceof TTrue) { + $filter_types[] = Type::getAtomicStringFromLiteral('1'); + continue; + } + + if ($atomic_type instanceof TFalse) { + $filter_types[] = Type::getAtomicStringFromLiteral(''); + continue; + } + + if ($atomic_type instanceof TBool) { + $filter_types[] = Type::getAtomicStringFromLiteral('1'); + $filter_types[] = Type::getAtomicStringFromLiteral(''); + continue; + } + + if ($atomic_type instanceof TMixed) { + $filter_types[] = new TNumericString(); + $filter_types[] = Type::getAtomicStringFromLiteral(''); + } + + $can_fail = true; + } + break; + } + + if ($input_type->hasMixed()) { + // can always fail if we have mixed + // only for redundancy in case there's a mistake in the switch above + $can_fail = true; + } + + // if an array is required, ignore all types we created from non-array on first level + if (!$in_array_recursion && self::hasFlag($flags_int_used, FILTER_REQUIRE_ARRAY)) { + $filter_types = array(); + } + + $return_type = $fails_type; + if ($filter_types !== array() + && ($can_fail === true || + (!$in_array_recursion && !$not_set_type && $input_type->possibly_undefined) + )) { + $return_type = Type::combineUnionTypes( + $return_type, + TypeCombiner::combine($filter_types, $codebase), + $codebase, + ); + } elseif ($filter_types !== array()) { + $return_type = TypeCombiner::combine($filter_types, $codebase); + } + + if (!$in_array_recursion + && !self::hasFlag($flags_int_used, FILTER_REQUIRE_ARRAY) + && self::hasFlag($flags_int_used, FILTER_FORCE_ARRAY)) { + $return_type = new Union([new TKeyedArray( + [$return_type], + null, + null, + true, + )]); + } + + if ($from_array !== array()) { + $from_array_union = TypeCombiner::combine($from_array, $codebase); + + $return_type = Type::combineUnionTypes( + $return_type, + $from_array_union, + $codebase, + ); + } + + if ($in_array_recursion && $input_type->possibly_undefined) { + $return_type = $return_type->setPossiblyUndefined(true); + } elseif (!$in_array_recursion && $not_set_type && $input_type->possibly_undefined) { + // in case of PHP CLI it will always fail for all filter_input even when they're set + // to fix this we would have to add support for environments in Context + // e.g. if php_sapi_name() === 'cli' + $return_type = Type::combineUnionTypes( + $return_type, + // the not set type is not coerced into an array when FILTER_FORCE_ARRAY is used + $not_set_type, + $codebase, + ); + } + + if (!$in_array_recursion) { + $return_type = self::addReturnTaint( + $statements_analyzer, + $code_location, + $return_type, + $function_id, + ); + } + + return $return_type; + } + + private static function addReturnTaint( + StatementsAnalyzer $statements_analyzer, + CodeLocation $code_location, + Union $return_type, + string $function_id + ): Union { + if ($statements_analyzer->data_flow_graph + && !in_array('TaintedInput', $statements_analyzer->getSuppressedIssues()) + ) { + $function_return_sink = DataFlowNode::getForMethodReturn( + $function_id, + $function_id, + null, + $code_location, + ); + + $statements_analyzer->data_flow_graph->addNode($function_return_sink); + + $function_param_sink = DataFlowNode::getForMethodArgument( + $function_id, + $function_id, + 0, + null, + $code_location, + ); + + $statements_analyzer->data_flow_graph->addNode($function_param_sink); + + $statements_analyzer->data_flow_graph->addPath( + $function_param_sink, + $function_return_sink, + 'arg', + ); + + $return_type = $return_type->setParentNodes([$function_return_sink->id => $function_return_sink]); + } + + return $return_type; + } + + /** @return array, options: array}> */ + public static function getFilters(Codebase $codebase): array + { + $general_filter_flags = array( + FILTER_REQUIRE_SCALAR, + FILTER_REQUIRE_ARRAY, + FILTER_FORCE_ARRAY, + FILTER_FLAG_NONE, // does nothing, default + ); + + // https://www.php.net/manual/en/filter.filters.sanitize.php + $sanitize_filters = array( + FILTER_SANITIZE_EMAIL => array( + 'flags' => array(), + 'options' => array(), + ), + FILTER_SANITIZE_ENCODED => array( + 'flags' => array( + FILTER_FLAG_STRIP_LOW, + FILTER_FLAG_STRIP_HIGH, + FILTER_FLAG_STRIP_BACKTICK, + FILTER_FLAG_ENCODE_LOW, + FILTER_FLAG_ENCODE_HIGH, + ), + 'options' => array(), + ), + FILTER_SANITIZE_NUMBER_FLOAT => array( + 'flags' => array( + FILTER_FLAG_ALLOW_FRACTION, + FILTER_FLAG_ALLOW_THOUSAND, + FILTER_FLAG_ALLOW_SCIENTIFIC, + ), + 'options' => array(), + ), + FILTER_SANITIZE_NUMBER_INT => array( + 'flags' => array(), + 'options' => array(), + ), + FILTER_SANITIZE_SPECIAL_CHARS => array( + 'flags' => array( + FILTER_FLAG_STRIP_LOW, + FILTER_FLAG_STRIP_HIGH, + FILTER_FLAG_STRIP_BACKTICK, + FILTER_FLAG_ENCODE_HIGH, + ), + 'options' => array(), + ), + FILTER_SANITIZE_FULL_SPECIAL_CHARS => array( + 'flags' => array( + FILTER_FLAG_NO_ENCODE_QUOTES, + ), + 'options' => array(), + ), + FILTER_SANITIZE_URL => array( + 'flags' => array(), + 'options' => array(), + ), + FILTER_UNSAFE_RAW => array( + 'flags' => array( + FILTER_FLAG_STRIP_LOW, + FILTER_FLAG_STRIP_HIGH, + FILTER_FLAG_STRIP_BACKTICK, + FILTER_FLAG_ENCODE_LOW, + FILTER_FLAG_ENCODE_HIGH, + FILTER_FLAG_ENCODE_AMP, + ), + 'options' => array(), + ), + + ); + + if ($codebase->analysis_php_version_id <= 7_03_00) { + // FILTER_SANITIZE_MAGIC_QUOTES + $sanitize_filters[521] = array( + 'flags' => array(), + 'options' => array(), + ); + } + + if ($codebase->analysis_php_version_id <= 8_01_00) { + // FILTER_SANITIZE_STRING + $sanitize_filters[513] = array( + 'flags' => array( + FILTER_FLAG_NO_ENCODE_QUOTES, + FILTER_FLAG_STRIP_LOW, + FILTER_FLAG_STRIP_HIGH, + FILTER_FLAG_STRIP_BACKTICK, + FILTER_FLAG_ENCODE_LOW, + FILTER_FLAG_ENCODE_HIGH, + FILTER_FLAG_ENCODE_AMP, + ), + 'options' => array(), + ); + } + + if ($codebase->analysis_php_version_id >= 7_03_00) { + // was added as a replacement for FILTER_SANITIZE_MAGIC_QUOTES + $sanitize_filters[FILTER_SANITIZE_ADD_SLASHES] = array( + 'flags' => array(), + 'options' => array(), + ); + } + + foreach ($sanitize_filters as $filter_int => $filter_data) { + $sanitize_filters[$filter_int]['flags'] = array_merge($filter_data['flags'], $general_filter_flags); + } + + // https://www.php.net/manual/en/filter.filters.validate.php + // validation filters all match bitmask 0x100 + // all support FILTER_NULL_ON_FAILURE flag https://www.php.net/manual/en/filter.filters.flags.php + $general_filter_flags_validate = array_merge($general_filter_flags, array(FILTER_NULL_ON_FAILURE)); + + $validate_filters = array( + FILTER_VALIDATE_BOOLEAN => array( + 'flags' => array(), + 'options' => array(), + ), + FILTER_VALIDATE_EMAIL => array( + 'flags' => array( + FILTER_FLAG_EMAIL_UNICODE, + ), + 'options' => array(), + ), + FILTER_VALIDATE_FLOAT => array( + 'flags' => array( + FILTER_FLAG_ALLOW_THOUSAND, + ), + 'options' => array( + 'decimal' => new Union([ + Type::getAtomicStringFromLiteral('.'), + Type::getAtomicStringFromLiteral(','), + ]), + ), + ), + FILTER_VALIDATE_INT => array( + 'flags' => array( + FILTER_FLAG_ALLOW_OCTAL, + FILTER_FLAG_ALLOW_HEX, + ), + 'options' => array( + 'min_range' => Type::getNumeric(), + 'max_range' => Type::getNumeric(), + ), + ), + FILTER_VALIDATE_IP => array( + 'flags' => array( + FILTER_FLAG_IPV4, + FILTER_FLAG_IPV6, + FILTER_FLAG_NO_PRIV_RANGE, + FILTER_FLAG_NO_RES_RANGE, + + ), + 'options' => array(), + ), + FILTER_VALIDATE_MAC => array( + 'flags' => array(), + 'options' => array(), + ), + FILTER_VALIDATE_REGEXP => array( + 'flags' => array(), + 'options' => array( + 'regexp' => Type::getNonFalsyString(), + ), + ), + FILTER_VALIDATE_URL => array( + 'flags' => array( + FILTER_FLAG_PATH_REQUIRED, + FILTER_FLAG_QUERY_REQUIRED, + ), + 'options' => array(), + ), + + ); + + if ($codebase->analysis_php_version_id >= 7_04_00) { + $validate_filters[FILTER_VALIDATE_FLOAT]['options']['min_range'] = Type::getNumeric(); + $validate_filters[FILTER_VALIDATE_FLOAT]['options']['max_range'] = Type::getNumeric(); + } + + if ($codebase->analysis_php_version_id < 8_00_00) { + // phpcs:ignore SlevomatCodingStandard.Numbers.RequireNumericLiteralSeparator.RequiredNumericLiteralSeparator + $validate_filters[FILTER_VALIDATE_URL]['flags'][] = 65536; // FILTER_FLAG_SCHEME_REQUIRED + // phpcs:ignore SlevomatCodingStandard.Numbers.RequireNumericLiteralSeparator.RequiredNumericLiteralSeparator + $validate_filters[FILTER_VALIDATE_URL]['flags'][] = 131072; // FILTER_FLAG_HOST_REQUIRED + } + + if ($codebase->analysis_php_version_id >= 8_02_00) { + // phpcs:ignore SlevomatCodingStandard.Numbers.RequireNumericLiteralSeparator.RequiredNumericLiteralSeparator + $validate_filters[FILTER_VALIDATE_IP]['flags'][] = 268435456; // FILTER_FLAG_GLOBAL_RANGE + } + + if ($codebase->analysis_php_version_id >= 7_00_00) { + $validate_filters[FILTER_VALIDATE_DOMAIN] = array( + 'flags' => array( + FILTER_FLAG_HOSTNAME, + ), + 'options' => array(), + ); + } + + foreach ($validate_filters as $filter_int => $filter_data) { + $validate_filters[$filter_int]['flags'] = array_merge( + $filter_data['flags'], + $general_filter_flags_validate, + ); + + $default_options = array( + 'default' => Type::getMixed(), + ); + $validate_filters[$filter_int]['options'] = array_merge($filter_data['options'], $default_options); + } + + // https://www.php.net/manual/en/filter.filters.misc.php + $other_filters = array( + FILTER_CALLBACK => array( + // the docs say that all flags are ignored + // however this seems to be incorrect https://github.com/php/doc-en/issues/2708 + // however they can only be used in the options array, not as a param directly + 'flags' => $general_filter_flags_validate, + // the options array is required for this filter + // and must be a valid callback instead of an array like in other cases + 'options' => array(), + ), + ); + + return $sanitize_filters + $validate_filters + $other_filters; + } +} diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php index bbf3642fa63..c6ea6a15fdf 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php @@ -3,30 +3,19 @@ namespace Psalm\Internal\Provider\ReturnTypeProvider; use Psalm\Internal\Analyzer\StatementsAnalyzer; -use Psalm\Internal\DataFlow\DataFlowNode; use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent; use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; use Psalm\Type; -use Psalm\Type\Atomic\TFalse; -use Psalm\Type\Atomic\TKeyedArray; -use Psalm\Type\Atomic\TLiteralInt; -use Psalm\Type\Atomic\TNull; use Psalm\Type\Union; use UnexpectedValueException; -use function in_array; - -use const FILTER_NULL_ON_FAILURE; -use const FILTER_SANITIZE_URL; -use const FILTER_VALIDATE_BOOLEAN; -use const FILTER_VALIDATE_DOMAIN; -use const FILTER_VALIDATE_EMAIL; -use const FILTER_VALIDATE_FLOAT; -use const FILTER_VALIDATE_INT; -use const FILTER_VALIDATE_IP; -use const FILTER_VALIDATE_MAC; +use function is_array; +use function is_int; + +use const FILTER_CALLBACK; +use const FILTER_DEFAULT; +use const FILTER_FLAG_NONE; use const FILTER_VALIDATE_REGEXP; -use const FILTER_VALIDATE_URL; /** * @internal @@ -41,135 +30,124 @@ public static function getFunctionIds(): array return ['filter_var']; } - public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): Union + public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Union { - $statements_source = $event->getStatementsSource(); + $statements_analyzer = $event->getStatementsSource(); + if (!$statements_analyzer instanceof StatementsAnalyzer) { + throw new UnexpectedValueException(); + } + $call_args = $event->getCallArgs(); $function_id = $event->getFunctionId(); $code_location = $event->getCodeLocation(); - if (!$statements_source instanceof StatementsAnalyzer) { - throw new UnexpectedValueException(); + $codebase = $statements_analyzer->getCodebase(); + + if (! isset($call_args[0])) { + return FilterUtils::missingFirstArg($codebase); } - $filter_type = null; - - if (isset($call_args[1]) - && ($second_arg_type = $statements_source->node_data->getType($call_args[1]->value)) - && $second_arg_type->isSingleIntLiteral() - ) { - $filter_type_type = $second_arg_type->getSingleIntLiteral(); - - switch ($filter_type_type->value) { - case FILTER_VALIDATE_INT: - $filter_type = Type::getInt(); - break; - - case FILTER_VALIDATE_FLOAT: - $filter_type = Type::getFloat(); - break; - - case FILTER_VALIDATE_BOOLEAN: - $filter_type = Type::getBool(); - - break; - - case FILTER_VALIDATE_IP: - case FILTER_VALIDATE_MAC: - case FILTER_VALIDATE_REGEXP: - case FILTER_VALIDATE_URL: - case FILTER_VALIDATE_EMAIL: - case FILTER_VALIDATE_DOMAIN: - case FILTER_SANITIZE_URL: - $filter_type = Type::getString(); - break; - } + $filter_int_used = FILTER_DEFAULT; + if (isset($call_args[1])) { + $filter_int_used = FilterUtils::getFilterArgValueOrError( + $call_args[1], + $statements_analyzer, + $codebase, + ); - $has_object_like = false; - $filter_null = false; - - if (isset($call_args[2]) - && ($third_arg_type = $statements_source->node_data->getType($call_args[2]->value)) - && $filter_type - ) { - foreach ($third_arg_type->getAtomicTypes() as $atomic_type) { - if ($atomic_type instanceof TKeyedArray) { - $has_object_like = true; - - if (isset($atomic_type->properties['options']) - && $atomic_type->properties['options']->hasArray() - && ($options_array = $atomic_type->properties['options']->getArray()) - && $options_array instanceof TKeyedArray - && isset($options_array->properties['default']) - ) { - $filter_type = Type::combineUnionTypes( - $filter_type, - $options_array->properties['default'], - ); - } else { - $filter_type = $filter_type->getBuilder()->addType(new TFalse)->freeze(); - } - - if (isset($atomic_type->properties['flags']) - && $atomic_type->properties['flags']->isSingleIntLiteral() - ) { - $filter_flag_type = - $atomic_type->properties['flags']->getSingleIntLiteral(); - - if ($filter_type->hasBool() - && $filter_flag_type->value === FILTER_NULL_ON_FAILURE - ) { - $filter_type = $filter_type->getBuilder()->addType(new TNull)->freeze(); - } - } - } elseif ($atomic_type instanceof TLiteralInt) { - if ($atomic_type->value === FILTER_NULL_ON_FAILURE) { - $filter_null = true; - $filter_type = $filter_type->getBuilder()->addType(new TNull)->freeze(); - } - } - } + if (!is_int($filter_int_used)) { + return $filter_int_used; } + } + + $options = null; + $flags_int_used = FILTER_FLAG_NONE; + if (isset($call_args[2])) { + $helper = FilterUtils::getOptionsArgValueOrError( + $call_args[2], + $statements_analyzer, + $codebase, + $code_location, + $function_id, + $filter_int_used, + ); - if (!$has_object_like && !$filter_null && $filter_type) { - $filter_type = $filter_type->getBuilder()->addType(new TFalse)->freeze(); + if (!is_array($helper)) { + return $helper; } - } - if (!$filter_type) { - $filter_type = Type::getMixed(); + $flags_int_used = $helper['flags_int_used']; + $options = $helper['options']; } - if ($statements_source->data_flow_graph - && !in_array('TaintedInput', $statements_source->getSuppressedIssues()) - ) { - $function_return_sink = DataFlowNode::getForMethodReturn( - $function_id, + // if we reach this point with callback, the callback is missing + if ($filter_int_used === FILTER_CALLBACK) { + return FilterUtils::missingFilterCallbackCallable( $function_id, - null, $code_location, + $statements_analyzer, + $codebase, ); + } - $statements_source->data_flow_graph->addNode($function_return_sink); + [$default, $min_range, $max_range, $has_range, $regexp] = FilterUtils::getOptions( + $filter_int_used, + $flags_int_used, + $options, + $statements_analyzer, + $code_location, + $codebase, + $function_id, + ); + + if (!$default) { + [$fails_type] = FilterUtils::getFailsNotSetType($flags_int_used); + } else { + $fails_type = $default; + } - $function_param_sink = DataFlowNode::getForMethodArgument( - $function_id, - $function_id, - 0, - null, - $code_location, - ); + if ($filter_int_used === FILTER_VALIDATE_REGEXP && $regexp === null) { + if ($codebase->analysis_php_version_id >= 8_00_00) { + // throws + return Type::getNever(); + } + + // any "array" flags are ignored by this filter! + return $fails_type; + } - $statements_source->data_flow_graph->addNode($function_param_sink); + $input_type = $statements_analyzer->node_data->getType($call_args[0]->value); - $statements_source->data_flow_graph->addPath( - $function_param_sink, - $function_return_sink, - 'arg', - ); + // only return now, as we still want to report errors above + if (!$input_type) { + return null; + } - return $filter_type->setParentNodes([$function_return_sink->id => $function_return_sink]); + $redundant_error_return_type = FilterUtils::checkRedundantFlags( + $filter_int_used, + $flags_int_used, + $fails_type, + $statements_analyzer, + $code_location, + $codebase, + ); + if ($redundant_error_return_type !== null) { + return $redundant_error_return_type; } - return $filter_type; + return FilterUtils::getReturnType( + $filter_int_used, + $flags_int_used, + $input_type, + $fails_type, + null, + $statements_analyzer, + $code_location, + $codebase, + $function_id, + $has_range, + $min_range, + $max_range, + $regexp, + ); } } diff --git a/src/Psalm/Issue/RedundantFlag.php b/src/Psalm/Issue/RedundantFlag.php new file mode 100644 index 00000000000..d3f0429636d --- /dev/null +++ b/src/Psalm/Issue/RedundantFlag.php @@ -0,0 +1,9 @@ + 'string', ], ], + 'filterInput' => [ + 'code' => ' ["default" => null]]); + } + function filterIntWithDefault(string $s) : int { + return filter_var($s, FILTER_VALIDATE_INT, ["options" => ["default" => 5]]); + } + function filterBool(string $s) : bool { + return filter_var($s, FILTER_VALIDATE_BOOLEAN); + } + function filterNullableBool(string $s) : ?bool { + return filter_var($s, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + function filterNullableBoolWithFlagsArray(string $s) : ?bool { + return filter_var($s, FILTER_VALIDATE_BOOLEAN, ["flags" => FILTER_NULL_ON_FAILURE]); + } + function filterFloat(string $s) : float { + $filtered = filter_var($s, FILTER_VALIDATE_FLOAT); + if ($filtered === false) { + return 0.0; + } + return $filtered; + } + function filterFloatWithDefault(string $s) : float { + return filter_var($s, FILTER_VALIDATE_FLOAT, ["options" => ["default" => 5.0]]); + }', + ], 'filterVar' => [ 'code' => ' [ 'code' => '|false|string */ - function foo() : array { + function foo() { return filter_input(INPUT_POST, "some_var") ?? []; }', ], From f8a53ebc5db2ae12a51f94a4590b67b0c4071592 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Mon, 18 Dec 2023 12:48:13 +0100 Subject: [PATCH 03/25] Fix callable without args not handled correctly --- .../Expression/Call/HighOrderFunctionArgHandler.php | 3 ++- tests/FunctionCallTest.php | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgHandler.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgHandler.php index 3d1a51c4e67..14a947f517a 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgHandler.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgHandler.php @@ -288,7 +288,8 @@ private static function isSupported(FunctionLikeParameter $container_param): boo } foreach ($container_param->type->getAtomicTypes() as $a) { - if (($a instanceof TClosure || $a instanceof TCallable) && !$a->params) { + // must check null explicitly, since no params (empty array) would not be handled correctly otherwise + if (($a instanceof TClosure || $a instanceof TCallable) && $a->params === null) { return false; } diff --git a/tests/FunctionCallTest.php b/tests/FunctionCallTest.php index 2b73d655018..b24166f9c2f 100644 --- a/tests/FunctionCallTest.php +++ b/tests/FunctionCallTest.php @@ -2352,6 +2352,18 @@ function fooFoo(int $a): void {} fooFoo("string");', 'error_message' => 'InvalidScalarArgument', ], + 'invalidArgumentCallableWithoutArgsUnion' => [ + 'code' => ' 'InvalidArgument', + ], 'invalidArgumentWithDeclareStrictTypes' => [ 'code' => ' Date: Mon, 18 Dec 2023 12:53:45 +0100 Subject: [PATCH 04/25] fix other places that have a similar bug --- src/Psalm/Codebase.php | 8 ++++---- src/Psalm/Internal/Codebase/Functions.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index 414703a4435..993958e607a 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -1688,7 +1688,7 @@ public function getSignatureInformation( if (InternalCallMapHandler::inCallMap($function_symbol)) { $callables = InternalCallMapHandler::getCallablesFromCallMap($function_symbol); - if (!$callables || !$callables[0]->params) { + if (!$callables || !isset($callables[0]->params)) { return null; } @@ -1838,7 +1838,7 @@ public function getBeginedLiteralPart(string $file_path, Position $position): st $offset = $position->toOffset($file_contents); preg_match('/\$?\w+$/', substr($file_contents, 0, $offset), $matches); - + return $matches[0] ?? ''; } @@ -1957,7 +1957,7 @@ public function getCompletionItemsForClassishThing( str_replace('$', '', $property_name), ); } - + foreach ($class_storage->pseudo_property_set_types as $property_name => $type) { $pseudo_property_types[$property_name] = new CompletionItem( str_replace('$', '', $property_name), @@ -1969,7 +1969,7 @@ public function getCompletionItemsForClassishThing( str_replace('$', '', $property_name), ); } - + $completion_items = [...$completion_items, ...array_values($pseudo_property_types)]; } diff --git a/src/Psalm/Internal/Codebase/Functions.php b/src/Psalm/Internal/Codebase/Functions.php index acba38f28bb..22fe2ee8edf 100644 --- a/src/Psalm/Internal/Codebase/Functions.php +++ b/src/Psalm/Internal/Codebase/Functions.php @@ -466,7 +466,7 @@ public function isCallMapFunctionPure( null, ); - if (!$function_callable->params + if (!isset($function_callable->params) || ($args !== null && count($args) === 0) || ($function_callable->return_type && $function_callable->return_type->isVoid()) ) { From 1ff8518888e8c8839f59a54f4fbb30d0cb2de716 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Tue, 19 Dec 2023 11:07:11 +0100 Subject: [PATCH 05/25] Fix https://github.com/vimeo/psalm/issues/9840 --- .../Expression/Call/ArgumentsAnalyzer.php | 73 +++++++++++++++++++ tests/ImmutableAnnotationTest.php | 63 ++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index 9ff268abeb6..97026ac25e2 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -7,6 +7,7 @@ use Psalm\Codebase; use Psalm\Context; use Psalm\Internal\Analyzer\AttributesAnalyzer; +use Psalm\Internal\Analyzer\Statements\Expression\Assignment\InstancePropertyAssignmentAnalyzer; use Psalm\Internal\Analyzer\Statements\Expression\AssignmentAnalyzer; use Psalm\Internal\Analyzer\Statements\Expression\CallAnalyzer; use Psalm\Internal\Analyzer\Statements\Expression\ExpressionIdentifier; @@ -45,6 +46,7 @@ use Psalm\Type\Atomic\TKeyedArray; use Psalm\Type\Atomic\TList; use Psalm\Type\Atomic\TLiteralString; +use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Atomic\TNonEmptyArray; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; @@ -1253,6 +1255,37 @@ private static function evaluateArbitraryParam( return null; } + private static function handleByRefReadonlyArg( + StatementsAnalyzer $statements_analyzer, + Context $context, + PhpParser\Node\Expr\PropertyFetch $stmt, + string $fq_class_name, + string $prop_name + ): void { + $property_id = $fq_class_name . '::$' . $prop_name; + + $codebase = $statements_analyzer->getCodebase(); + $declaring_property_class = (string) $codebase->properties->getDeclaringClassForProperty( + $property_id, + false, # what does this do? @todo + $statements_analyzer, + ); + $declaring_class_storage = $codebase->classlike_storage_provider->get($declaring_property_class); + + if (isset($declaring_class_storage->properties[$prop_name])) { + $property_storage = $declaring_class_storage->properties[$prop_name]; + + InstancePropertyAssignmentAnalyzer::trackPropertyImpurity( + $statements_analyzer, + $stmt, + $property_id, + $property_storage, + $declaring_class_storage, + $context, + ); + } + } + /** * @return false|null */ @@ -1274,6 +1307,46 @@ private static function handleByRefFunctionArg( 'reset', 'end', 'next', 'prev', 'array_pop', 'array_shift', ]; + if ($arg->value instanceof PhpParser\Node\Expr\PropertyFetch + && $arg->value->name instanceof PhpParser\Node\Identifier) { + $prop_name = $arg->value->name->name; + if ($statements_analyzer->getFQCLN()) { + $fq_class_name = $statements_analyzer->getFQCLN(); + + self::handleByRefReadonlyArg( + $statements_analyzer, + $context, + $arg->value, + $fq_class_name, + $prop_name, + ); + } else { + // @todo atm only works for simple fetch, $a->foo, not $a->foo->bar + // I guess there's a function to do this, but I couldn't locate it + $var_id = ExpressionIdentifier::getVarId( + $arg->value->var, + $statements_analyzer->getFQCLN(), + $statements_analyzer, + ); + + if ($var_id && isset($context->vars_in_scope[$var_id])) { + foreach ($context->vars_in_scope[$var_id]->getAtomicTypes() as $atomic_type) { + if ($atomic_type instanceof TNamedObject) { + $fq_class_name = $atomic_type->value; + + self::handleByRefReadonlyArg( + $statements_analyzer, + $context, + $arg->value, + $fq_class_name, + $prop_name, + ); + } + } + } + } + } + if (($var_id && isset($context->vars_in_scope[$var_id])) || ($method_id && in_array( diff --git a/tests/ImmutableAnnotationTest.php b/tests/ImmutableAnnotationTest.php index 1aa481b2a03..56838b9411f 100644 --- a/tests/ImmutableAnnotationTest.php +++ b/tests/ImmutableAnnotationTest.php @@ -764,6 +764,69 @@ public function getShortMutating() : string { }', 'error_message' => 'ImpurePropertyAssignment', ], + 'readonlyByRefInClass' => [ + 'code' => 'values = $values; + } + + public function bar(): mixed + { + return reset($this->values); + } + }', + 'error_message' => 'InaccessibleProperty', + ], + 'readonlyByRef' => [ + 'code' => 'values = $values; + } + } + + $x = new Foo([]); + reset($x->values);', + 'error_message' => 'InaccessibleProperty', + ], + 'readonlyByRefCustomFunction' => [ + 'code' => 'values = $values; + } + } + + /** + * @param string $a + * @param array $b + * @return void + */ + function bar($a, &$b) {} + + $x = new Foo([]); + bar("hello", $x->values);', + 'error_message' => 'InaccessibleProperty', + ], 'preventUnset' => [ 'code' => ' Date: Tue, 19 Dec 2023 11:22:51 +0100 Subject: [PATCH 06/25] make tests work in PHP < 8.2 --- .../Expression/Call/ArgumentsAnalyzer.php | 12 ++- src/Psalm/Type/Atomic/TKeyedArray.php | 2 + tests/ImmutableAnnotationTest.php | 98 +++++++++++-------- 3 files changed, 66 insertions(+), 46 deletions(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index 97026ac25e2..003355bfcd4 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -2,6 +2,7 @@ namespace Psalm\Internal\Analyzer\Statements\Expression\Call; +use InvalidArgumentException; use PhpParser; use Psalm\CodeLocation; use Psalm\Codebase; @@ -1267,10 +1268,15 @@ private static function handleByRefReadonlyArg( $codebase = $statements_analyzer->getCodebase(); $declaring_property_class = (string) $codebase->properties->getDeclaringClassForProperty( $property_id, - false, # what does this do? @todo + true, $statements_analyzer, ); - $declaring_class_storage = $codebase->classlike_storage_provider->get($declaring_property_class); + + try { + $declaring_class_storage = $codebase->classlike_storage_provider->get($declaring_property_class); + } catch (InvalidArgumentException $_) { + return; + } if (isset($declaring_class_storage->properties[$prop_name])) { $property_storage = $declaring_class_storage->properties[$prop_name]; @@ -1310,7 +1316,7 @@ private static function handleByRefFunctionArg( if ($arg->value instanceof PhpParser\Node\Expr\PropertyFetch && $arg->value->name instanceof PhpParser\Node\Identifier) { $prop_name = $arg->value->name->name; - if ($statements_analyzer->getFQCLN()) { + if (!empty($statements_analyzer->getFQCLN())) { $fq_class_name = $statements_analyzer->getFQCLN(); self::handleByRefReadonlyArg( diff --git a/src/Psalm/Type/Atomic/TKeyedArray.php b/src/Psalm/Type/Atomic/TKeyedArray.php index 3bd7e2a65e4..c1c29b991c8 100644 --- a/src/Psalm/Type/Atomic/TKeyedArray.php +++ b/src/Psalm/Type/Atomic/TKeyedArray.php @@ -112,6 +112,8 @@ public function setProperties(array $properties): self if ($cloned->is_list) { $last_k = -1; $had_possibly_undefined = false; + + /** @psalm-suppress InaccessibleProperty */ ksort($cloned->properties); foreach ($cloned->properties as $k => $v) { if (is_string($k) || $last_k !== ($k-1) || ($had_possibly_undefined && !$v->possibly_undefined)) { diff --git a/tests/ImmutableAnnotationTest.php b/tests/ImmutableAnnotationTest.php index 56838b9411f..da36f660df9 100644 --- a/tests/ImmutableAnnotationTest.php +++ b/tests/ImmutableAnnotationTest.php @@ -768,63 +768,75 @@ public function getShortMutating() : string { 'code' => 'values = $values; - } - - public function bar(): mixed - { - return reset($this->values); - } - }', + final class Foo + { + /** + * @readonly + */ + public array $values; + + public function __construct(array $values) + { + $this->values = $values; + } + + /** + * @return mixed + */ + public function bar() + { + return reset($this->values); + } + }', 'error_message' => 'InaccessibleProperty', ], 'readonlyByRef' => [ 'code' => 'values = $values; - } - } + public function __construct(array $values) + { + $this->values = $values; + } + } - $x = new Foo([]); - reset($x->values);', + $x = new Foo([]); + reset($x->values);', 'error_message' => 'InaccessibleProperty', ], 'readonlyByRefCustomFunction' => [ 'code' => 'values = $values; - } - } - - /** - * @param string $a - * @param array $b - * @return void - */ - function bar($a, &$b) {} - - $x = new Foo([]); - bar("hello", $x->values);', + final class Foo + { + /** + * @readonly + */ + public array $values; + + public function __construct(array $values) + { + $this->values = $values; + } + } + + /** + * @param string $a + * @param array $b + * @return void + */ + function bar($a, &$b) {} + + $x = new Foo([]); + bar("hello", $x->values);', 'error_message' => 'InaccessibleProperty', ], 'preventUnset' => [ From 5731f927feb67e762e16bdd3c5694569d8315a95 Mon Sep 17 00:00:00 2001 From: ging-dev Date: Wed, 20 Dec 2023 17:16:21 +0700 Subject: [PATCH 07/25] fix: #10496 #10503 --- src/Psalm/Internal/Type/TypeParser.php | 80 ++++++++------------------ tests/AnnotationTest.php | 14 +++++ 2 files changed, 39 insertions(+), 55 deletions(-) diff --git a/src/Psalm/Internal/Type/TypeParser.php b/src/Psalm/Internal/Type/TypeParser.php index 58718eae2dd..e908c4e3a1d 100644 --- a/src/Psalm/Internal/Type/TypeParser.php +++ b/src/Psalm/Internal/Type/TypeParser.php @@ -641,13 +641,31 @@ private static function getTypeFromGenericTree( throw new TypeParseTreeException('No generic params provided for type'); } - if ($generic_type_value === 'array' || $generic_type_value === 'associative-array') { + if ($generic_type_value === 'array' + || $generic_type_value === 'associative-array' + || $generic_type_value === 'non-empty-array' + ) { + if ($generic_type_value !== 'non-empty-array') { + $generic_type_value = 'array'; + } + if ($generic_params[0]->isMixed()) { $generic_params[0] = Type::getArrayKey($from_docblock); } if (count($generic_params) !== 2) { - throw new TypeParseTreeException('Too many template parameters for array'); + throw new TypeParseTreeException('Too many template parameters for '.$generic_type_value); + } + + if ($type_aliases !== []) { + $intersection_types = self::resolveTypeAliases( + $codebase, + $generic_params[0]->getAtomicTypes(), + ); + + if ($intersection_types !== []) { + $generic_params[0] = $generic_params[0]->setTypes($intersection_types); + } } foreach ($generic_params[0]->getAtomicTypes() as $key => $atomic_type) { @@ -671,6 +689,7 @@ private static function getTypeFromGenericTree( || $atomic_type instanceof TNever || $atomic_type instanceof TTemplateParam || $atomic_type instanceof TValueOf + || !$from_docblock ) { continue; } @@ -690,7 +709,10 @@ private static function getTypeFromGenericTree( throw new TypeParseTreeException('Invalid array key type ' . $atomic_type->getKey()); } - return new TArray($generic_params, $from_docblock); + return $generic_type_value === 'array' + ? new TArray($generic_params, $from_docblock) + : new TNonEmptyArray($generic_params, null, null, 'non-empty-array', $from_docblock) + ; } if ($generic_type_value === 'arraylike-object') { @@ -709,58 +731,6 @@ private static function getTypeFromGenericTree( ); } - if ($generic_type_value === 'non-empty-array') { - if ($generic_params[0]->isMixed()) { - $generic_params[0] = Type::getArrayKey($from_docblock); - } - - if (count($generic_params) !== 2) { - throw new TypeParseTreeException('Too many template parameters for non-empty-array'); - } - - foreach ($generic_params[0]->getAtomicTypes() as $key => $atomic_type) { - // PHP 8 values with whitespace after number are counted as numeric - // and filter_var treats them as such too - if ($atomic_type instanceof TLiteralString - && trim($atomic_type->value) === $atomic_type->value - && ($string_to_int = filter_var($atomic_type->value, FILTER_VALIDATE_INT)) !== false - ) { - $builder = $generic_params[0]->getBuilder(); - $builder->removeType($key); - $generic_params[0] = $builder->addType(new TLiteralInt($string_to_int, $from_docblock))->freeze(); - continue; - } - - if ($atomic_type instanceof TInt - || $atomic_type instanceof TString - || $atomic_type instanceof TArrayKey - || $atomic_type instanceof TClassConstant // @todo resolve and check types - || $atomic_type instanceof TMixed - || $atomic_type instanceof TNever - || $atomic_type instanceof TTemplateParam - || $atomic_type instanceof TValueOf - ) { - continue; - } - - if ($codebase->register_stub_files || $codebase->register_autoload_files) { - $builder = $generic_params[0]->getBuilder(); - $builder->removeType($key); - - if (count($generic_params[0]->getAtomicTypes()) <= 1) { - $builder = $builder->addType(new TArrayKey($from_docblock)); - } - - $generic_params[0] = $builder->freeze(); - continue; - } - - throw new TypeParseTreeException('Invalid array key type ' . $atomic_type->getKey()); - } - - return new TNonEmptyArray($generic_params, null, null, 'non-empty-array', $from_docblock); - } - if ($generic_type_value === 'iterable') { if (count($generic_params) > 2) { throw new TypeParseTreeException('Too many template parameters for iterable'); diff --git a/tests/AnnotationTest.php b/tests/AnnotationTest.php index e0855c3bdc8..64ebf674b4e 100644 --- a/tests/AnnotationTest.php +++ b/tests/AnnotationTest.php @@ -1348,6 +1348,20 @@ function f(): array } EOT, ], + 'validArrayKeyAlias' => [ + 'code' => ' + */ + class Foo {}', + 'assertions' => [], + ], ]; } From 1cb7dd9d7277cf630584c13a3a7d165c756de595 Mon Sep 17 00:00:00 2001 From: Sam L Date: Sun, 24 Dec 2023 12:02:37 -0500 Subject: [PATCH 08/25] Initial commit, issue-10490 Reproduce failure in unit test --- tests/DeprecatedAnnotationTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/DeprecatedAnnotationTest.php b/tests/DeprecatedAnnotationTest.php index adcf30e5b17..0f465bf7c72 100644 --- a/tests/DeprecatedAnnotationTest.php +++ b/tests/DeprecatedAnnotationTest.php @@ -99,6 +99,24 @@ class A { public $property; } '], + 'suppressDeprecatedClassOnTemplateType' => [ + 'code' => ' + * @psalm-suppress DeprecatedClass + */ + class TheChildClass extends TheParentClass {} + '], ]; } From 21617c702913b5556383421e72f88b9884fc6aad Mon Sep 17 00:00:00 2001 From: Sam L Date: Sun, 24 Dec 2023 17:29:05 -0500 Subject: [PATCH 09/25] Include suppressed issues from the class the template is being used by --- src/Psalm/Internal/Analyzer/ClassAnalyzer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index a37b4b7ad04..7d376caf544 100644 --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php @@ -350,7 +350,7 @@ public function analyze( null, true, ), - $this->getSuppressedIssues(), + $storage->suppressed_issues + $this->getSuppressedIssues(), ); } } From a60de4bac81a1d99b976bea2a962ad67fe65093f Mon Sep 17 00:00:00 2001 From: Sam L Date: Sun, 24 Dec 2023 17:50:57 -0500 Subject: [PATCH 10/25] Whitelist suppressed issue types to for template extended params --- src/Psalm/Internal/Analyzer/ClassAnalyzer.php | 2 +- src/Psalm/Storage/ClassLikeStorage.php | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index 7d376caf544..4e6ef467bcb 100644 --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php @@ -350,7 +350,7 @@ public function analyze( null, true, ), - $storage->suppressed_issues + $this->getSuppressedIssues(), + $storage->getSuppressedIssuesForTemplateExtendParams() + $this->getSuppressedIssues(), ); } } diff --git a/src/Psalm/Storage/ClassLikeStorage.php b/src/Psalm/Storage/ClassLikeStorage.php index f8564444a26..737854d5eda 100644 --- a/src/Psalm/Storage/ClassLikeStorage.php +++ b/src/Psalm/Storage/ClassLikeStorage.php @@ -10,11 +10,13 @@ use Psalm\Internal\MethodIdentifier; use Psalm\Internal\Type\TypeAlias\ClassTypeAlias; use Psalm\Issue\CodeIssue; +use Psalm\Issue\DeprecatedClass; use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; use function array_values; +use function in_array; final class ClassLikeStorage implements HasAttributesInterface { @@ -550,4 +552,22 @@ private function hasAttribute(string $fq_class_name): bool return false; } + + /** + * @return array + */ + public function getSuppressedIssuesForTemplateExtendParams(): array + { + $allowed_issue_types = [ + DeprecatedClass::getIssueType(), + ]; + $suppressed_issues_for_template_extend_params = []; + foreach ($this->suppressed_issues as $offset => $suppressed_issue) { + if (!in_array($suppressed_issue, $allowed_issue_types, true)) { + continue; + } + $suppressed_issues_for_template_extend_params[$offset] = $suppressed_issue; + } + return $suppressed_issues_for_template_extend_params; + } } From 891036ef3b5aba5466e7fad85ff93c3530610277 Mon Sep 17 00:00:00 2001 From: Ivan Kurnosov Date: Wed, 3 Jan 2024 10:55:04 +1300 Subject: [PATCH 11/25] Fixed SessionUpdateTimestampHandlerInterface parameter names Close https://github.com/vimeo/psalm/issues/10512 --- dictionaries/CallMap.php | 4 ++-- dictionaries/CallMap_historical.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index 853b78ed8a0..a771eb5b74d 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -11141,8 +11141,8 @@ 'SessionIdInterface::create_sid' => ['string'], 'SessionUpdateTimestampHandler::updateTimestamp' => ['bool', 'id'=>'string', 'data'=>'string'], 'SessionUpdateTimestampHandler::validateId' => ['char', 'id'=>'string'], -'SessionUpdateTimestampHandlerInterface::updateTimestamp' => ['bool', 'key'=>'string', 'value'=>'string'], -'SessionUpdateTimestampHandlerInterface::validateId' => ['bool', 'key'=>'string'], +'SessionUpdateTimestampHandlerInterface::updateTimestamp' => ['bool', 'id'=>'string', 'data'=>'string'], +'SessionUpdateTimestampHandlerInterface::validateId' => ['bool', 'id'=>'string'], 'set_error_handler' => ['null|callable(int,string,string=,int=,array=):bool', 'callback'=>'null|callable(int,string,string=,int=,array=):bool', 'error_levels='=>'int'], 'set_exception_handler' => ['null|callable(Throwable):void', 'callback'=>'null|callable(Throwable):void'], 'set_file_buffer' => ['int', 'stream'=>'resource', 'size'=>'int'], diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index 60e798e09cb..56399017b9f 100644 --- a/dictionaries/CallMap_historical.php +++ b/dictionaries/CallMap_historical.php @@ -6583,8 +6583,8 @@ 'SessionIdInterface::create_sid' => ['string'], 'SessionUpdateTimestampHandler::updateTimestamp' => ['bool', 'id'=>'string', 'data'=>'string'], 'SessionUpdateTimestampHandler::validateId' => ['char', 'id'=>'string'], - 'SessionUpdateTimestampHandlerInterface::updateTimestamp' => ['bool', 'key'=>'string', 'value'=>'string'], - 'SessionUpdateTimestampHandlerInterface::validateId' => ['bool', 'key'=>'string'], + 'SessionUpdateTimestampHandlerInterface::updateTimestamp' => ['bool', 'id'=>'string', 'data'=>'string'], + 'SessionUpdateTimestampHandlerInterface::validateId' => ['bool', 'id'=>'string'], 'SimpleXMLElement::__construct' => ['void', 'data'=>'string', 'options='=>'int', 'dataIsURL='=>'bool', 'namespaceOrPrefix='=>'string', 'isPrefix='=>'bool'], 'SimpleXMLElement::__get' => ['SimpleXMLElement', 'name'=>'string'], 'SimpleXMLElement::__toString' => ['string'], From 1a3bba3e91d4771faeb76d4412c47b8533292e54 Mon Sep 17 00:00:00 2001 From: sji Date: Tue, 9 Jan 2024 06:42:49 +0900 Subject: [PATCH 12/25] Reduce memory consumption of caching and parallel processing without igbinary --- src/Psalm/Aliases.php | 4 ++ src/Psalm/CodeLocation.php | 39 ++++++++++++++++ src/Psalm/Internal/MethodIdentifier.php | 2 + src/Psalm/Storage/Assertion.php | 1 + src/Psalm/Storage/AttributeArg.php | 1 + src/Psalm/Storage/AttributeStorage.php | 1 + src/Psalm/Storage/ClassConstantStorage.php | 1 + src/Psalm/Storage/ClassLikeStorage.php | 1 + src/Psalm/Storage/EnumCaseStorage.php | 2 + src/Psalm/Storage/FileStorage.php | 1 + src/Psalm/Storage/FunctionLikeParameter.php | 1 + src/Psalm/Storage/FunctionLikeStorage.php | 1 + src/Psalm/Storage/Possibilities.php | 2 + src/Psalm/Storage/PropertyStorage.php | 1 + ...UnserializeMemoryUsageSuppressionTrait.php | 24 ++++++++++ src/Psalm/Type/Atomic.php | 3 ++ src/Psalm/Type/Union.php | 46 +++++++++++++++++++ 17 files changed, 131 insertions(+) create mode 100644 src/Psalm/Storage/UnserializeMemoryUsageSuppressionTrait.php diff --git a/src/Psalm/Aliases.php b/src/Psalm/Aliases.php index 526021c8a57..103d3f449d0 100644 --- a/src/Psalm/Aliases.php +++ b/src/Psalm/Aliases.php @@ -2,8 +2,12 @@ namespace Psalm; +use Psalm\Storage\UnserializeMemoryUsageSuppressionTrait; + final class Aliases { + use UnserializeMemoryUsageSuppressionTrait; + /** * @var array */ diff --git a/src/Psalm/CodeLocation.php b/src/Psalm/CodeLocation.php index 0bdfd64a683..344a5981972 100644 --- a/src/Psalm/CodeLocation.php +++ b/src/Psalm/CodeLocation.php @@ -100,6 +100,32 @@ class CodeLocation public const CATCH_VAR = 6; public const FUNCTION_PHPDOC_METHOD = 7; + private const PROPERTY_KEYS_FOR_UNSERIALIZE = [ + 'file_path' => 'file_path', + 'file_name' => 'file_name', + 'raw_line_number' => 'raw_line_number', + "\0" . self::class . "\0" . 'end_line_number' => 'end_line_number', + 'raw_file_start' => 'raw_file_start', + 'raw_file_end' => 'raw_file_end', + "\0*\0" . 'file_start' => 'file_start', + "\0*\0" . 'file_end' => 'file_end', + "\0*\0" . 'single_line' => 'single_line', + "\0*\0" . 'preview_start' => 'preview_start', + "\0" . self::class . "\0" . 'preview_end' => 'preview_end', + "\0" . self::class . "\0" . 'selection_start' => 'selection_start', + "\0" . self::class . "\0" . 'selection_end' => 'selection_end', + "\0" . self::class . "\0" . 'column_from' => 'column_from', + "\0" . self::class . "\0" . 'column_to' => 'column_to', + "\0" . self::class . "\0" . 'snippet' => 'snippet', + "\0" . self::class . "\0" . 'text' => 'text', + 'docblock_start' => 'docblock_start', + "\0" . self::class . "\0" . 'docblock_start_line_number' => 'docblock_start_line_number', + "\0*\0" . 'docblock_line_number' => 'docblock_line_number', + "\0" . self::class . "\0" . 'regex_type' => 'regex_type', + "\0" . self::class . "\0" . 'have_recalculated' => 'have_recalculated', + 'previous_location' => 'previous_location', + ]; + public function __construct( FileSource $file_source, PhpParser\Node $stmt, @@ -136,6 +162,19 @@ public function __construct( $this->docblock_line_number = $comment_line; } + /** + * Suppresses memory usage when unserializing objects. + * + * @see \Psalm\Storage\UnserializeMemoryUsageSuppressionTrait + */ + public function __unserialize(array $properties): void + { + foreach (self::PROPERTY_KEYS_FOR_UNSERIALIZE as $key => $property_name) { + /** @psalm-suppress PossiblyUndefinedStringArrayOffset */ + $this->$property_name = $properties[$key]; + } + } + /** * @psalm-suppress PossiblyUnusedMethod Part of public API * @return static diff --git a/src/Psalm/Internal/MethodIdentifier.php b/src/Psalm/Internal/MethodIdentifier.php index 8d2d18d6d23..62742a74b9f 100644 --- a/src/Psalm/Internal/MethodIdentifier.php +++ b/src/Psalm/Internal/MethodIdentifier.php @@ -4,6 +4,7 @@ use InvalidArgumentException; use Psalm\Storage\ImmutableNonCloneableTrait; +use Psalm\Storage\UnserializeMemoryUsageSuppressionTrait; use function explode; use function is_string; @@ -18,6 +19,7 @@ final class MethodIdentifier { use ImmutableNonCloneableTrait; + use UnserializeMemoryUsageSuppressionTrait; public string $fq_class_name; /** @var lowercase-string */ diff --git a/src/Psalm/Storage/Assertion.php b/src/Psalm/Storage/Assertion.php index 2fcc4324bbc..4053ba9c44d 100644 --- a/src/Psalm/Storage/Assertion.php +++ b/src/Psalm/Storage/Assertion.php @@ -10,6 +10,7 @@ abstract class Assertion { use ImmutableNonCloneableTrait; + use UnserializeMemoryUsageSuppressionTrait; abstract public function getNegation(): Assertion; diff --git a/src/Psalm/Storage/AttributeArg.php b/src/Psalm/Storage/AttributeArg.php index 0472ad6b94b..17b5fd43d92 100644 --- a/src/Psalm/Storage/AttributeArg.php +++ b/src/Psalm/Storage/AttributeArg.php @@ -12,6 +12,7 @@ final class AttributeArg { use ImmutableNonCloneableTrait; + use UnserializeMemoryUsageSuppressionTrait; /** * @var ?string * @psalm-suppress PossiblyUnusedProperty It's part of the public API for now diff --git a/src/Psalm/Storage/AttributeStorage.php b/src/Psalm/Storage/AttributeStorage.php index 7b9b0e7298a..72bca00bb60 100644 --- a/src/Psalm/Storage/AttributeStorage.php +++ b/src/Psalm/Storage/AttributeStorage.php @@ -10,6 +10,7 @@ final class AttributeStorage { use ImmutableNonCloneableTrait; + use UnserializeMemoryUsageSuppressionTrait; /** * @var string */ diff --git a/src/Psalm/Storage/ClassConstantStorage.php b/src/Psalm/Storage/ClassConstantStorage.php index 9c6d7cfcde5..072f80a0439 100644 --- a/src/Psalm/Storage/ClassConstantStorage.php +++ b/src/Psalm/Storage/ClassConstantStorage.php @@ -19,6 +19,7 @@ final class ClassConstantStorage /** @psalm-suppress MutableDependency Mutable by design */ use CustomMetadataTrait; use ImmutableNonCloneableTrait; + use UnserializeMemoryUsageSuppressionTrait; public ?CodeLocation $type_location; diff --git a/src/Psalm/Storage/ClassLikeStorage.php b/src/Psalm/Storage/ClassLikeStorage.php index f8564444a26..0ba397d2ada 100644 --- a/src/Psalm/Storage/ClassLikeStorage.php +++ b/src/Psalm/Storage/ClassLikeStorage.php @@ -19,6 +19,7 @@ final class ClassLikeStorage implements HasAttributesInterface { use CustomMetadataTrait; + use UnserializeMemoryUsageSuppressionTrait; /** * @var array diff --git a/src/Psalm/Storage/EnumCaseStorage.php b/src/Psalm/Storage/EnumCaseStorage.php index 24ec0791399..4c91419a69b 100644 --- a/src/Psalm/Storage/EnumCaseStorage.php +++ b/src/Psalm/Storage/EnumCaseStorage.php @@ -6,6 +6,8 @@ final class EnumCaseStorage { + use UnserializeMemoryUsageSuppressionTrait; + /** * @var int|string|null */ diff --git a/src/Psalm/Storage/FileStorage.php b/src/Psalm/Storage/FileStorage.php index fe818ff6d86..043cd196a1a 100644 --- a/src/Psalm/Storage/FileStorage.php +++ b/src/Psalm/Storage/FileStorage.php @@ -10,6 +10,7 @@ final class FileStorage { use CustomMetadataTrait; + use UnserializeMemoryUsageSuppressionTrait; /** * @var array diff --git a/src/Psalm/Storage/FunctionLikeParameter.php b/src/Psalm/Storage/FunctionLikeParameter.php index 6c4a241080d..39125c366bd 100644 --- a/src/Psalm/Storage/FunctionLikeParameter.php +++ b/src/Psalm/Storage/FunctionLikeParameter.php @@ -12,6 +12,7 @@ final class FunctionLikeParameter implements HasAttributesInterface, TypeNode { use CustomMetadataTrait; + use UnserializeMemoryUsageSuppressionTrait; /** * @var string diff --git a/src/Psalm/Storage/FunctionLikeStorage.php b/src/Psalm/Storage/FunctionLikeStorage.php index 7be91f80434..ee6128e787f 100644 --- a/src/Psalm/Storage/FunctionLikeStorage.php +++ b/src/Psalm/Storage/FunctionLikeStorage.php @@ -16,6 +16,7 @@ abstract class FunctionLikeStorage implements HasAttributesInterface { use CustomMetadataTrait; + use UnserializeMemoryUsageSuppressionTrait; /** * @var CodeLocation|null diff --git a/src/Psalm/Storage/Possibilities.php b/src/Psalm/Storage/Possibilities.php index 70eb35287b4..2c04ed415ed 100644 --- a/src/Psalm/Storage/Possibilities.php +++ b/src/Psalm/Storage/Possibilities.php @@ -12,6 +12,8 @@ final class Possibilities { + use UnserializeMemoryUsageSuppressionTrait; + /** * @var list the rule being asserted */ diff --git a/src/Psalm/Storage/PropertyStorage.php b/src/Psalm/Storage/PropertyStorage.php index 745e1e50ea3..c285c737ee8 100644 --- a/src/Psalm/Storage/PropertyStorage.php +++ b/src/Psalm/Storage/PropertyStorage.php @@ -9,6 +9,7 @@ final class PropertyStorage implements HasAttributesInterface { use CustomMetadataTrait; + use UnserializeMemoryUsageSuppressionTrait; /** * @var ?bool diff --git a/src/Psalm/Storage/UnserializeMemoryUsageSuppressionTrait.php b/src/Psalm/Storage/UnserializeMemoryUsageSuppressionTrait.php new file mode 100644 index 00000000000..a59b7ce2a02 --- /dev/null +++ b/src/Psalm/Storage/UnserializeMemoryUsageSuppressionTrait.php @@ -0,0 +1,24 @@ + $value) { + $this->$key = $value; + } + } +} diff --git a/src/Psalm/Type/Atomic.php b/src/Psalm/Type/Atomic.php index d54d1a6e3ce..46092ec8162 100644 --- a/src/Psalm/Type/Atomic.php +++ b/src/Psalm/Type/Atomic.php @@ -11,6 +11,7 @@ use Psalm\Internal\Type\TypeAlias; use Psalm\Internal\Type\TypeAlias\LinkableTypeAlias; use Psalm\Internal\TypeVisitor\ClasslikeReplacer; +use Psalm\Storage\UnserializeMemoryUsageSuppressionTrait; use Psalm\Type; use Psalm\Type\Atomic\TArray; use Psalm\Type\Atomic\TArrayKey; @@ -82,6 +83,8 @@ */ abstract class Atomic implements TypeNode { + use UnserializeMemoryUsageSuppressionTrait; + public function __construct(bool $from_docblock = false) { $this->from_docblock = $from_docblock; diff --git a/src/Psalm/Type/Union.php b/src/Psalm/Type/Union.php index 90a891917b1..c6602cd19a4 100644 --- a/src/Psalm/Type/Union.php +++ b/src/Psalm/Type/Union.php @@ -233,6 +233,52 @@ final class Union implements TypeNode */ public $different = false; + private const PROPERTY_KEYS_FOR_UNSERIALIZE = [ + "\0" . self::class . "\0" . 'types' => 'types', + 'from_docblock' => 'from_docblock', + 'from_calculation' => 'from_calculation', + 'from_property' => 'from_property', + 'from_static_property' => 'from_static_property', + 'initialized' => 'initialized', + 'initialized_class' => 'initialized_class', + 'checked' => 'checked', + 'failed_reconciliation' => 'failed_reconciliation', + 'ignore_nullable_issues' => 'ignore_nullable_issues', + 'ignore_falsable_issues' => 'ignore_falsable_issues', + 'ignore_isset' => 'ignore_isset', + 'possibly_undefined' => 'possibly_undefined', + 'possibly_undefined_from_try' => 'possibly_undefined_from_try', + 'explicit_never' => 'explicit_never', + 'had_template' => 'had_template', + 'from_template_default' => 'from_template_default', + "\0" . self::class . "\0" . 'literal_string_types' => 'literal_string_types', + "\0" . self::class . "\0" . 'typed_class_strings' => 'typed_class_strings', + "\0" . self::class . "\0" . 'literal_int_types' => 'literal_int_types', + "\0" . self::class . "\0" . 'literal_float_types' => 'literal_float_types', + 'by_ref' => 'by_ref', + 'reference_free' => 'reference_free', + 'allow_mutations' => 'allow_mutations', + 'has_mutations' => 'has_mutations', + "\0" . self::class . "\0" . 'id' => 'id', + "\0" . self::class . "\0" . 'exact_id' => 'exact_id', + 'parent_nodes' => 'parent_nodes', + 'propagate_parent_nodes' => 'propagate_parent_nodes', + 'different' => 'different', + ]; + + /** + * Suppresses memory usage when unserializing objects. + * + * @see \Psalm\Storage\UnserializeMemoryUsageSuppressionTrait + */ + public function __unserialize(array $properties): void + { + foreach (self::PROPERTY_KEYS_FOR_UNSERIALIZE as $key => $property_name) { + /** @psalm-suppress PossiblyUndefinedStringArrayOffset */ + $this->$property_name = $properties[$key]; + } + } + /** * @param TProperties $properties * @return static From c3a41d136d94a0b823e1c4c185c1bd4e0ada5650 Mon Sep 17 00:00:00 2001 From: Sam L Date: Tue, 9 Jan 2024 17:49:07 -0500 Subject: [PATCH 13/25] Bots From b2e53f6cf8d3696c051e826c8f4b898b79aebddd Mon Sep 17 00:00:00 2001 From: Karl Thaler Date: Tue, 9 Jan 2024 15:28:23 -0800 Subject: [PATCH 14/25] Implement __set method in SimpleXMLElement --- stubs/extensions/simplexml.phpstub | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stubs/extensions/simplexml.phpstub b/stubs/extensions/simplexml.phpstub index 7f0bfa2143f..4fc5e3046bc 100644 --- a/stubs/extensions/simplexml.phpstub +++ b/stubs/extensions/simplexml.phpstub @@ -66,6 +66,8 @@ class SimpleXMLElement implements Traversable, Countable public function count(): int {} public function __get(string $name): SimpleXMLElement|SimpleXMLIterator|null {} + + public function __set(string $name, $value): void {} } /** From 138b47a04f8481dbe268e5c069352c47b4643b92 Mon Sep 17 00:00:00 2001 From: Karl Thaler Date: Wed, 10 Jan 2024 13:07:32 -0800 Subject: [PATCH 15/25] Omit property assignment tests for SimpleXMLElement --- tests/PropertyTypeTest.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/PropertyTypeTest.php b/tests/PropertyTypeTest.php index 0fbd78699e3..69be1387253 100644 --- a/tests/PropertyTypeTest.php +++ b/tests/PropertyTypeTest.php @@ -3825,22 +3825,6 @@ class A { ', 'error_message' => 'UndefinedPropertyAssignment', ], - 'setPropertiesOfSimpleXMLElement1' => [ - 'code' => '"); - $a->b = "c"; - ', - 'error_message' => 'UndefinedPropertyAssignment', - ], - 'setPropertiesOfSimpleXMLElement2' => [ - 'code' => '"); - if (isset($a->b)) { - $a->b = "c"; - } - ', - 'error_message' => 'UndefinedPropertyAssignment', - ], ]; } } From f04be62e8b6896acdb2fd714230b6a37f31ff466 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Thu, 11 Jan 2024 18:40:45 +0100 Subject: [PATCH 16/25] can only be positive --- dictionaries/CallMap.php | 4 ++-- dictionaries/CallMap_historical.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index c4215cfc163..f3ea1336bae 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -3315,7 +3315,7 @@ 'getopt' => ['array>|false', 'short_options'=>'string', 'long_options='=>'array', '&w_rest_index='=>'int'], 'getprotobyname' => ['int|false', 'protocol'=>'string'], 'getprotobynumber' => ['string', 'protocol'=>'int'], -'getrandmax' => ['int'], +'getrandmax' => ['int<1, max>'], 'getrusage' => ['array', 'mode='=>'int'], 'getservbyname' => ['int|false', 'service'=>'string', 'protocol'=>'string'], 'getservbyport' => ['string|false', 'port'=>'int', 'protocol'=>'string'], @@ -7646,7 +7646,7 @@ 'msql_query' => ['resource', 'query'=>'string', 'link_identifier='=>'?resource'], 'msql_result' => ['string', 'result'=>'resource', 'row'=>'int', 'field='=>'mixed'], 'msql_select_db' => ['bool', 'database_name'=>'string', 'link_identifier='=>'?resource'], -'mt_getrandmax' => ['int'], +'mt_getrandmax' => ['int<1, max>'], 'mt_rand' => ['int', 'min'=>'int', 'max'=>'int'], 'mt_rand\'1' => ['int'], 'mt_srand' => ['void', 'seed='=>'?int', 'mode='=>'int'], diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index 6e16b365027..bceffdfafcd 100644 --- a/dictionaries/CallMap_historical.php +++ b/dictionaries/CallMap_historical.php @@ -10679,7 +10679,7 @@ 'getopt' => ['array>|false', 'short_options'=>'string', 'long_options='=>'array'], 'getprotobyname' => ['int|false', 'protocol'=>'string'], 'getprotobynumber' => ['string', 'protocol'=>'int'], - 'getrandmax' => ['int'], + 'getrandmax' => ['int<1, max>'], 'getrusage' => ['array', 'mode='=>'int'], 'getservbyname' => ['int|false', 'service'=>'string', 'protocol'=>'string'], 'getservbyport' => ['string|false', 'port'=>'int', 'protocol'=>'string'], @@ -12488,7 +12488,7 @@ 'msql_query' => ['resource', 'query'=>'string', 'link_identifier='=>'?resource'], 'msql_result' => ['string', 'result'=>'resource', 'row'=>'int', 'field='=>'mixed'], 'msql_select_db' => ['bool', 'database_name'=>'string', 'link_identifier='=>'?resource'], - 'mt_getrandmax' => ['int'], + 'mt_getrandmax' => ['int<1, max>'], 'mt_rand' => ['int', 'min'=>'int', 'max'=>'int'], 'mt_rand\'1' => ['int'], 'mt_srand' => ['void', 'seed='=>'int', 'mode='=>'int'], From 7658fc4d331f89f070a3205a280af470dc4e2052 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Thu, 11 Jan 2024 18:43:15 +0100 Subject: [PATCH 17/25] unserialize change to class-string --- dictionaries/CallMap.php | 2 +- dictionaries/CallMap_historical.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index f3ea1336bae..783f520e962 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -14165,7 +14165,7 @@ 'unlink' => ['bool', 'filename'=>'string', 'context='=>'resource'], 'unpack' => ['array|false', 'format'=>'string', 'string'=>'string', 'offset='=>'int'], 'unregister_tick_function' => ['void', 'callback'=>'callable'], -'unserialize' => ['mixed', 'data'=>'string', 'options='=>'array{allowed_classes?:string[]|bool}'], +'unserialize' => ['mixed', 'data'=>'string', 'options='=>'array{allowed_classes?:class-string[]|bool}'], 'unset' => ['void', 'var='=>'mixed', '...args='=>'mixed'], 'untaint' => ['bool', '&rw_string'=>'string', '&...rw_strings='=>'string'], 'uopz_allow_exit' => ['void', 'allow'=>'bool'], diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index bceffdfafcd..61e3db09d55 100644 --- a/dictionaries/CallMap_historical.php +++ b/dictionaries/CallMap_historical.php @@ -15197,7 +15197,7 @@ 'unlink' => ['bool', 'filename'=>'string', 'context='=>'resource'], 'unpack' => ['array', 'format'=>'string', 'string'=>'string'], 'unregister_tick_function' => ['void', 'callback'=>'callable'], - 'unserialize' => ['mixed', 'data'=>'string', 'options='=>'array{allowed_classes?:string[]|bool}'], + 'unserialize' => ['mixed', 'data'=>'string', 'options='=>'array{allowed_classes?:class-string[]|bool}'], 'unset' => ['void', 'var='=>'mixed', '...args='=>'mixed'], 'untaint' => ['bool', '&rw_string'=>'string', '&...rw_strings='=>'string'], 'uopz_allow_exit' => ['void', 'allow'=>'bool'], From d209bab9f5d8eadd0cba49a2635e209354fd466b Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Sat, 13 Jan 2024 00:57:53 +0100 Subject: [PATCH 18/25] Fix mb_get_info can return null - CI failing bc of reflection See https://github.com/php/php-src/issues/12753 --- dictionaries/CallMap.php | 2 +- dictionaries/CallMap_82_delta.php | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index 783f520e962..cec0545f126 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -6625,7 +6625,7 @@ 'mb_ereg_search_setpos' => ['bool', 'offset'=>'int'], 'mb_eregi' => ['bool', 'pattern'=>'string', 'string'=>'string', '&w_matches='=>'array|null'], 'mb_eregi_replace' => ['string|false|null', 'pattern'=>'string', 'replacement'=>'string', 'string'=>'string', 'options='=>'string|null'], -'mb_get_info' => ['array|string|int|false', 'type='=>'string'], +'mb_get_info' => ['array|string|int|false|null', 'type='=>'string'], 'mb_http_input' => ['array|string|false', 'type='=>'string|null'], 'mb_http_output' => ['string|bool', 'encoding='=>'string|null'], 'mb_internal_encoding' => ['string|bool', 'encoding='=>'string|null'], diff --git a/dictionaries/CallMap_82_delta.php b/dictionaries/CallMap_82_delta.php index 47ded4fbb19..3064f54ff52 100644 --- a/dictionaries/CallMap_82_delta.php +++ b/dictionaries/CallMap_82_delta.php @@ -53,6 +53,10 @@ 'old' => ['non-empty-list', 'string'=>'string', 'length='=>'positive-int'], 'new' => ['list', 'string'=>'string', 'length='=>'positive-int'], ], + 'mb_get_info' => [ + 'old' => ['array|string|int|false', 'type='=>'string'], + 'new' => ['array|string|int|false|null', 'type='=>'string'], + ], ], 'removed' => [ From 6f184dca916ec156addc5e997f3e915dee87004d Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Fri, 12 Jan 2024 20:46:04 +0100 Subject: [PATCH 19/25] remove redundat directory separator which caused "//" in path not found errors --- src/Psalm/Config.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index 82c696c0bcd..e6f05540b55 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -787,7 +787,7 @@ public static function loadFromXMLFile(string $file_path, string $current_dir): { $file_contents = file_get_contents($file_path); - $base_dir = dirname($file_path) . DIRECTORY_SEPARATOR; + $base_dir = dirname($file_path); if ($file_contents === false) { throw new InvalidArgumentException('Cannot open ' . $file_path); @@ -1304,7 +1304,7 @@ private static function fromXmlAndPaths( // ProjectAnalyzer::getInstance()->check_paths_files is not populated at this point in time $paths_to_check = null; - + global $argv; // Hack for Symfonys own argv resolution. @@ -1312,7 +1312,7 @@ private static function fromXmlAndPaths( if (!isset($argv[0]) || basename($argv[0]) !== 'psalm-plugin') { $paths_to_check = CliUtils::getPathsToCheck(null); } - + if ($paths_to_check !== null) { $paths_to_add_to_project_files = array(); foreach ($paths_to_check as $path) { @@ -1473,7 +1473,7 @@ private static function fromXmlAndPaths( $path = Path::isAbsolute($plugin_file_name) ? $plugin_file_name - : $config->base_dir . $plugin_file_name; + : $config->base_dir . DIRECTORY_SEPARATOR . $plugin_file_name; $config->addPluginPath($path); } From a2bf5cb0546661bd8b058132b738a8798fc68959 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Sun, 17 Dec 2023 20:31:32 +0100 Subject: [PATCH 20/25] Fix empty literal string becomes non-empty-string when max literal string length is 0, which means literal strings are disabled --- src/Psalm/Type.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index 1215799f785..d05a9b9d5c3 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -287,7 +287,7 @@ public static function getAtomicStringFromLiteral(string $value, bool $from_docb $type = $config->eventDispatcher->dispatchStringInterpreter($event); if (!$type) { - if (strlen($value) < $config->max_string_length) { + if ($value === '' || strlen($value) < $config->max_string_length) { $type = new TLiteralString($value, $from_docblock); } else { $type = new TNonEmptyString($from_docblock); From 40926182604e32a97e0fd342c2ff6704b5c7dac8 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Wed, 20 Dec 2023 09:11:59 +0100 Subject: [PATCH 21/25] should be non-falsy-string instead of non-empty-string in most cases --- src/Psalm/Type.php | 6 ++++-- tests/TypeCombinationTest.php | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index d05a9b9d5c3..cc5de1f8b03 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -277,7 +277,7 @@ public static function getString(?string $value = null): Union return new Union([$value === null ? new TString() : self::getAtomicStringFromLiteral($value)]); } - /** @return TLiteralString|TNonEmptyString */ + /** @return TLiteralString|TNonEmptyString|TNonFalsyString */ public static function getAtomicStringFromLiteral(string $value, bool $from_docblock = false): TString { $config = Config::getInstance(); @@ -289,8 +289,10 @@ public static function getAtomicStringFromLiteral(string $value, bool $from_docb if (!$type) { if ($value === '' || strlen($value) < $config->max_string_length) { $type = new TLiteralString($value, $from_docblock); - } else { + } elseif ($value === '0') { $type = new TNonEmptyString($from_docblock); + } else { + $type = new TNonFalsyString($from_docblock); } } diff --git a/tests/TypeCombinationTest.php b/tests/TypeCombinationTest.php index b891e84dcc2..f041453d9d5 100644 --- a/tests/TypeCombinationTest.php +++ b/tests/TypeCombinationTest.php @@ -120,6 +120,13 @@ function takesLiteralString($arg) {} takesLiteralString($c); }', ], + 'tooLongLiteralShouldBeNonFalsyString' => [ + 'code' => ' [ + '$x===' => 'non-falsy-string', + ] + ], ]; } From 7f0f0bc36173909a333324cd1a4ce0f63e81ab75 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Wed, 20 Dec 2023 09:22:49 +0100 Subject: [PATCH 22/25] ensure concat https://psalm.dev/r/323e33ae8a will be a non-falsy-string --- .../Expression/BinaryOp/ConcatAnalyzer.php | 18 ++++++++++++++++-- tests/BinaryOperationTest.php | 9 +++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php index fd7ab531f3e..6a59185b55f 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php @@ -258,6 +258,19 @@ public static function analyze( $has_numeric_and_non_empty = $has_numeric_type && $has_non_empty; + $non_falsy_string = $numeric_type->getBuilder()->addType(new TNonFalsyString())->freeze(); + $left_non_falsy = UnionTypeComparator::isContainedBy( + $codebase, + $left_type, + $non_falsy_string, + ); + + $right_non_falsy = UnionTypeComparator::isContainedBy( + $codebase, + $right_type, + $non_falsy_string, + ); + $all_literals = $left_type->allLiterals() && $right_type->allLiterals(); if ($has_non_empty) { @@ -265,9 +278,10 @@ public static function analyze( $result_type = new Union([new TNonEmptyNonspecificLiteralString]); } elseif ($all_lowercase) { $result_type = Type::getNonEmptyLowercaseString(); + } elseif ($all_non_empty || $has_numeric_and_non_empty || $left_non_falsy || $right_non_falsy) { + $result_type = Type::getNonFalsyString(); } else { - $result_type = $all_non_empty || $has_numeric_and_non_empty ? - Type::getNonFalsyString() : Type::getNonEmptyString(); + $result_type = Type::getNonEmptyString(); } } else { if ($all_literals) { diff --git a/tests/BinaryOperationTest.php b/tests/BinaryOperationTest.php index f0fe0c9e3ae..e2dafc18724 100644 --- a/tests/BinaryOperationTest.php +++ b/tests/BinaryOperationTest.php @@ -357,6 +357,15 @@ public function providerValidCodeParse(): iterable 'code' => ' [ + 'code' => ' [ + '$a===' => 'non-falsy-string', + ], + 'ignored_issues' => ['InvalidReturnType'], + ], 'concatenationWithNumberInWeakMode' => [ 'code' => ' Date: Sat, 13 Jan 2024 17:12:41 +0100 Subject: [PATCH 23/25] code style --- tests/TypeCombinationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TypeCombinationTest.php b/tests/TypeCombinationTest.php index f041453d9d5..56af82a69ed 100644 --- a/tests/TypeCombinationTest.php +++ b/tests/TypeCombinationTest.php @@ -125,7 +125,7 @@ function takesLiteralString($arg) {} $x = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";', 'assertions' => [ '$x===' => 'non-falsy-string', - ] + ], ], ]; } From 02467fbb6a484c9e8b8bb08ac317c7e8a1c20947 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Sun, 14 Jan 2024 10:42:05 +0100 Subject: [PATCH 24/25] add support for extract to set variables for keyed arrays and respect EXTR_SKIP --- .../Call/NamedFunctionCallHandler.php | 93 +++++++++++++++++++ tests/FunctionCallTest.php | 40 +++++++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php index 514df1a55a5..5f1900c2b78 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php @@ -35,6 +35,8 @@ use Psalm\Type\Atomic\TDependentGetType; use Psalm\Type\Atomic\TFloat; use Psalm\Type\Atomic\TInt; +use Psalm\Type\Atomic\TKeyedArray; +use Psalm\Type\Atomic\TList; use Psalm\Type\Atomic\TLowercaseString; use Psalm\Type\Atomic\TMixed; use Psalm\Type\Atomic\TNamedObject; @@ -47,12 +49,18 @@ use Psalm\Type\Union; use function array_map; +use function count; use function extension_loaded; use function in_array; +use function is_numeric; use function is_string; +use function preg_match; use function strpos; use function strtolower; +use const EXTR_OVERWRITE; +use const EXTR_SKIP; + /** * @internal */ @@ -261,13 +269,98 @@ public static function handle( } if ($function_id === 'extract') { + $flag_value = false; + if (!isset($stmt->args[1])) { + $flag_value = EXTR_OVERWRITE; + } elseif (isset($stmt->args[1]->value) + && $stmt->args[1]->value instanceof PhpParser\Node\Expr + && ($flags_type = $statements_analyzer->node_data->getType($stmt->args[1]->value)) + && $flags_type->hasLiteralInt() && count($flags_type->getAtomicTypes()) === 1) { + $flag_type_value = $flags_type->getSingleIntLiteral()->value; + if ($flag_type_value === EXTR_SKIP) { + $flag_value = EXTR_SKIP; + } elseif ($flag_type_value === EXTR_OVERWRITE) { + $flag_value = EXTR_OVERWRITE; + } + // @todo add support for other flags + } + + $is_unsealed = true; + $validated_var_ids = []; + if ($flag_value !== false && isset($stmt->args[0]->value) + && $stmt->args[0]->value instanceof PhpParser\Node\Expr + && ($array_type_union = $statements_analyzer->node_data->getType($stmt->args[0]->value)) + && $array_type_union->isSingle() + ) { + foreach ($array_type_union->getAtomicTypes() as $array_type) { + if ($array_type instanceof TList) { + $array_type = $array_type->getKeyedArray(); + } + + if ($array_type instanceof TKeyedArray) { + foreach ($array_type->properties as $key => $type) { + // variables must start with letters or underscore + if ($key === '' || is_numeric($key) || preg_match('/^[A-Za-z_]/', $key) !== 1) { + continue; + } + + $var_id = '$' . $key; + $validated_var_ids[] = $var_id; + + if (isset($context->vars_in_scope[$var_id]) && $flag_value === EXTR_SKIP) { + continue; + } + + if (!isset($context->vars_in_scope[$var_id]) && $type->possibly_undefined === true) { + $context->possibly_assigned_var_ids[$var_id] = true; + } elseif (isset($context->vars_in_scope[$var_id]) + && $type->possibly_undefined === true + && $flag_value === EXTR_OVERWRITE) { + $type = Type::combineUnionTypes( + $context->vars_in_scope[$var_id], + $type, + $codebase, + false, + true, + 500, + false, + ); + } + + $context->vars_in_scope[$var_id] = $type; + $context->assigned_var_ids[$var_id] = (int) $stmt->getAttribute('startFilePos'); + } + + if (!isset($array_type->fallback_params)) { + $is_unsealed = false; + } + } + } + } + + if ($flag_value === EXTR_OVERWRITE && $is_unsealed === false) { + return; + } + + if ($flag_value === EXTR_SKIP && $is_unsealed === false) { + return; + } + $context->check_variables = false; + if ($flag_value === EXTR_SKIP) { + return; + } + foreach ($context->vars_in_scope as $var_id => $_) { if ($var_id === '$this' || strpos($var_id, '[') || strpos($var_id, '>')) { continue; } + if (in_array($var_id, $validated_var_ids, true)) { + continue; + } + $mixed_type = new Union([new TMixed()], [ 'parent_nodes' => $context->vars_in_scope[$var_id]->parent_nodes, ]); diff --git a/tests/FunctionCallTest.php b/tests/FunctionCallTest.php index 93297a79ddd..e63636dff43 100644 --- a/tests/FunctionCallTest.php +++ b/tests/FunctionCallTest.php @@ -513,19 +513,41 @@ function foo($s): array { ], 'extractVarCheck' => [ 'code' => ' "bar"]; + $foo = "foo"; + $a = getUnsealedArray(); extract($a); takesString($foo);', 'assertions' => [], 'ignored_issues' => [ - 'MixedAssignment', - 'MixedArrayAccess', 'MixedArgument', ], ], + 'extractVarCheckValid' => [ + 'code' => ' 15]; + extract($a); + takesInt($foo);', + ], + 'extractSkipExtr' => [ + 'code' => ' "x", "b" => "y"], EXTR_SKIP);', + 'assertions' => [ + '$a===' => '1', + '$b===' => '\'y\'', + ], + ], 'compact' => [ 'code' => ' [], 'php_version' => '8.1', ], + 'extractVarCheckInvalid' => [ + 'code' => ' 15]; + extract($a); + takesInt($foo);', + 'error_message' => 'InvalidScalarArgument', + ], ]; } From f940c029e17d1f2165d7a03877efbc53b2f380c9 Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Sun, 14 Jan 2024 19:00:09 +0100 Subject: [PATCH 25/25] make basename & dirname return types more specific --- .../BasenameReturnTypeProvider.php | 43 +++++++++++++++- .../DirnameReturnTypeProvider.php | 50 ++++++++++++++----- tests/ReturnTypeProvider/BasenameTest.php | 20 ++++++++ tests/ReturnTypeProvider/DirnameTest.php | 22 +++++++- 4 files changed, 120 insertions(+), 15 deletions(-) diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/BasenameReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/BasenameReturnTypeProvider.php index 8e11092326e..487f1dcffb4 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/BasenameReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/BasenameReturnTypeProvider.php @@ -44,7 +44,48 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev ); if ($evaled_path === null) { - return Type::getString(); + $union = $statements_source->getNodeTypeProvider()->getType($call_args[0]->value); + $generic = false; + $non_empty = false; + if ($union !== null) { + foreach ($union->getAtomicTypes() as $atomic) { + if ($atomic instanceof Type\Atomic\TNonFalsyString) { + continue; + } + + if ($atomic instanceof Type\Atomic\TLiteralString) { + if ($atomic->value === '') { + $generic = true; + break; + } + + if ($atomic->value === '0') { + $non_empty = true; + continue; + } + + continue; + } + + if ($atomic instanceof Type\Atomic\TNonEmptyString) { + $non_empty = true; + continue; + } + + $generic = true; + break; + } + } + + if ($union === null || $generic) { + return Type::getString(); + } + + if ($non_empty) { + return Type::getNonEmptyString(); + } + + return Type::getNonFalsyString(); } $basename = basename($evaled_path); diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/DirnameReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/DirnameReturnTypeProvider.php index f6c63c47ac4..ff4fbc54fed 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/DirnameReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/DirnameReturnTypeProvider.php @@ -9,7 +9,6 @@ use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; use Psalm\Type; use Psalm\Type\Atomic\TLiteralInt; -use Psalm\Type\Atomic\TNonEmptyString; use Psalm\Type\Union; use function array_values; @@ -39,6 +38,41 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev $statements_source = $event->getStatementsSource(); $node_type_provider = $statements_source->getNodeTypeProvider(); + $union = $node_type_provider->getType($call_args[0]->value); + $generic = false; + if ($union !== null) { + foreach ($union->getAtomicTypes() as $atomic) { + if ($atomic instanceof Type\Atomic\TNonFalsyString) { + continue; + } + + if ($atomic instanceof Type\Atomic\TLiteralString) { + if ($atomic->value === '') { + $generic = true; + break; + } + + // 0 will be non-falsy too (.) + continue; + } + + if ($atomic instanceof Type\Atomic\TNonEmptyString + || $atomic instanceof Type\Atomic\TEmptyNumeric) { + continue; + } + + // generic string is the only other possible case of empty string + // which would result in a generic string + $generic = true; + break; + } + } + + $fallback_type = Type::getNonFalsyString(); + if ($union === null || $generic) { + $fallback_type = Type::getString(); + } + $dir_level = 1; if (isset($call_args[1])) { $type = $node_type_provider->getType($call_args[1]->value); @@ -49,7 +83,7 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev $atomic_type->value > 0) { $dir_level = $atomic_type->value; } else { - return Type::getString(); + return $fallback_type; } } } @@ -63,17 +97,7 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev ); if ($evaled_path === null) { - $type = $node_type_provider->getType($call_args[0]->value); - if ($type !== null && $type->isSingle()) { - $atomic_type = array_values($type->getAtomicTypes())[0]; - if ($atomic_type instanceof TNonEmptyString) { - return Type::getNonEmptyString(); - } - } - } - - if ($evaled_path === null) { - return Type::getString(); + return $fallback_type; } $path_to_file = dirname($evaled_path, $dir_level); diff --git a/tests/ReturnTypeProvider/BasenameTest.php b/tests/ReturnTypeProvider/BasenameTest.php index 5c624651f69..89ab1562581 100644 --- a/tests/ReturnTypeProvider/BasenameTest.php +++ b/tests/ReturnTypeProvider/BasenameTest.php @@ -32,5 +32,25 @@ public function providerValidCodeParse(): iterable '$base===' => 'string', ], ]; + + yield 'basenameOfStringPathReturnsNonEmptyString' => [ + 'code' => ' [ + '$base===' => 'non-empty-string', + ], + ]; + + yield 'basenameOfStringPathReturnsNonFalsyString' => [ + 'code' => ' [ + '$base===' => 'non-falsy-string', + ], + ]; } } diff --git a/tests/ReturnTypeProvider/DirnameTest.php b/tests/ReturnTypeProvider/DirnameTest.php index 48dea4d2cca..98d7b40f722 100644 --- a/tests/ReturnTypeProvider/DirnameTest.php +++ b/tests/ReturnTypeProvider/DirnameTest.php @@ -49,7 +49,27 @@ public function providerValidCodeParse(): iterable $dir = dirname(uniqid() . "abc", 2); ', 'assertions' => [ - '$dir===' => 'non-empty-string', + '$dir===' => 'non-falsy-string', + ], + ]; + + yield 'dirnameOfNonEmptyShouldBeNonFalsy' => [ + 'code' => ' [ + '$dir===' => 'non-falsy-string', + ], + ]; + + yield 'dirnameOfEmptyShouldBeString' => [ + 'code' => ' [ + '$dir===' => 'string', ], ]; }