綴Photo には「あの頃の記憶(特定の年月)の写真へ戻る」導線がある。モバイルの画面遷移は go_router が担当していて、/tsuzuru/auto?mode=passed&year=2024&month=3 のような URL の形(パス+クエリ) で「どの画面に・どの年月で飛ぶか」を表します。これは Web のページアドレスではなく、アプリ内の画面遷移を表すルートで、外部から特定画面へ飛ばす deep link も同じ形を使います。このルートから年月を復元する処理に、地味だが面白い穴があった。month=13 のような範囲外の値が検証されていなかった。
これ自体はアプリが普通に使う分には絶対に起きません。でも「なぜ実害がほぼ無いのに直したか」と「DateTime の黙った正規化」が、考える材料として面白かったのでまとめる。
この記事で扱う観察ポイント
DateTimeは範囲外の月日を例外なしで黙って正規化します- だから不正な月は「クラッシュ」ではなく「別の月を表示」という、より気づきにくい壊れ方をします
- 似た2つの入口で、片方だけ検証していた非対称
- 「アプリが生成しない値」と「届かない値」は別
範囲チェックが無かった
ルートから復元する処理は、こうだった。
if (modeValue == 'passed') {
final year = int.tryParse(queryParameters['year'] ?? '');
final month = int.tryParse(queryParameters['month'] ?? '');
if (year != null && month != null) {
// ← null チェックだけ。1〜12 かどうかは見ていない
return TsuzuruRouteArgs.passed(year: year, month: month);
}
}int.tryParse で数値になるかは見ていますが、month が 1〜12 に収まっているかは見ていません。month=13 でも month=0 でも、そのまま通ります。
DateTime は黙って正規化する
「不正な月ならどこかでエラーになって気づくのでは?」と思うが、ならない。この年月は最終的に DAO でこう使われる。
final start = DateTime(year, month);
final end = DateTime(year, month + 1);ここがポイント。Dart の DateTime コンストラクタは、範囲外の月日を例外を投げずに繰り上げ・繰り下げで正規化します。
DateTime(2024, 13); // → 2025-01-01(13月=翌年1月)
DateTime(2024, 0); // → 2023-12-01(0月=前年12月)
DateTime(2024, 2, 30); // → 2024-03-01(2月30日=3月1日あたり)非対称だった: もう一方の入口は検証していた
面白いのは、ルートから年月を復元する経路がもう1つあって(期間指定モードの from/to)、そっちは正規表現でフォーマットを見たうえで 1〜12 をちゃんと弾いていた。
// 期間モード側(こっちは検証していた)
if (year == null || month == null || month < 1 || month > 12) {
return null;
}片方だけ守られている非対称が、見落としを生んでいた。「似た処理がすぐ隣にあるなら、守り方も揃っているはず」という思い込みは危ないです。
直し: 境界をパース時に弾く
復元の入口(パース時)で範囲を検証し、外れていたら既存の安全なフォールバック(自動モード)に落とします。
最初は passed の分岐に period と同じ条件をそのまま書いて揃えた。でも年月を受け取る入口は実は3つ(period / passed / deep link redirect)あって、各所に同じ条件をベタ書きすると「片方だけ直し忘れる」——まさに今回のバグ——を繰り返しかねません。そこで検証を1つの番人(述語関数)にまとめ、3入口すべてがそれを呼ぶ形にした。
// period / passed / deep link の3入口が共有する「番人」
bool isValidYearMonth(int year, int month) =>
year >= 1 && year <= 9999 && month >= 1 && month <= 12;
// passed の復元(パース時に弾く)
if (modeValue == 'passed') {
final year = int.tryParse(queryParameters['year'] ?? '');
final month = int.tryParse(queryParameters['month'] ?? '');
if (year != null && month != null && isValidYearMonth(year, month)) {
return TsuzuruRouteArgs.passed(year: year, month: month);
}
// 範囲外は不正な deep link とみなし、auto に落とす
}これで「検証が揃っていること」が、人の注意ではなくコードの構造で保証されます。
あわせて、これを固定するユニットテストも入れた(isValidYearMonth 自体/passed の month=13・month=0・year=0・year=10000 がそれぞれ auto に落ちるか)。
なぜ実害が小さいのに直したか
正直、実害はほぼ無い。アプリ自身は実在の年月(1〜12)からしかルートを組み立てないので、month=13 を生成することはありません。これが起きるのは、壊れた deep link を踏んだ・手書きのリンクで範囲外の値が渡ってきた、くらいの経路だけです。
AI に書いてもらった実装は「アプリが普通に渡す値」だけを相手にしがちで、この境界チェックも最初は素通りだった。外部入力を「任意の値が飛んでくる前提」で受け直すのは、コードを書いた後に自分がレビューで補う部分だった。
それでも直したのは2つの理由。
- 「アプリが生成しない」と「届かない」は別。ルート/deep link(外部入力)は、アプリ側のコードがどう作るかと無関係に、任意の値が飛んでくる前提で受けるべきです。将来 deep link を外部公開したら、ここは普通に踏まれます。
- 非対称が気持ち悪い。次に触る人(未来の自分含む)を確実に混乱させます。最初はその場で同じ条件を足して揃え、最終的には共有関数1つにまとめて、揃っていること自体を構造で固定した。
学びポイント
- 境界チェックは「落ちないから要りません」ではなく、「静かに間違わせないために要ります」
- 検証は入口(パース時)で。似た入口が複数あるなら守り方を揃える——揃え方は2通りあります。各所に同じ条件を書く(手で対称に保つ)か、1つの述語関数にまとめて全入口が呼ぶ(構造的に対称)。入口が増えるほど後者が効きます
- 「アプリ自身は生成しない値」でも、外部入力(ルート・deep link)としては届く前提で受けます
おわりに
DateTime(year, month) が黙って繰り上げる挙動は、便利な場面もあります(日付の足し算で DateTime(y, m + 1) と書けるのはこの正規化のおかげ)。でも「便利な暗黙の補正」は、検証していない入力と組み合わさると「静かに間違う」に化けます。便利さと危うさが、同じ仕組みの裏表になっている。
この記事は2026年5月時点の自分の理解で書いています。