【PHP8.3】unserializeのエラーハンドリングがいいかんじになるよ

[unserialize](https://www.php.net/manual/ja/function.unserialize.php)に変なデータを与えた場合、値によってバラバラな挙動になります。

```php
unserialize('foo'); // E_NOTICE
unserialize('E:3:"foo";'); // E_WARNING + E_NOTICE
unserialize('O:19:"SplDoublyLinkedList":3:{i:0;i:0;i:1;N;i:2;a:0:{}}'); // UnexpectedValueException
```

E_WARNING + E_NOTICEなんて特に意味がわかりませんね。
ということで、このあたりをどうにかしようというRFCが提出されました。

以下は該当のRFC、[Improve unserialize() error handling](https://wiki.php.net/rfc/improve_unserialize_error_handling)の紹介です。


# PHP RFC: Improve unserialize() error handling


## Introduction


現在、[unserialize](https://www.php.net/manual/ja/function.unserialize.php)のエラーは一貫性がなく、制御が困難です。
与えた文字列によって、PHPはE_NOTICEかE_WARNINGか\Exceptionか\Errorか、もしくはこれらのうち複数を発生させます。

```php
unserialize('foo'); // Notice: unserialize(): Error at offset 0 of 3 bytes
unserialize('i:12345678901234567890;'); // Warning: unserialize(): Numerical result out of range
unserialize('E:3:"foo";'); // Warning: unserialize(): Invalid enum name 'foo' (missing colon)
                           // Notice: unserialize(): Error at offset 0 of 10 bytes
unserialize('E:3:"fo:";'); // Warning: unserialize(): Class 'fo' not found
                           // Notice: unserialize(): Error at offset 0 of 10 bytes

try {
    unserialize('O:19:"SplDoublyLinkedList":3:{i:0;i:0;i:1;N;i:2;a:0:{}}');
} catch (UnexpectedValueException $e) {
    echo $e->getMessage(), PHP_EOL; // Incomplete or ill-typed serialization data
}

try {
    unserialize(
        'a:2:{i:12345678901234567890;O:19:"SplDoublyLinkedList":3:{i:0;i:0;i:1;N;i:2;a:0:{}}}'
    ); // Warning: unserialize(): Numerical result out of range
       // Notice: unserialize(): Unexpected end of serialized data
       // Notice: unserialize(): Error at offset 83 of 84 bytes
} catch (UnexpectedValueException $e) {
    echo $e->getMessage(), PHP_EOL; // Incomplete or ill-typed serialization data
}
```

全てのエラーを確実に処理したい場合、現在は以下のようにしなければなりません。

```php
try {
    set_error_handler(static function ($severity, $message, $file, $line) {
        throw new \ErrorException($message, 0, $severity, $file, $line);
    });
    $result = unserialize($serialized);
} catch (\Throwable $e) {
    // unserializeに失敗した
} finally {
    restore_error_handler();
}

var_dump($result); // 何かする
```

unserializeをエラーハンドラでキャッチする例は、実際に[restore_error_handlerのドキュメント](https://www.php.net/manual/en/function.restore-error-handler.php)で使用されています。


## Proposal

この問題への対策は、2点で構成されています。

### Add wrapper Exception 'UnserializationFailedException'

新たな例外`\UnserializationFailedException`を追加します。
現在`unserialize`関数およびマジックメソッド`__unserialize()`で投げている例外は、全て`\UnserializationFailedException`になります。

これによって、開発者はひとつの文`catch(\UnserializationFailedException $e)`で、`unserialize`中に発生する全てをキャッチできるようになります。
これによって全てのロジックをひとつのtryで囲むだけで済み、可読性を上げることができます。
`unserialize`以外からUnserializationFailedExceptionが発生することはないので、無関係な例外を誤まって検出してしまうこともありません。
`->getPrevious()`メソッドで、シリアライズが失敗した原因を知ることができます。

これらの変更をPHPコードとして表すと、おおよそ以下のようになります。

```php
function unserialize(string $data, array $options = []): mixed
{
    try {
        // 現在のunserializeのロジック
    } catch (\Throwable $e) {
        throw new \UnserializationFailedException(previous: $e);
    }
}
```

また、`UnserializationFailedException`の実装は以下のとおりです。

```php
/**
 * @strict-properties
 */
class UnserializationFailedException extends \Exception
{
}
```

### Increase the error reporting severity in the unserialize() parser

`unserialize`ハンドラに辿り着く前に、文字列の構文エラー、範囲外の整数値を渡した、などの理由で`unserialize`に失敗することがあります。
現在これらは例外にならず、かわりにE_NOTICEもしくはE_WARNING、あるいはその両方が発生します。

これらのエラーは統一する必要があるでしょう。
特に`unserialize`ハンドラまで辿り着かずに失敗しているということは、安全でない操作が行われていることを意味するので、厳しい方向にすべきでしょう。

・serializeの出力にバイナリセーフではない変換や保存を行うと、シリアライズオブジェクトは完全に破壊されます。
・serializeの出力を文字コードの異なるストレージに保存すると、バイト長が変わる可能性があり、非ASCIIのシリアライズオブジェクトが読めなくなります。
・信頼できない入力をunserializeに渡すと、攻撃者は任意のエラーを発生させられるだけではなく、任意のリモートコードを実行される場合があります。

これらのケースに共通するものとして、シリアライズされた元のデータがそのままunserializeに渡ってきていないということです。
最も良かった場合、unserializeは単に失敗してfalseが返ります。
最も良くない場合、中身が想定外に書き換わります。

少なくともE_NOTICEはE_WARNINGに変更し、常にE_WARNINGが発生するようにすべきでしょう。

しかしそれよりも、単純にUnserializationFailedExceptionを投げるようにした方がより良い解決策かもしれません。
これまで例外ハンドリングをしてこなかったアプリケーションにおいて、この変更は急激なものかもしれません。
しかしそのようなアプリケーションも、そもそも元からThrowableをキャッチすることはできていなければなりません。
ここで一貫してUnserializationFailedExceptionを投げるようにすることで、統一されたエラー処理の利点を利用することができるようになります。


### Resulting code

冒頭の例は、こう書けるようになります。

```php
try {
    $result = unserialize($serialized);
    var_dump($result); // try中に他の処理を書いてもいい。
                       // こちらがエラーを出してもUnserializationFailureExceptionにはキャッチされない。
} catch (\UnserializationFailureException $e) {
    // unserializeが失敗した
}
```

E_WARNINGとE_NOTICEがUnserializationFailureExceptionに引き上げられた場合でも全く同じです。


## Backward Incompatible Changes

後方互換性のない変更点。

### Addition of a new exception class

新たな例外クラス、UnserializationFailedExceptionは使用できなくなります。
GitHubで見つからなかったので、実質的に影響はないでしょう。

### Existing error handling for unserialize() might need to be updated

エラーハンドリング方法が変わりますが、これは大きな問題にはならないでしょう。
現在の[unserialize](https://www.php.net/manual/en/function.unserialize.php)のエラーは一貫性がないため、全てをキャッチするためには`catch(\Throwable)`としなければならないのですが、そうなっていれば今後もそのまま動作するからです。

## Proposed PHP Version(s)

PHP8.3

## RFC Impact

既存のエクステンションは、`unserialize`のエラー処理を行っている場合は、より適切な例外を発するように変更してもよいでしょう。

[igbinary](https://www.php.net/manual/ja/book.igbinary.php)などシリアライズ機能そのものをカスタムする拡張機能は、動作がデフォルトシリアライザと一致するように書き替える必要があるかもしれません。

## Open Issues

既知の問題点は特にありません。

## Unaffected PHP Functionality

`unserialize()`と関連しない機能に影響はありません。
`unserialize()`・`__wakeup()`・`Serializable::unserialize()`を使っている場合、その処理方法によっては影響を受けるかもしれません。


## Future Scope

将来の展望であり、このRFCには含まれません。

・`__PHP_Incomplete_Class`を作るかわりに例外を出す。 https://externals.io/message/118566#118613
・ini設定`unserialize_callback_func`をPHP側から設定できるようにする。 https://externals.io/message/118566#118672
・末尾の異常データをエラーにする。 https://github.com/php/php-src/pull/9630

## Proposed Voting Choices

投票者の2/3の賛成で受理されます。

投票期間は2022/09/22から2022/10/07です。

『PHP8.3で例外UnserializationFailedExceptionを追加する』『PHP8.3でE_NOTICEをE_WARNINGにする』『PHP9.0でE_WARNINGをUnserializationFailedExceptionにする』それぞれについて投票が行われました。

『PHP8.3でE_NOTICEをE_WARNINGにする』は賛成33反対2で受理、『PHP9.0でE_WARNINGをUnserializationFailedExceptionにする』は賛成23反対8で受理されました。
『PHP8.3で例外UnserializationFailedExceptionを追加する』は賛成20反対12で却下されました。


## References

[Exceptionの実装](https://github.com/php/php-src/pull/9425)
[NoticeをWARNINGにする](https://github.com/php/php-src/pull/9629)


# 感想

[DateTime](https://qiita.com/rana_kualu/items/342aba05b612f5b237e2)に引き続き、[unserialize](https://www.php.net/manual/ja/function.unserialize)もいいかんじにエラーハンドリングできるようになります。

UnserializationFailedExceptionが導入されると、たとえはSymfonyでは[30行程度の修正が必要](https://github.com/symfony/symfony/pull/47882)になります。
そんなわけでBCbreakの多いUnserializationFailedExceptionの導入はPHP9になったようです。

今後はぜひ、高機能になったExceptionを使っていいかんじのエラーハンドリングをしてみましょう。
まあ私は面倒なんで『unserializeできんかった』一言で終わらせると思いますが。