diff --git a/config.xsd b/config.xsd index c97e3198ad9..c5dc07ce0de 100644 --- a/config.xsd +++ b/config.xsd @@ -420,6 +420,7 @@ + diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index 853b78ed8a0..cec0545f126 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'], @@ -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'], @@ -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'], @@ -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'], @@ -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'], @@ -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_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' => [ diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index 60e798e09cb..61e3db09d55 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'], @@ -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'], @@ -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'], @@ -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'], diff --git a/docs/running_psalm/issues.md b/docs/running_psalm/issues.md index 364541ee439..aa94cf66fbc 100644 --- a/docs/running_psalm/issues.md +++ b/docs/running_psalm/issues.md @@ -221,6 +221,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/Aliases.php b/src/Psalm/Aliases.php index daa39b90a30..00056ed9c20 100644 --- a/src/Psalm/Aliases.php +++ b/src/Psalm/Aliases.php @@ -4,8 +4,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 9c35acfb340..a51d33b9eff 100644 --- a/src/Psalm/CodeLocation.php +++ b/src/Psalm/CodeLocation.php @@ -90,6 +90,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, @@ -126,6 +152,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/Codebase.php b/src/Psalm/Codebase.php index e3f48f025ed..9385394d25a 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -1377,7 +1377,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; } @@ -1527,7 +1527,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] ?? ''; } @@ -1653,7 +1653,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), @@ -1665,7 +1665,7 @@ public function getCompletionItemsForClassishThing( str_replace('$', '', $property_name), ); } - + $completion_items = [...$completion_items, ...array_values($pseudo_property_types)]; } diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index c248e3e0574..af4810c755c 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -636,7 +636,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); @@ -1147,7 +1147,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) { @@ -1307,7 +1317,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); } diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index 57ba7d23cd5..ae55904eab8 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->getSuppressedIssuesForTemplateExtendParams() + $this->getSuppressedIssues(), ); } } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php index b3f7a03e5ed..411203ba72f 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php @@ -257,6 +257,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) { @@ -264,9 +277,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/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index d265e22d0bb..970f3cad650 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -4,11 +4,13 @@ namespace Psalm\Internal\Analyzer\Statements\Expression\Call; +use InvalidArgumentException; use PhpParser; use Psalm\CodeLocation; 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 +47,7 @@ use Psalm\Type\Atomic\TClosure; use Psalm\Type\Atomic\TKeyedArray; use Psalm\Type\Atomic\TLiteralString; +use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Atomic\TNonEmptyArray; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; @@ -1252,6 +1255,42 @@ 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, + true, + $statements_analyzer, + ); + + 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]; + + InstancePropertyAssignmentAnalyzer::trackPropertyImpurity( + $statements_analyzer, + $stmt, + $property_id, + $property_storage, + $declaring_class_storage, + $context, + ); + } + } + /** * @return false|null */ @@ -1273,6 +1312,46 @@ private static function handleByRefFunctionArg( 'reset', 'end', 'next', 'prev', 'array_pop', 'array_shift', 'extract', ]; + if ($arg->value instanceof PhpParser\Node\Expr\PropertyFetch + && $arg->value->name instanceof PhpParser\Node\Identifier) { + $prop_name = $arg->value->name->name; + if (!empty($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/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgHandler.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgHandler.php index ff627e40f54..0fe60d359f7 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/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php index c5409880ba3..fc5049f6747 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php @@ -37,6 +37,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; @@ -49,12 +51,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 */ @@ -263,13 +271,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/src/Psalm/Internal/Codebase/Functions.php b/src/Psalm/Internal/Codebase/Functions.php index 2cb3ffefd88..fdc13f373f2 100644 --- a/src/Psalm/Internal/Codebase/Functions.php +++ b/src/Psalm/Internal/Codebase/Functions.php @@ -455,7 +455,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()) ) { diff --git a/src/Psalm/Internal/MethodIdentifier.php b/src/Psalm/Internal/MethodIdentifier.php index c41a4b8ba9e..c03d2959703 100644 --- a/src/Psalm/Internal/MethodIdentifier.php +++ b/src/Psalm/Internal/MethodIdentifier.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Psalm\Storage\ImmutableNonCloneableTrait; +use Psalm\Storage\UnserializeMemoryUsageSuppressionTrait; use function explode; use function is_string; @@ -20,6 +21,7 @@ final class MethodIdentifier { use ImmutableNonCloneableTrait; + use UnserializeMemoryUsageSuppressionTrait; public string $fq_class_name; /** @var lowercase-string */ diff --git a/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php b/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php index de3a11462b6..9902454f8f3 100644 --- a/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php @@ -27,6 +27,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; @@ -87,6 +88,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/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/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 40598d4a1e4..7d56d35cce6 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php @@ -5,30 +5,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 @@ -43,135 +32,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/Internal/Type/TypeParser.php b/src/Psalm/Internal/Type/TypeParser.php index c51c3a2c372..319d6e59b89 100644 --- a/src/Psalm/Internal/Type/TypeParser.php +++ b/src/Psalm/Internal/Type/TypeParser.php @@ -642,13 +642,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) { @@ -672,6 +690,7 @@ private static function getTypeFromGenericTree( || $atomic_type instanceof TNever || $atomic_type instanceof TTemplateParam || $atomic_type instanceof TValueOf + || !$from_docblock ) { continue; } @@ -691,7 +710,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') { @@ -710,58 +732,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/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 @@ + */ diff --git a/src/Psalm/Storage/ClassConstantStorage.php b/src/Psalm/Storage/ClassConstantStorage.php index 78488412a81..147fcaf709b 100644 --- a/src/Psalm/Storage/ClassConstantStorage.php +++ b/src/Psalm/Storage/ClassConstantStorage.php @@ -21,6 +21,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 8fabc2e3edd..39b02b3ab96 100644 --- a/src/Psalm/Storage/ClassLikeStorage.php +++ b/src/Psalm/Storage/ClassLikeStorage.php @@ -12,15 +12,18 @@ 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 { use CustomMetadataTrait; + use UnserializeMemoryUsageSuppressionTrait; /** * @var array @@ -459,4 +462,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; + } } diff --git a/src/Psalm/Storage/EnumCaseStorage.php b/src/Psalm/Storage/EnumCaseStorage.php index 12cf8b1098e..77870e3cbd8 100644 --- a/src/Psalm/Storage/EnumCaseStorage.php +++ b/src/Psalm/Storage/EnumCaseStorage.php @@ -10,6 +10,8 @@ final class EnumCaseStorage { + use UnserializeMemoryUsageSuppressionTrait; + public TLiteralString|TLiteralInt|null $value = null; public CodeLocation $stmt_location; diff --git a/src/Psalm/Storage/FileStorage.php b/src/Psalm/Storage/FileStorage.php index ce309152bba..77514caa132 100644 --- a/src/Psalm/Storage/FileStorage.php +++ b/src/Psalm/Storage/FileStorage.php @@ -12,6 +12,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 cdeaee2d3eb..07f335590e5 100644 --- a/src/Psalm/Storage/FunctionLikeParameter.php +++ b/src/Psalm/Storage/FunctionLikeParameter.php @@ -14,6 +14,7 @@ final class FunctionLikeParameter implements HasAttributesInterface, TypeNode { use CustomMetadataTrait; + use UnserializeMemoryUsageSuppressionTrait; public string $name; diff --git a/src/Psalm/Storage/FunctionLikeStorage.php b/src/Psalm/Storage/FunctionLikeStorage.php index 81685917896..f37991fe963 100644 --- a/src/Psalm/Storage/FunctionLikeStorage.php +++ b/src/Psalm/Storage/FunctionLikeStorage.php @@ -18,6 +18,7 @@ abstract class FunctionLikeStorage implements HasAttributesInterface { use CustomMetadataTrait; + use UnserializeMemoryUsageSuppressionTrait; public ?CodeLocation $location = null; diff --git a/src/Psalm/Storage/Possibilities.php b/src/Psalm/Storage/Possibilities.php index 62959661725..09bdad5c23b 100644 --- a/src/Psalm/Storage/Possibilities.php +++ b/src/Psalm/Storage/Possibilities.php @@ -14,6 +14,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 3eb742c281e..5cf159bd2c1 100644 --- a/src/Psalm/Storage/PropertyStorage.php +++ b/src/Psalm/Storage/PropertyStorage.php @@ -11,6 +11,7 @@ final class PropertyStorage implements HasAttributesInterface { use CustomMetadataTrait; + use UnserializeMemoryUsageSuppressionTrait; public ?bool $is_static = null; 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.php b/src/Psalm/Type.php index 12f19b3d658..024251cca09 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -279,7 +279,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,10 +289,12 @@ 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 { + } elseif ($value === '0') { $type = new TNonEmptyString($from_docblock); + } else { + $type = new TNonFalsyString($from_docblock); } } diff --git a/src/Psalm/Type/Atomic.php b/src/Psalm/Type/Atomic.php index b80d0af22a0..835bf49d45c 100644 --- a/src/Psalm/Type/Atomic.php +++ b/src/Psalm/Type/Atomic.php @@ -13,6 +13,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; @@ -81,6 +82,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/Atomic/TKeyedArray.php b/src/Psalm/Type/Atomic/TKeyedArray.php index f3c10394ad7..0760f088e9d 100644 --- a/src/Psalm/Type/Atomic/TKeyedArray.php +++ b/src/Psalm/Type/Atomic/TKeyedArray.php @@ -113,6 +113,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/src/Psalm/Type/Union.php b/src/Psalm/Type/Union.php index 1d0b7fd0748..ba6b51f3989 100644 --- a/src/Psalm/Type/Union.php +++ b/src/Psalm/Type/Union.php @@ -188,6 +188,52 @@ final class Union implements TypeNode public bool $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 diff --git a/stubs/extensions/simplexml.phpstub b/stubs/extensions/simplexml.phpstub index d2501f62096..66b67530cf1 100644 --- a/stubs/extensions/simplexml.phpstub +++ b/stubs/extensions/simplexml.phpstub @@ -69,6 +69,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 {} } /** diff --git a/tests/AnnotationTest.php b/tests/AnnotationTest.php index 1c8b15c4969..3e254899342 100644 --- a/tests/AnnotationTest.php +++ b/tests/AnnotationTest.php @@ -1365,6 +1365,20 @@ function f(): array } EOT, ], + 'validArrayKeyAlias' => [ + 'code' => ' + */ + class Foo {}', + 'assertions' => [], + ], ]; } diff --git a/tests/BinaryOperationTest.php b/tests/BinaryOperationTest.php index c2463e43d83..5ecc5533326 100644 --- a/tests/BinaryOperationTest.php +++ b/tests/BinaryOperationTest.php @@ -359,6 +359,15 @@ public function providerValidCodeParse(): iterable 'code' => ' [ + 'code' => ' [ + '$a===' => 'non-falsy-string', + ], + 'ignored_issues' => ['InvalidReturnType'], + ], 'concatenationWithNumberInWeakMode' => [ 'code' => ' [ + 'code' => ' + * @psalm-suppress DeprecatedClass + */ + class TheChildClass extends TheParentClass {} + '], ]; } diff --git a/tests/FunctionCallTest.php b/tests/FunctionCallTest.php index eb28397f3ad..302bdeb773f 100644 --- a/tests/FunctionCallTest.php +++ b/tests/FunctionCallTest.php @@ -515,19 +515,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' => ' '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' => ' 'InvalidScalarArgument', ], + 'invalidArgumentCallableWithoutArgsUnion' => [ + 'code' => ' 'InvalidArgument', + ], 'invalidArgumentWithDeclareStrictTypes' => [ 'code' => ' [], 'php_version' => '8.1', ], + 'extractVarCheckInvalid' => [ + 'code' => ' 15]; + extract($a); + takesInt($foo);', + 'error_message' => 'InvalidScalarArgument', + ], ]; } diff --git a/tests/ImmutableAnnotationTest.php b/tests/ImmutableAnnotationTest.php index 11e8313932c..3185b14df01 100644 --- a/tests/ImmutableAnnotationTest.php +++ b/tests/ImmutableAnnotationTest.php @@ -766,6 +766,81 @@ public function getShortMutating() : string { }', 'error_message' => 'ImpurePropertyAssignment', ], + 'readonlyByRefInClass' => [ + 'code' => 'values = $values; + } + + /** + * @return mixed + */ + public function bar() + { + 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' => ' 'UndefinedPropertyAssignment', ], - 'setPropertiesOfSimpleXMLElement1' => [ - 'code' => '"); - $a->b = "c"; - ', - 'error_message' => 'UndefinedPropertyAssignment', - ], - 'setPropertiesOfSimpleXMLElement2' => [ - 'code' => '"); - if (isset($a->b)) { - $a->b = "c"; - } - ', - 'error_message' => 'UndefinedPropertyAssignment', - ], ]; } } diff --git a/tests/ReturnTypeProvider/BasenameTest.php b/tests/ReturnTypeProvider/BasenameTest.php index 701255887e0..6e61ffea06a 100644 --- a/tests/ReturnTypeProvider/BasenameTest.php +++ b/tests/ReturnTypeProvider/BasenameTest.php @@ -34,5 +34,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 894bd1c1c6d..029f91d0a8c 100644 --- a/tests/ReturnTypeProvider/DirnameTest.php +++ b/tests/ReturnTypeProvider/DirnameTest.php @@ -51,7 +51,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', ], ]; } diff --git a/tests/TypeCombinationTest.php b/tests/TypeCombinationTest.php index cb03d9f1138..bb1447e26ff 100644 --- a/tests/TypeCombinationTest.php +++ b/tests/TypeCombinationTest.php @@ -122,6 +122,13 @@ function takesLiteralString($arg) {} takesLiteralString($c); }', ], + 'tooLongLiteralShouldBeNonFalsyString' => [ + 'code' => ' [ + '$x===' => 'non-falsy-string', + ], + ], ]; } diff --git a/tests/TypeReconciliation/ConditionalTest.php b/tests/TypeReconciliation/ConditionalTest.php index 460ea919a3c..fe6327a72ed 100644 --- a/tests/TypeReconciliation/ConditionalTest.php +++ b/tests/TypeReconciliation/ConditionalTest.php @@ -1420,9 +1420,9 @@ function foo(A $a) : B { 'nullCoalescePossibleMixed' => [ 'code' => '|false|string */ - function foo() : array { + function foo() { return filter_input(INPUT_POST, "some_var") ?? []; }', ],