Skip to content

fix: keep truncated UnwrapException message valid UTF-8#99

Merged
valbeat merged 2 commits into
mainfrom
fix/unwrap-exception-multibyte-truncation
Jul 8, 2026
Merged

fix: keep truncated UnwrapException message valid UTF-8#99
valbeat merged 2 commits into
mainfrom
fix/unwrap-exception-multibyte-truncation

Conversation

@valbeat

@valbeat valbeat commented Jul 8, 2026

Copy link
Copy Markdown
Owner

概要

コードレビュー(Opus 独立レビュー含む)で見つかった実バグの修正です。UnwrapException::describe() の値要約の切り詰めがバイト単位の substr() を使っているため、マルチバイト文字を含む長い値では 120 バイト目で文字の途中を切って不正な UTF-8 を生成し、例外メッセージの json_encode() が失敗します。

再現(修正前)

$err = new Err(str_repeat('', 200)); // 各文字 3 バイト
$err->unwrap();
// → UnwrapException のメッセージが不正な UTF-8 を含む
// → json_encode(['message' => $e->getMessage()]) === false
//    (json_last_error: "Malformed UTF-8 characters")

エラーハンドリング経路(=すでに何か失敗している最中)で、ログ基盤が例外メッセージを JSON 化しようとすると二次障害になります。ドックブロックがすべて日本語のプロジェクトであり、エラー値に日本語文字列が載るのは現実的なシナリオです。

修正

  • src/UnwrapException.php: 切り詰めを substr()mb_strcut($summary, 0, 120, 'UTF-8') に変更。mb_strcut はバイト上限を超えない範囲で文字境界を保って切ります(バイト予算はそのまま維持)。
  • composer.json: これにより生じる ext-mbstring 依存を require に宣言。

検証

  • 追加テスト unwrap_withLongMultibyteError_keepsValidUtf8Messagetests/ErrTest.php)で、切り詰め後のメッセージが mb_check_encoding(..., 'UTF-8') を通り json_encode が成功することをピン留め
  • 修正後の実測: bytes=176 valid_utf8=yes json=ok
  • 78 tests / 124 assertions パス、PHPStan level max エラーなし、cs-fixer クリーン

TDD

  1. マルチバイト切り詰めの回帰テストを先に追加し RED(truncated message must remain valid UTF-8 failure)を確認してコミット
  2. mb_strcut で実装して GREEN

https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK

Summary by CodeRabbit

  • Bug Fixes

    • 長いエラーメッセージの切り詰め時に、マルチバイト文字が崩れにくくなりました。
    • 文字化けや不正な UTF-8 が混ざる可能性を減らし、より安全にエラーメッセージを扱えるようになりました。
  • Chores

    • 必要な PHP 拡張の要件が明示されました。
  • Tests

    • 長い多バイト文字を含むエラー表示が正しく切り詰められることを確認するテストを追加しました。

valbeat added 2 commits July 8, 2026 17:31
UnwrapException::describe() truncates the value summary at a byte
offset, so a long multibyte string (e.g. Japanese) gets cut mid
character and the exception message becomes invalid UTF-8 — which
makes json_encode() of the message fail. Currently RED.

Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK
describe() truncated the value summary with a byte-based substr(), so
a long multibyte error value (e.g. a Japanese string) was cut mid
character and the exception message became invalid UTF-8. That makes
json_encode() of the message return false — a secondary failure in
logging pipelines exactly when something has already gone wrong.

Use mb_strcut(), which respects the byte budget without splitting a
character, and declare the resulting ext-mbstring dependency.

Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK
@valbeat valbeat self-assigned this Jul 8, 2026
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

UnwrapException::describe() の文字列切り詰め処理を substr() から mb_strcut() に変更し、マルチバイト文字境界を壊さないようにした。composer.jsonext-mbstring 依存を追加し、UTF-8妥当性を検証するテストを新規追加した。

Changes

UTF-8安全な切り詰め処理

Layer / File(s) Summary
mb_strcut への置き換えと依存追加
src/UnwrapException.php, composer.json
describe()MAX_SUMMARY_LENGTH 超過時の切り詰めを substr() から mb_strcut(..., 'UTF-8') に変更し、composer.json に ext-mbstring 依存(*)を追加。
UTF-8妥当性検証テスト
tests/ErrTest.php
unwrap_withLongMultibyteError_keepsValidUtf8Message テストを追加し、切り詰め後メッセージのUTF-8妥当性、json_encode可能性、'(truncated)' の含有を検証。

Estimated code review effort: 1 (Trivial) | ~5 minutes


レビューコメント

  • mb_strcut() への変更は妥当です。バイト単位切り詰めの substr() はマルチバイト文字(例: 日本語)境界を破壊し、不正なUTF-8バイト列を生成しUTF-8として不正な文字列を返す危険がありましたが、mb_strcut() は文字境界を尊重するため安全です。
  • ext-mbstringcomposer.jsonrequire に明示したのは適切です。mb_strcut() はmbstring拡張が有効でないと利用できないため、実行環境依存の暗黙的な前提を明示化した点は評価できます。ただし本番・CI両方の環境で当該拡張が有効か確認してください。
  • テストは切り詰め後の妥当性(json_encode 可能性含む)を明示的に検証しており、Result型の不変条件(unwrap() 失敗時のメッセージが常に有効な文字列であること)を担保する上で有効です。
  • 軽微な確認事項として、mb_strcut() の第三引数エンコーディングは常に 'UTF-8' 固定である点、入力が非UTF-8の場合の挙動(多くの場合そのまま処理を続行しても問題ないか)を念のため確認しておくと安心です。
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed UnwrapException の切り詰め時に UTF-8 を保つ修正内容を適切に要約しています。
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/unwrap-exception-multibyte-truncation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (308fd0d) to head (066a205).
⚠️ Report is 3 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff             @@
##                main       #99   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
  Complexity        65        65           
