Skip to content

Commit 8f4a43c

Browse files
ondrejmirtesclaude
andcommitted
Add unserialize() throw type extension
PhpStorm stubs now declare @throws \TypeError and \ValueError on unserialize(), which made every call an explicit throw point. Both are thrown for invalid $options, which a call without them or with a constant valid array cannot have, and PHP 7 only warns. TypeError is also thrown when the data does not fit a typed property, so it stays unless no class is allowed: then objects become __PHP_Incomplete_Class. The decisions read native types. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhb4ssXuKVWcVBcNFpsdn7
1 parent 9616f3f commit 8f4a43c

3 files changed

Lines changed: 305 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Expr\FuncCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\DependencyInjection\AutowiredService;
8+
use PHPStan\Php\PhpVersion;
9+
use PHPStan\Reflection\FunctionReflection;
10+
use PHPStan\Type\Constant\ConstantArrayType;
11+
use PHPStan\Type\Constant\ConstantStringType;
12+
use PHPStan\Type\DynamicFunctionThrowTypeExtension;
13+
use PHPStan\Type\IntegerRangeType;
14+
use PHPStan\Type\ObjectType;
15+
use PHPStan\Type\Type;
16+
use PHPStan\Type\VoidType;
17+
use TypeError;
18+
use function count;
19+
use function preg_match;
20+
21+
/**
22+
* unserialize() throws TypeError and ValueError for invalid $options, and
23+
* TypeError when the data does not fit a typed property. Objects of classes
24+
* that are not allowed are unserialized as __PHP_Incomplete_Class.
25+
*/
26+
#[AutowiredService]
27+
final class UnserializeFunctionThrowTypeExtension implements DynamicFunctionThrowTypeExtension
28+
{
29+
30+
public function __construct(private PhpVersion $phpVersion)
31+
{
32+
}
33+
34+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
35+
{
36+
return $functionReflection->getName() === 'unserialize';
37+
}
38+
39+
public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): ?Type
40+
{
41+
if (!$this->phpVersion->throwsValueErrorForInternalFunctions()) {
42+
return new VoidType();
43+
}
44+
45+
$args = $funcCall->getArgs();
46+
foreach ($args as $arg) {
47+
if ($arg->unpack || $arg->name !== null) {
48+
return $functionReflection->getThrowType();
49+
}
50+
}
51+
52+
if (count($args) < 2) {
53+
return new ObjectType(TypeError::class);
54+
}
55+
56+
$optionsType = $scope->getNativeType($args[1]->value);
57+
$constantArrays = $optionsType->getConstantArrays();
58+
if (!$optionsType->isArray()->yes() || count($constantArrays) === 0) {
59+
return $functionReflection->getThrowType();
60+
}
61+
62+
$allowsNoClasses = true;
63+
foreach ($constantArrays as $constantArray) {
64+
if (!$this->areOptionsValid($constantArray)) {
65+
return $functionReflection->getThrowType();
66+
}
67+
68+
if ($this->allowsNoClasses($constantArray)) {
69+
continue;
70+
}
71+
72+
$allowsNoClasses = false;
73+
}
74+
75+
if ($allowsNoClasses) {
76+
return new VoidType();
77+
}
78+
79+
return new ObjectType(TypeError::class);
80+
}
81+
82+
private function areOptionsValid(ConstantArrayType $options): bool
83+
{
84+
$allowedClassesKey = new ConstantStringType('allowed_classes');
85+
if (!$options->hasOffsetValueType($allowedClassesKey)->no()) {
86+
$allowedClasses = $options->getOffsetValueType($allowedClassesKey);
87+
if (!$allowedClasses->isBoolean()->yes() && !$this->isListOfClassNames($allowedClasses)) {
88+
return false;
89+
}
90+
}
91+
92+
$maxDepthKey = new ConstantStringType('max_depth');
93+
if (!$options->hasOffsetValueType($maxDepthKey)->no()) {
94+
return IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($options->getOffsetValueType($maxDepthKey))->yes();
95+
}
96+
97+
return true;
98+
}
99+
100+
private function isListOfClassNames(Type $type): bool
101+
{
102+
if (!$type->isArray()->yes()) {
103+
return false;
104+
}
105+
106+
if ($type->isIterableAtLeastOnce()->no()) {
107+
return true;
108+
}
109+
110+
$classNames = $type->getIterableValueType()->getConstantStrings();
111+
if (count($classNames) === 0 || !$type->getIterableValueType()->isString()->yes()) {
112+
return false;
113+
}
114+
115+
foreach ($classNames as $className) {
116+
if (preg_match('~^[0-9A-Za-z_\\\\\x80-\xff]*$~', $className->getValue()) !== 1) {
117+
return false;
118+
}
119+
}
120+
121+
return true;
122+
}
123+
124+
/**
125+
* With no allowed classes, objects become __PHP_Incomplete_Class and no
126+
* typed property can reject the data.
127+
*/
128+
private function allowsNoClasses(ConstantArrayType $options): bool
129+
{
130+
$allowedClassesKey = new ConstantStringType('allowed_classes');
131+
if (!$options->hasOffsetValueType($allowedClassesKey)->yes()) {
132+
return false;
133+
}
134+
135+
$allowedClasses = $options->getOffsetValueType($allowedClassesKey);
136+
137+
return $allowedClasses->isFalse()->yes()
138+
|| ($allowedClasses->isArray()->yes() && $allowedClasses->isIterableAtLeastOnce()->no());
139+
}
140+
141+
}

