Skip to content

Declare Throwable as the unserialize() throw type unless allowed_classes provably forbids all classes - #6629

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-k4sediy
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-k4sediy

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

unserialize() runs user code: autoloaders, __wakeup(), __unserialize() and Serializable::unserialize(). Any of these can throw any exception. The throw type extension only declared TypeError, so a catch (Exception $e) around unserialize($s) was reported as Dead catch - Exception is never thrown in the try block.

With this change, the extension returns Throwable whenever the call may instantiate classes.

Changes

  • src/Type/Php/UnserializeFunctionThrowTypeExtension.php:
    • Return Throwable when there are no options, when the options are not a constant array, when arguments are named or unpacked, or when allowed_classes is not provably false/[].
    • When no class can be instantiated, valid options give void. Invalid options give the stub's TypeError|ValueError on PHP 8 and void on PHP 7.
    • PHP 7 used to always get void. It now gets Throwable too when classes are allowed, because magic methods can throw there as well.
  • Probed analogous builtins that call user code:
    • serialize() already has an implicit throw point.
    • sprintf()/printf() do not accept objects in PHPStan's signatures.
    • json_encode() of a JsonSerializable can also throw from jsonSerialize(). I left it unchanged: returning Throwable would swallow the checked JsonException for JSON_THROW_ON_ERROR, and a dynamic throw type extension can only return a single Type.

Root cause

The extension treated unserialize() as if its only possible exceptions were those thrown by the engine: option validation and typed-property mismatches. It ignored the user code that unserialize() runs for every object it restores. So every catch of a non-TypeError exception looked dead, including ValueError catches when classes were allowed.

Test

  • tests/PHPStan/Rules/Exceptions/data/bug-15329.php: the reproducer from the issue. Before the fix it reported a dead catch; now it reports nothing.
  • tests/PHPStan/Rules/Exceptions/data/unserialize-throw-type.php: updated expectations. Catches around calls that may instantiate classes are no longer reported. New cases cover:
    • invalid options with allowed_classes => false (ValueError is thrown on PHP 8 and is dead on PHP 7)
    • catch (Exception) with allowed_classes => [] (still dead)
    • catch (Exception) with allowed_classes => true (not dead)

Fixes phpstan/phpstan#15329

🤖 Generated with Claude Code

…_classes` provably forbids all classes

- UnserializeFunctionThrowTypeExtension now returns Throwable whenever unserialize()
  may instantiate classes (no options, non-constant options, named/unpacked args,
  or allowed_classes not provably false/[]), since autoloaders, __wakeup(),
  __unserialize() and Serializable::unserialize() can throw anything. Previously
  only TypeError (or the stub's TypeError|ValueError) was declared, so catching
  Exception (or ValueError) around unserialize() was reported as a dead catch.
- The same applies on PHP 7, which previously always got a void throw type.
- When no class is allowed, valid options still produce no throw point and
  invalid options on PHP 8 keep the stub's TypeError|ValueError.
- Probed json_encode() with JsonSerializable (jsonSerialize() can also throw);
  not changed here because a single Type cannot express JsonException together
  with arbitrary user exceptions without losing the checked JsonException.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dead catch reported in unserialize()

1 participant