`BOOTSNAP_READONLY`時にRubyソースのエンコーディングが崩れるバグを修正
BOOTSNAP_READONLY=1環境でキャッシュからRubyソースを読み込む際、文字列エンコーディングがASCII-8BITに変化してしまうバグが修正されました。1.24.0で導入されたこの問題により、純粋なASCII範囲外のリテラル文字列を扱う際にエンコーディングエラーが発生していました。
背景
bootsnap 1.24.0へのアップグレード後、本番環境でエンコーディング関連のエラーが報告されていました(#537)。
報告されたエラーの特徴は、文字列のエンコーディングがUTF-8からASCII-8BITに変化してしまうというものでした。この変化により、たとえばgsub呼び出し時にincompatible encoding regexp match例外が発生するなど、バリデーション処理が失敗するケースが確認されていました。影響範囲はRackリクエストのrequest.paramsに含まれるcontrollerやaction_nameといった文字列にも及んでおり、BOOTSNAP_READONLY=1でプリコンパイル済みキャッシュを使用する環境で再現していました。
問題の根本原因は、ISeq.compileが引数として渡されたソース文字列のエンコーディングをそのまま使用するという挙動にあります。キャッシュから読み込んだバイナリデータをASCII-8BITのままcompileに渡すと、コンパイル結果のエンコーディング情報もそれに引きずられてしまいます。
技術的な変更
修正はlib/bootsnap/compile_cache/iseq.rbのinput_to_outputメソッド1箇所に集中しており、Encoding.default_externalを使ってソース文字列のエンコーディングを明示的に設定するようになりました。
変更前:
def input_to_output(source, path, _kwargs)
RubyVM::InstructionSequence.compile(source, path, path, nil, @compile_options)
end
変更後:
def input_to_output(source, path, _kwargs)
RubyVM::InstructionSequence.compile(
source.force_encoding(Encoding.default_external),
path,
path,
nil,
@compile_options,
)
end
force_encoding はバイト列を変換せずエンコーディングラベルだけを付け替えるメソッドです。キャッシュから読み込んだバイナリデータのバイト列は変えずに、Rubyが「このソースはどのエンコーディングで書かれているか」を判断する際にEncoding.default_external(通常はUTF-8)を正しく参照させることができます。
回帰テストとしてtest/compile_cache/iseq_cache_test.rbにも検証が追加されました。
def test_input_to_output_encoding
compiler = Bootsnap::CompileCache::ISeq::DEFAULT
source = "_a = 'fée'.encoding".b
iseq = compiler.input_to_output(source, "a.rb", nil)
assert_equal Encoding.default_external, iseq.eval
end
テストでは'fée'(ASCII範囲外の文字を含む文字列)のソースコードをバイナリエンコーディング(.b)で渡し、コンパイル・評価後の文字列エンコーディングがEncoding.default_externalと一致することを確認しています。
設計判断
force_encodingを用いてコンパイル直前にエンコーディングを付与するという最小限の修正が採用されました。
PR本文では「ISeq.compileはソース文字列のエンコーディングを使用するため、エンコーディングを変更する必要がある」と明確に説明されています。キャッシュから読み込むバイト列自体はUTF-8として有効なデータであり、バイト変換は不要です。force_encodingはバイト列を保持したままラベルだけを変えるため、この用途に適しています。修正箇所をinput_to_outputの呼び出し1点に限定したことで、変更の影響範囲が最小化されています。
まとめ
本修正は、キャッシュ読み込みパスでASCII-8BITのバイト列をEncoding.default_externalとして解釈させる1行の追加によって、1.24.0で混入したエンコーディング崩れを解消しています。force_encodingという既存のRuby APIを的確に用いることで、バイト列を変えずにエンコーディングの誤認識のみを修正した、影響範囲の小さな実装といえます。