‎tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,4 +1422,82 @@ public function testDsMapVoidThrowType(): void
14221422
]);
14231423
}
14241424

1425+
#[RequiresPhp('>= 8.0.0')]
1426+
public function testUnserializeThrowType(): void
1427+
{
1428+
$this->analyse([__DIR__ . '/data/unserialize-throw-type.php'], [
1429+
[
1430+
'Dead catch - ValueError is never thrown in the try block.',
1431+
15,
1432+
],
1433+
[
1434+
'Dead catch - TypeError is never thrown in the try block.',
1435+
27,
1436+
],
1437+
[
1438+
'Dead catch - TypeError is never thrown in the try block.',
1439+
33,
1440+
],
1441+
[
1442+
'Dead catch - ValueError is never thrown in the try block.',
1443+
39,
1444+
],
1445+
]);
1446+
}
1447+
1448+
#[RequiresPhp('< 8.0.0')]
1449+
public function testUnserializeThrowTypeBeforePhp8(): void
1450+
{
1451+
$this->analyse([__DIR__ . '/data/unserialize-throw-type.php'], [
1452+
[
1453+
'Dead catch - ValueError is never thrown in the try block.',
1454+
15,
1455+
],
1456+
[
1457+
'Dead catch - TypeError is never thrown in the try block.',
1458+
21,
1459+
],
1460+
[
1461+
'Dead catch - TypeError is never thrown in the try block.',
1462+
27,
1463+
],
1464+
[
1465+
'Dead catch - TypeError is never thrown in the try block.',
1466+
33,
1467+
],
1468+
[
1469+
'Dead catch - ValueError is never thrown in the try block.',
1470+
39,
1471+
],
1472+
[
1473+
'Dead catch - TypeError is never thrown in the try block.',
1474+
45,
1475+
],
1476+
[
1477+
'Dead catch - ValueError is never thrown in the try block.',
1478+
51,
1479+
],
1480+
[
1481+
'Dead catch - ValueError is never thrown in the try block.',
1482+
57,
1483+
],
1484+
[
1485+
'Dead catch - TypeError is never thrown in the try block.',
1486+
63,
1487+
],
1488+
[
1489+
'Dead catch - ValueError is never thrown in the try block.',
1490+
69,
1491+
],
1492+
[
1493+
'Dead catch - TypeError is never thrown in the try block.',
1494+
75,
1495+
],
1496+
[
1497+
'Dead catch - ValueError is never thrown in the try block.',
1498+
81,
1499+
],
1500+
]);
1501+
}
1502+
14251503
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace UnserializeThrowType;
4+
5+
class Foo
6+
{
7+
8+
/**
9+
* @param array{allowed_classes: false} $phpDocOptions
10+
*/
11+
public function doFoo(string $s, array $options, array $phpDocOptions, int $depth): void
12+
{
13+
try {
14+
$a = unserialize($s);
15+
} catch (\ValueError $e) {
16+
17+
}
18+
19+
try {
20+
$a = unserialize($s);
21+
} catch (\TypeError $e) {
22+
23+
}
24+
25+
try {
26+
$a = unserialize($s, ['allowed_classes' => false]);
27+
} catch (\TypeError $e) {
28+
29+
}
30+
31+
try {
32+
$a = unserialize($s, ['allowed_classes' => [], 'max_depth' => 10]);
33+
} catch (\TypeError $e) {
34+
35+
}
36+
37+
try {
38+
$a = unserialize($s, ['allowed_classes' => [Foo::class], 'max_depth' => 10]);
39+
} catch (\ValueError $e) {
40+
41+
}
42+
43+
try {
44+
$a = unserialize($s, ['allowed_classes' => [Foo::class]]);
45+
} catch (\TypeError $e) {
46+
47+
}
48+
49+
try {
50+
$a = unserialize($s, ['max_depth' => -1]);
51+
} catch (\ValueError $e) {
52+
53+
}
54+
55+
try {
56+
$a = unserialize($s, ['allowed_classes' => ['a b']]);
57+
} catch (\ValueError $e) {
58+
59+
}
60+
61+
try {
62+
$a = unserialize($s, ['max_depth' => '5']);
63+
} catch (\TypeError $e) {
64+
65+
}
66+
67+
try {
68+
$a = unserialize($s, $options);
69+
} catch (\ValueError $e) {
70+
71+
}
72+
73+
try {
74+
$a = unserialize($s, $phpDocOptions);
75+
} catch (\TypeError $e) {
76+
77+
}
78+
79+
try {
80+
$a = unserialize($s, ['max_depth' => $depth]);
81+
} catch (\ValueError $e) {
82+
83+
}
84+
}
85+
86+
}

0 commit comments

Comments
 (0)