===========================================
  Files              4         4           
  Lines            130       130           
===========================================
  Hits             130       130           
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds the ext-mbstring extension dependency and updates UnwrapException.php to use mb_strcut instead of substr when truncating error messages, preventing malformed UTF-8 sequences. A new test is added to verify this behavior. The review feedback suggests improving the new test by adding a $this->fail() call inside the try block to ensure that the test fails if the expected UnwrapException is not thrown.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread tests/ErrTest.php
Comment on lines +150 to +152
try {
$err->unwrap();
} catch (UnwrapException $e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

このテストでは、$err->unwrap() が例外をスローしなかった場合(バグなどにより正常終了してしまった場合)、catch ブロックがスキップされてテストが正常にパスしてしまいます(PHPUnitの設定によっては assertions が実行されず risky と判定されることはありますが、明示的な失敗にはなりません)。

例外が確実にスローされることを保証するために、try ブロックの最後に $this->fail() を追加することをお勧めします。

        try {
            $err->unwrap();
            $this->fail('Expected UnwrapException was not thrown.');
        } catch (UnwrapException $e) {

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/ErrTest.php`:
- Around line 150-152: The ErrTest::unwrap() test can silently pass if
$err->unwrap() does not throw, because the catch block is never reached and no
assertion runs. Update the try/catch around $err->unwrap() to explicitly fail
the test when no UnwrapException is thrown by adding a failure path immediately
after the unwrap call, so the test only passes when the exception is actually
raised and caught.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 838389c9-9247-490f-be28-ce3e3f5d1125

📥 Commits

Reviewing files that changed from the base of the PR and between 308fd0d and 066a205.

📒 Files selected for processing (3)
  • composer.json
  • src/UnwrapException.php
  • tests/ErrTest.php

Comment thread tests/ErrTest.php
Comment on lines +150 to +152
try {
$err->unwrap();
} catch (UnwrapException $e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

unwrap() が例外を投げなかった場合の silent pass を防止すべき

try ブロック内の $err->unwrap() が例外を投げなければ、catch ブロック内のアサーションが一切実行されず、テストが無条件で通過してしまいます。unwrap() の後に $this->fail() を追加して、例外が投げられなかった場合は確実に失敗させるべきです。

As per coding guidelines, t-wada 流 TDD を意識し、テストの信頼性を高めるべきです。

✅ 提案する修正
         try {
             $err->unwrap();
+            $this->fail('Expected UnwrapException was not thrown');
         } catch (UnwrapException $e) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
$err->unwrap();
} catch (UnwrapException $e) {
try {
$err->unwrap();
$this->fail('Expected UnwrapException was not thrown');
} catch (UnwrapException $e) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ErrTest.php` around lines 150 - 152, The ErrTest::unwrap() test can
silently pass if $err->unwrap() does not throw, because the catch block is never
reached and no assertion runs. Update the try/catch around $err->unwrap() to
explicitly fail the test when no UnwrapException is thrown by adding a failure
path immediately after the unwrap call, so the test only passes when the
exception is actually raised and caught.

Source: Path instructions

@valbeat
valbeat marked this pull request as ready for review July 8, 2026 08:39
Copilot AI review requested due to automatic review settings July 8, 2026 08:39
@valbeat
valbeat merged commit 41da4e7 into main Jul 8, 2026
12 checks passed
@valbeat
valbeat deleted the fix/unwrap-exception-multibyte-truncation branch July 8, 2026 08:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

UnwrapException::describe() が長いマルチバイト文字列を切り詰める際に substr() によって UTF-8 を壊し、例外メッセージの json_encode() が失敗しうる実バグを修正する PR です。エラーハンドリング経路での二次障害(ログ JSON 化失敗)を防ぐ目的で、切り詰め処理を UTF-8 の文字境界を保つ方法へ変更しています。

Changes:

  • UnwrapException::describe() の切り詰めを substr() から mb_strcut(..., 'UTF-8') に変更し、不正 UTF-8 の生成を回避
  • ext-mbstringcomposer.jsonrequire に追加
  • 長いマルチバイトエラー値で、切り詰め後メッセージが valid UTF-8 かつ json_encode 可能であることを検証するテストを追加

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
tests/ErrTest.php マルチバイト長文エラー値での切り詰め後メッセージが UTF-8 として正当であることを検証するテストを追加
src/UnwrapException.php 値要約の切り詰めを mb_strcut に変更し、文字境界を保って valid UTF-8 を維持
composer.json 実装変更に伴う ext-mbstring 依存をランタイム要件として明示

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/ErrTest.php
Comment on lines +150 to +152
try {
$err->unwrap();
} catch (UnwrapException $e) {
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.

2 participants