PostgreSQL `cidr`/`inet` カラムのIPv6プレフィックスがスキーマダンプで失われるバグを修正
PostgreSQLの cidr / inet カラムにIPv6アドレスをデフォルト値として設定した場合、schema.rb へのダンプ時にサブネットプレフィックスが失われるサイレントなデータ破壊バグが修正されました。10年来の「IPv4専用」の仮定が原因で、/32 プレフィックスを持つIPv6アドレスが特に影響を受けていました。
背景
OID::Cidr#type_cast_for_schema はスキーマダンパーが schema.rb にデフォルト値を書き出す際に使用されるメソッドですが、2014年の導入(4321cd09a5)以来、IPv4専用の仮定を持ち続けていました。具体的には、プレフィックス長が /32 に等しい場合にプレフィックスを省略する実装になっており、IPv4のフルマスクである /32 を「省略してよい完全アドレス」と同一視していました。
IPv6ではフルマスクは /128 であり、/32 は「ISPや地域に割り当てられるサブネット」として広く使われる有効なプレフィックスです。そのため、"::/32" というデフォルト値は schema.rb に "::" として書き出され、db:schema:load 時に IPAddr.new("::")として読み込まれると /128 として解釈されてしまいます。マイグレーションが意図したネットワーク情報が、スキーマのラウンドトリップを経て静かに失われていました。
以下のコードでこの挙動を再現できます:
require "ipaddr"
orig = IPAddr.new("::/32")
dump = orig.prefix == 32 ? %("#{orig}") : %("#{orig}/#{orig.prefix}")
dump # => "\":::\""
reloaded = IPAddr.new(dump[1..-2])
reloaded.prefix # => 128 (元は 32)
この問題はIPv6ネットワークを cidr / inet カラムのデフォルト値として使用しているアプリケーションのみに影響し、IPv4のみを使用しているアプリケーションには影響がありません。
技術的な変更
修正は activerecord/lib/active_record/connection_adapters/postgresql/oid/cidr.rb の type_cast_for_schema メソッド1行の変更に集約されます。プレフィックスの省略判定を「アドレスファミリーに応じたフルマスク長との比較」に改めることで、IPv4・IPv6の両方を正しく扱えるようになっています。
変更前:
def type_cast_for_schema(value)
# If the subnet mask is equal to /32, don't output it
if value.prefix == 32
"\"#{value}\""
else
"\"#{value}/#{value.prefix}\""
end
end
変更後:
def type_cast_for_schema(value)
# If the subnet mask covers the full address, don't output it
if value.prefix == (value.ipv6? ? 128 : 32)
"\"#{value}\""
else
"\"#{value}/#{value.prefix}\""
end
end
この変更によって各アドレスの扱いが次のように変わります:
-
IPv4(既存動作を維持):
192.168.1.0/32→"192.168.1.0"、192.168.1.0/24→"192.168.1.0/24" -
IPv6(バグ修正):
::/32→"::/32"、fe80::/10→"fe80::/10"、::/0→"::/0" -
IPv6フルマスク(対称性の向上):
::1/128→"::1"(/128を省略)
::1/128 のケースは動作変更ですが、IPAddr.new("::1") を経由しても同一アドレスに復元されるため、ラウンドトリップの正確性は保たれます。また、OID::Inet は OID::Cidr を継承しており type_cast_for_schema をオーバーライドしていないため、この修正は inet カラムにも自動的に適用されます。
テスト面では activerecord/test/cases/adapters/postgresql/cidr_test.rb に2つのテストケースが追加されています。IPv4の既存動作確認とIPv6の修正後の動作確認を網羅しており、既存の network_test.rb はIPv4デフォルト値のスキーマダンプアサーションを含むためIPv4リグレッションのチェックとしても機能します。
設計判断
value.ipv6? ? 128 : 32 という三項演算子による分岐が採用されました。IPv4とIPv6のフルマスク長をハードコードした最小限の変更であり、既存のコードパスに影響を与えません。IPAddr の ipv6? メソッドを活用することで、アドレスファミリーの判定をRuby標準ライブラリに委ねており、新たな依存を導入していない点も特徴的です。
IPv6フルマスク(/128)を省略する変更はバグ修正の副産物として含まれていますが、PR本文では「対称性のための化粧的変更(cosmetic change)」と明示されています。ラウンドトリップの正確性は担保されているため問題はありませんが、既存の schema.rb を持つプロジェクトで ::1/128 形式のデフォルト値がある場合、次回のスキーマダンプで ::1 に書き換わることになります。
まとめ
10年間見過ごされてきたIPv4中心の仮定を1行の条件式で修正し、IPv6サブネットプレフィックスのサイレントな破壊を防ぎます。変更行数は最小限ですが、cidr と inet の両型に同時に適用され、スキーマダンプの信頼性をIPv6環境でも保証する実質的な修正です。