diff --git a/composer.json b/composer.json index 40cc9965ebf..aed943f1dd5 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "felixfbecker/language-server-protocol": "^1.5.2", "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0", "netresearch/jsonmapper": "^1.0 || ^2.0 || ^3.0 || ^4.0", - "nikic/php-parser": "^4.16", + "nikic/php-parser": "^4.17", "sebastian/diff": "^4.0 || ^5.0 || ^6.0", "spatie/array-to-xml": "^2.17.0 || ^3.0", "symfony/console": "^4.1.6 || ^5.0 || ^6.0 || ^7.0", diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index 5df0bc588e1..48e23a2c603 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -7906,9 +7906,9 @@ 'mysqli_fetch_array\'2' => ['list|false|null', 'result'=>'mysqli_result', 'mode='=>'2'], 'mysqli_fetch_assoc' => ['array|false|null', 'result'=>'mysqli_result'], 'mysqli_fetch_column' => ['null|int|float|string|false', 'result'=>'mysqli_result', 'column='=>'int'], -'mysqli_fetch_field' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'result'=>'mysqli_result'], -'mysqli_fetch_field_direct' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'result'=>'mysqli_result', 'index'=>'int'], -'mysqli_fetch_fields' => ['list', 'result'=>'mysqli_result'], +'mysqli_fetch_field' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result'], +'mysqli_fetch_field_direct' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result', 'index'=>'int'], +'mysqli_fetch_fields' => ['list', 'result'=>'mysqli_result'], 'mysqli_fetch_lengths' => ['array|false', 'result'=>'mysqli_result'], 'mysqli_fetch_object' => ['object|false|null', 'result'=>'mysqli_result', 'class='=>'string', 'constructor_args='=>'array'], 'mysqli_fetch_row' => ['list|false|null', 'result'=>'mysqli_result'], @@ -7920,7 +7920,7 @@ 'mysqli_get_charset' => ['?object', 'mysql'=>'mysqli'], 'mysqli_get_client_info' => ['string', 'mysql='=>'?mysqli'], 'mysqli_get_client_stats' => ['array'], -'mysqli_get_client_version' => ['int', 'link'=>'mysqli'], +'mysqli_get_client_version' => ['int'], 'mysqli_get_connection_stats' => ['array', 'mysql'=>'mysqli'], 'mysqli_get_host_info' => ['string', 'mysql'=>'mysqli'], 'mysqli_get_links_stats' => ['array'], @@ -7962,9 +7962,9 @@ 'mysqli_result::fetch_array\'2' => ['list|false|null', 'mode='=>'2'], 'mysqli_result::fetch_assoc' => ['array|false|null'], 'mysqli_result::fetch_column' => ['null|int|float|string|false', 'column='=>'int'], -'mysqli_result::fetch_field' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false'], -'mysqli_result::fetch_field_direct' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'index'=>'int'], -'mysqli_result::fetch_fields' => ['list'], +'mysqli_result::fetch_field' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false'], +'mysqli_result::fetch_field_direct' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'index'=>'int'], +'mysqli_result::fetch_fields' => ['list'], 'mysqli_result::fetch_object' => ['object|false|null', 'class='=>'string', 'constructor_args='=>'array'], 'mysqli_result::fetch_row' => ['list|false|null'], 'mysqli_result::field_seek' => ['true', 'index'=>'int'], @@ -10630,7 +10630,7 @@ 'ReflectionParameter::getDeclaringFunction' => ['ReflectionFunctionAbstract'], 'ReflectionParameter::getDefaultValue' => ['mixed'], 'ReflectionParameter::getDefaultValueConstantName' => ['?string'], -'ReflectionParameter::getName' => ['string'], +'ReflectionParameter::getName' => ['non-empty-string'], 'ReflectionParameter::getPosition' => ['int<0, max>'], 'ReflectionParameter::getType' => ['?ReflectionType'], 'ReflectionParameter::hasType' => ['bool'], diff --git a/dictionaries/CallMap_81_delta.php b/dictionaries/CallMap_81_delta.php index eaa855863e6..176978a46b2 100644 --- a/dictionaries/CallMap_81_delta.php +++ b/dictionaries/CallMap_81_delta.php @@ -727,6 +727,30 @@ 'old' => ['bool', 'statement' => 'mysqli_stmt'], 'new' => ['bool', 'statement' => 'mysqli_stmt', 'params=' => 'list|null'], ], + 'mysqli_fetch_field' => [ + 'old' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result'], + 'new' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result'], + ], + 'mysqli_fetch_field_direct' => [ + 'old' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result', 'index'=>'int'], + 'new' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result', 'index'=>'int'], + ], + 'mysqli_fetch_fields' => [ + 'old' => ['list', 'result'=>'mysqli_result'], + 'new' => ['list', 'result'=>'mysqli_result'], + ], + 'mysqli_result::fetch_field' => [ + 'old' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false'], + 'new' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false'], + ], + 'mysqli_result::fetch_field_direct' => [ + 'old' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'index'=>'int'], + 'new' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'index'=>'int'], + ], + 'mysqli_result::fetch_fields' => [ + 'old' => ['list'], + 'new' => ['list'], + ], 'mysqli_stmt_execute' => [ 'old' => ['bool', 'statement' => 'mysqli_stmt'], 'new' => ['bool', 'statement' => 'mysqli_stmt', 'params=' => 'list|null'], diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index 127e0f81311..aa56a806272 100644 --- a/dictionaries/CallMap_historical.php +++ b/dictionaries/CallMap_historical.php @@ -5968,7 +5968,7 @@ 'ReflectionParameter::getDeclaringFunction' => ['ReflectionFunctionAbstract'], 'ReflectionParameter::getDefaultValue' => ['mixed'], 'ReflectionParameter::getDefaultValueConstantName' => ['?string'], - 'ReflectionParameter::getName' => ['string'], + 'ReflectionParameter::getName' => ['non-empty-string'], 'ReflectionParameter::getPosition' => ['int<0, max>'], 'ReflectionParameter::getType' => ['?ReflectionType'], 'ReflectionParameter::hasType' => ['bool'], @@ -12728,9 +12728,9 @@ 'mysqli_fetch_array\'1' => ['array|false|null', 'result'=>'mysqli_result', 'mode='=>'1'], 'mysqli_fetch_array\'2' => ['list|false|null', 'result'=>'mysqli_result', 'mode='=>'2'], 'mysqli_fetch_assoc' => ['array|false|null', 'result'=>'mysqli_result'], - 'mysqli_fetch_field' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'result'=>'mysqli_result'], - 'mysqli_fetch_field_direct' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'result'=>'mysqli_result', 'index'=>'int'], - 'mysqli_fetch_fields' => ['list', 'result'=>'mysqli_result'], + 'mysqli_fetch_field' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result'], + 'mysqli_fetch_field_direct' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result', 'index'=>'int'], + 'mysqli_fetch_fields' => ['list', 'result'=>'mysqli_result'], 'mysqli_fetch_lengths' => ['array|false', 'result'=>'mysqli_result'], 'mysqli_fetch_object' => ['object|false|null', 'result'=>'mysqli_result', 'class='=>'string', 'constructor_args='=>'array'], 'mysqli_fetch_row' => ['list|false|null', 'result'=>'mysqli_result'], @@ -12742,7 +12742,7 @@ 'mysqli_get_charset' => ['?object', 'mysql'=>'mysqli'], 'mysqli_get_client_info' => ['string', 'mysql='=>'?mysqli'], 'mysqli_get_client_stats' => ['array'], - 'mysqli_get_client_version' => ['int', 'link'=>'mysqli'], + 'mysqli_get_client_version' => ['int'], 'mysqli_get_connection_stats' => ['array', 'mysql'=>'mysqli'], 'mysqli_get_host_info' => ['string', 'mysql'=>'mysqli'], 'mysqli_get_links_stats' => ['array'], @@ -12783,9 +12783,9 @@ 'mysqli_result::fetch_array\'1' => ['array|false|null', 'mode='=>'1'], 'mysqli_result::fetch_array\'2' => ['list|false|null', 'mode='=>'2'], 'mysqli_result::fetch_assoc' => ['array|false|null'], - 'mysqli_result::fetch_field' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false'], - 'mysqli_result::fetch_field_direct' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'index'=>'int'], - 'mysqli_result::fetch_fields' => ['list'], + 'mysqli_result::fetch_field' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false'], + 'mysqli_result::fetch_field_direct' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'index'=>'int'], + 'mysqli_result::fetch_fields' => ['list'], 'mysqli_result::fetch_object' => ['object|false|null', 'class='=>'string', 'constructor_args='=>'array'], 'mysqli_result::fetch_row' => ['list|false|null'], 'mysqli_result::field_seek' => ['bool', 'index'=>'int'], diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index 069fd2c7a1f..93fecd99d7d 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -129,7 +129,11 @@ */ class Config { - private const DEFAULT_FILE_NAME = 'psalm.xml'; + private const DEFAULT_FILE_NAMES = [ + 'psalm.xml', + 'psalm.xml.dist', + 'psalm.dist.xml', + ]; public const CONFIG_NAMESPACE = 'https://getpsalm.org/schema/config'; public const REPORT_INFO = 'info'; public const REPORT_ERROR = 'error'; @@ -773,10 +777,10 @@ public static function locateConfigFile(string $path): ?string } do { - $maybe_path = $dir_path . DIRECTORY_SEPARATOR . self::DEFAULT_FILE_NAME; - - if (file_exists($maybe_path) || file_exists($maybe_path .= '.dist')) { - return $maybe_path; + foreach (self::DEFAULT_FILE_NAMES as $defaultFileName) { + if (file_exists($maybe_path = $dir_path . DIRECTORY_SEPARATOR . $defaultFileName)) { + return $maybe_path; + } } $dir_path = dirname($dir_path); diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php index bedec945c25..b8c236e087f 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php @@ -1087,7 +1087,7 @@ private static function analyzeAtomicAssignment( * If we have an explicit list of all allowed magic properties on the class, and we're * not in that list, fall through */ - if (!$var_id || !$class_storage->hasSealedProperties($codebase->config)) { + if (!$class_storage->hasSealedProperties($codebase->config)) { if (!$context->collect_initializations && !$context->collect_mutations) { self::taintProperty( $statements_analyzer, diff --git a/src/Psalm/Internal/Json/Json.php b/src/Psalm/Internal/Json/Json.php index 9cd7ebb743a..feadf0d1679 100644 --- a/src/Psalm/Internal/Json/Json.php +++ b/src/Psalm/Internal/Json/Json.php @@ -4,8 +4,12 @@ use RuntimeException; +use function array_walk_recursive; +use function bin2hex; +use function is_string; use function json_encode; use function json_last_error_msg; +use function preg_replace_callback; use const JSON_PRETTY_PRINT; use const JSON_UNESCAPED_SLASHES; @@ -19,6 +23,30 @@ final class Json { public const PRETTY = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + // from https://stackoverflow.com/a/11709412 + private const INVALID_UTF_REGEXP = <<<'EOF' + /( + [\xC0-\xC1] # Invalid UTF-8 Bytes + | [\xF5-\xFF] # Invalid UTF-8 Bytes + | \xE0[\x80-\x9F] # Overlong encoding of prior code point + | \xF0[\x80-\x8F] # Overlong encoding of prior code point + | [\xC2-\xDF](?![\x80-\xBF]) # Invalid UTF-8 Sequence Start + | [\xE0-\xEF](?![\x80-\xBF]{2}) # Invalid UTF-8 Sequence Start + | [\xF0-\xF4](?![\x80-\xBF]{3}) # Invalid UTF-8 Sequence Start + | (?<=[\x00-\x7F\xF5-\xFF])[\x80-\xBF] # Invalid UTF-8 Sequence Middle + | (? $data * @psalm-pure */ - public static function encode($data, ?int $options = null): string + public static function encode(array $data, ?int $options = null): string { if ($options === null) { $options = self::DEFAULT; } $result = json_encode($data, $options); + + if ($result == false) { + $result = json_encode(self::scrub($data), $options); + } + if ($result === false) { /** @psalm-suppress ImpureFunctionCall */ throw new RuntimeException('Cannot create JSON string: '.json_last_error_msg()); @@ -43,4 +76,27 @@ public static function encode($data, ?int $options = null): string return $result; } + + /** @psalm-pure */ + private static function scrub(array $data): array + { + /** @psalm-suppress ImpureFunctionCall */ + array_walk_recursive( + $data, + /** + * @psalm-pure + * @param mixed $value + */ + function (&$value): void { + if (is_string($value)) { + $value = preg_replace_callback( + self::INVALID_UTF_REGEXP, + static fn(array $matches): string => '', + $value, + ); + } + }, + ); + return $data; + } } diff --git a/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php b/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php index 40360a93b37..f422285413e 100644 --- a/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php +++ b/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php @@ -121,13 +121,21 @@ public static function isContainedBy( return false; } - if ($input_type_part instanceof TCallableString - && (get_class($container_type_part) === TSingleLetter::class - || get_class($container_type_part) === TNonEmptyString::class + if ($input_type_part instanceof TCallableString) { + if (get_class($container_type_part) === TNonEmptyString::class || get_class($container_type_part) === TNonFalsyString::class - || get_class($container_type_part) === TLowercaseString::class) - ) { - return true; + ) { + return true; + } + + if (get_class($container_type_part) === TLowercaseString::class + || get_class($container_type_part) === TSingleLetter::class + ) { + if ($atomic_comparison_result) { + $atomic_comparison_result->type_coerced = true; + } + return false; + } } if (($container_type_part instanceof TLowercaseString diff --git a/stubs/CoreGenericFunctions.phpstub b/stubs/CoreGenericFunctions.phpstub index 23acb168ce7..622eed45916 100644 --- a/stubs/CoreGenericFunctions.phpstub +++ b/stubs/CoreGenericFunctions.phpstub @@ -1913,7 +1913,7 @@ function stream_select(null|array &$read, null|array &$write, null|array &$excep * @psalm-taint-escape sql * @psalm-flow ($string) -> return */ -function mysqli_escape_string($string) {} +function mysqli_escape_string(mysqli $mysqli, $string) {} /** * @psalm-pure @@ -1921,7 +1921,7 @@ function mysqli_escape_string($string) {} * @psalm-taint-escape sql * @psalm-flow ($string) -> return */ -function mysqli_real_escape_string($string) {} +function mysqli_real_escape_string(mysqli $mysqli, $string) {} /** * @psalm-pure @@ -2042,3 +2042,23 @@ function exec(string $command, &$output = null, int &$result_code = null): strin * @psalm-ignore-falsable-return */ function get_browser(?string $user_agent = null, bool $return_array = false): object|array|false {} + +/** + * @psalm-taint-sink callable $callback + */ +function forward_static_call(callable $callback, mixed ...$args): mixed {} + +/** + * @psalm-taint-sink callable $callback + */ +function forward_static_call_array(callable $callback, array $args): mixed {} + +/** + * @psalm-taint-sink callable $callback + */ +function register_shutdown_function(callable $callback, mixed ...$args): void {} + +/** + * @psalm-taint-sink callable $callback + */ +function register_tick_function(callable $callback, mixed ...$args): bool {} diff --git a/stubs/extensions/mysqli.phpstub b/stubs/extensions/mysqli.phpstub index 39566bc9592..370db68690b 100644 --- a/stubs/extensions/mysqli.phpstub +++ b/stubs/extensions/mysqli.phpstub @@ -126,6 +126,11 @@ class mysqli * @var int<-1, max>|numeric-string */ public int|string $affected_rows; + + /** + * @psalm-taint-sink sql $query + */ + public function execute_query(string $query, ?array $params = null): mysqli_result|bool {} } /** @@ -190,6 +195,11 @@ class mysqli_stmt public string $sqlstate; } +/** + * @psalm-taint-sink sql $query + */ +function mysqli_execute_query(mysqli $mysql, string $query, ?array $params = null): mysqli_result|bool {} + /** * @psalm-taint-sink callable $class * diff --git a/tests/FunctionCallTest.php b/tests/FunctionCallTest.php index 833b38af431..b928a42cecf 100644 --- a/tests/FunctionCallTest.php +++ b/tests/FunctionCallTest.php @@ -402,6 +402,14 @@ function foo(string $s) : void { 'assertions' => [], 'ignored_issues' => ['MixedAssignment', 'MixedArgument'], ], + 'noRedundantErrorForCallableStrToLower' => [ + 'code' => <<<'PHP' + [ 'code' => 'assertEquals('{"data":""}', Json::encode(["data" => $invalidUtf])); + } +} diff --git a/tests/MixinAnnotationTest.php b/tests/MixinAnnotationTest.php index 88529223bd8..3c0c11b26bb 100644 --- a/tests/MixinAnnotationTest.php +++ b/tests/MixinAnnotationTest.php @@ -616,6 +616,21 @@ class A {} (new A)->foo;', 'error_message' => 'UndefinedPropertyFetch', ], + 'undefinedMixinClassWithPropertyFetch_WithMagicMethod' => [ + 'code' => 'foo;', + 'error_message' => 'UndefinedMagicPropertyFetch', + ], 'undefinedMixinClassWithPropertyAssignment' => [ 'code' => 'foo = "bar";', 'error_message' => 'UndefinedPropertyAssignment', ], + 'undefinedMixinClassWithPropertyAssignment_WithMagicMethod' => [ + 'code' => 'foo = "bar";', + 'error_message' => 'UndefinedMagicPropertyAssignment', + ], 'undefinedMixinClassWithMethodCall' => [ 'code' => 'foo();', 'error_message' => 'UndefinedMethod', ], + 'undefinedMixinClassWithMethodCall_WithMagicMethod' => [ + 'code' => 'foo();', + 'error_message' => 'UndefinedMagicMethod', + ], + 'undefinedMixinClassWithStaticMethodCall' => [ + 'code' => ' 'UndefinedMethod', + ], + 'undefinedMixinClassWithStaticMethodCall_WithMagicMethod' => [ + 'code' => ' 'UndefinedMagicMethod', + ], 'inheritTemplatedMixinWithSelf' => [ 'code' => 'escape_string($_GET["a"]); - $b = mysqli_escape_string($_GET["b"]); + $b = mysqli_escape_string($mysqli, $_GET["b"]); $c = $mysqli->real_escape_string($_GET["c"]); - $d = mysqli_real_escape_string($_GET["d"]); + $d = mysqli_real_escape_string($mysqli, $_GET["d"]); $mysqli->query("$a$b$c$d");', ], @@ -2555,12 +2555,14 @@ public static function getPrevious(string $s): string { ], 'assertMysqliOnlyEscapesSqlTaints3' => [ 'code' => ' 'TaintedHtml', ], 'assertMysqliOnlyEscapesSqlTaints4' => [ 'code' => ' 'TaintedHtml', ], 'assertDb2OnlyEscapesSqlTaints' => [ @@ -2632,6 +2634,58 @@ public static function getPrevious(string $s): string { $function->invoke();', 'error_message' => 'TaintedCallable', ], + 'taintedExecuteQueryFunction' => [ + 'code' => ' 'TaintedSql', + ], + 'taintedExecuteQueryMethod' => [ + 'code' => 'execute_query($query);', + 'error_message' => 'TaintedSql', + ], + 'taintedRegisterShutdownFunction' => [ + 'code' => ' 'TaintedCallable', + ], + 'taintedRegisterTickFunction' => [ + 'code' => ' 'TaintedCallable', + ], + 'taintedForwardStaticCall' => [ + 'code' => ' 'TaintedCallable', + ], + 'taintedForwardStaticCallArray' => [ + 'code' => ' 'TaintedCallable', + ], ]; $dataSetsArrays = [ diff --git a/tests/TypeComparatorTest.php b/tests/TypeComparatorTest.php index da9dfe055f8..3848fa766c0 100644 --- a/tests/TypeComparatorTest.php +++ b/tests/TypeComparatorTest.php @@ -6,6 +6,7 @@ use Psalm\Internal\Provider\FakeFileProvider; use Psalm\Internal\Provider\Providers; use Psalm\Internal\RuntimeCaches; +use Psalm\Internal\Type\Comparator\TypeComparisonResult; use Psalm\Internal\Type\Comparator\UnionTypeComparator; use Psalm\Internal\Type\TypeTokenizer; use Psalm\Tests\Internal\Provider\FakeParserCacheProvider; @@ -129,6 +130,43 @@ public function testTypeDoesNotAcceptType(string $parent_type_string, string $ch ); } + /** @dataProvider getCoercibleComparisons */ + public function testTypeIsCoercible(string $parent_type_string, string $child_type_string): void + { + $parent_type = Type::parseString($parent_type_string); + $child_type = Type::parseString($child_type_string); + + $result = new TypeComparisonResult(); + + $contained = UnionTypeComparator::isContainedBy( + $this->project_analyzer->getCodebase(), + $child_type, + $parent_type, + false, + false, + $result, + ); + + $this->assertFalse($contained, 'Type ' . $parent_type_string . ' should not contain ' . $child_type_string); + $this->assertTrue( + $result->type_coerced, + 'Type ' . $parent_type_string . ' should be coercible into ' . $child_type_string, + ); + } + + /** @return iterable */ + public function getCoercibleComparisons(): iterable + { + yield 'callableStringIntoLowercaseString' => [ + 'lowercase-string', + 'callable-string', + ]; + yield 'lowercaseStringIntoCallableString' => [ + 'callable-string', + 'lowercase-string', + ]; + } + /** * @return array */ @@ -155,10 +193,6 @@ public function getSuccessfulComparisons(): array 'array{foo?: string}&array', 'array', ], - 'Lowercase-stringAndCallable-string' => [ - 'lowercase-string', - 'callable-string', - ], 'callableUnionAcceptsCallableUnion' => [ '(callable(int,string[]): void)|(callable(int): void)', '(callable(int): void)|(callable(int,string[]): void)',