マジックバイト検出時のIO状態破壊を修正
Marcel::MimeType.for(io) がMIMEタイプ判定後も呼び出し元のIOオブジェクトのエンコーディングを永続的に変更してしまう問題が修正されました。この修正により、MIME検出後にIOを読み続けても元のエンコーディングが維持されます。
背景
Marcel::MimeType.for(io) はMIMEタイプ判定のためにIOのエンコーディングをBINARYに変更しており、判定後も元のエンコーディングに戻していませんでした。たとえば以下のように、UTF-8として開いたStringIOがMIME検出後にASCII-8BITに変化してしまいます。
io = StringIO.new("hello world")
io.external_encoding # => #<Encoding:UTF-8>
Marcel::MimeType.for(io)
io.external_encoding # => #<Encoding:BINARY (ASCII-8BIT)>
StringIOの場合、内部で保持しているStringオブジェクト自体も再タグ付けされます。その後このデータをJSONエンコードすると、json gemの「UTF-8 string passed as BINARY」警告が発生し、json 3.0ではエラーになります。
この挙動の根本原因は、#30 でmimemagicから引き継がれた io.binmode と io.set_encoding(Encoding::BINARY) の2行です。これらはマジックバイト読み取り前にIOをバイナリモードに切り替えるものでしたが、実際には magic_match_io がBINARYエンコーディングのバッファ経由で IO#read(n, buffer) を呼び出しているため、IO自体のモードに関わらずバイナリ結果が得られます。つまり、IOの状態を変更する2行は最初から冗長だったことになります。
なお #140 では、この set_encoding 呼び出しがRuby 3.4でStringIOのfrozen文字列警告を引き起こす問題への回避策として io.close_write や +io.to_s(ミュータブル文字列へのコピー)が追加されていました。今回の修正ではそもそもの原因を取り除くため、これらの回避策も不要になります。
技術的な変更
lib/marcel/magic.rb の magic_match メソッドから、冗長なIO状態変更とその回避策がまとめて削除されました。
変更前:
def self.magic_match(io, method)
return magic_match(StringIO.new(+io.to_s), method) unless io.respond_to?(:read)
# Make StringIO readonly before encoding changes to prevent Ruby 3.4 frozen string warnings.
# Should be fixed in Ruby 3.5+: https://redmine.ruby-lang.org/issues/21280
io.close_write if io.respond_to?(:closed_write?) && !io.closed_write?
io.binmode if io.respond_to?(:binmode)
io.set_encoding(Encoding::BINARY) if io.respond_to?(:set_encoding)
buffer = (+"").encode(Encoding::BINARY)
MAGIC.send(method) { |type, matches| magic_match_io(io, matches, buffer) }
end
変更後:
def self.magic_match(io, method)
return magic_match(StringIO.new(io.to_s), method) unless io.respond_to?(:read)
buffer = (+"").encode(Encoding::BINARY)
MAGIC.send(method) { |type, matches| magic_match_io(io, matches, buffer) }
end
削除されたのは次の3点です。
-
io.close_write(#140 で追加されたRuby 3.4向け回避策) -
io.binmode(mimemagicから引き継がれた冗長な呼び出し) -
io.set_encoding(Encoding::BINARY)(同上)
また、#read を持たないオブジェクト(文字列など)をStringIOに変換するフォールバック行の +io.to_s(ミュータブルコピー)が io.to_s に戻っています。set_encoding の呼び出しが消えたことで、StringIOのfrozen文字列を直接渡しても問題が起きなくなったためです。
リグレッションテストとして、MIME検出前後で external_encoding が変化しないことを確認するテストが追加されています。
test "does not mutate the source IO's encoding" do
io = StringIO.new("hello world")
original_encoding = io.external_encoding
Marcel::MimeType.for(io)
assert_equal original_encoding, io.external_encoding
end
設計判断
冗長なコードを除去することで回避策も一掃するアプローチが採用されました。
#140 はRuby 3.4の警告を抑制するために close_write というWorkaroundを追加しましたが、その警告自体が「冗長な set_encoding 呼び出しがfrozen文字列を変更しようとしている」ことに起因していました。本PRはWorkaroundを重ねるのではなく、原因である binmode/set_encoding 呼び出しを削除することを選択しています。PRの説明では2つのコミットに分けて「冗長な変更の削除」と「その回避策の削除」が段階的に整理されており、変更の因果関係が追いやすい構成になっています。
バイナリバッファ経由の IO#read(n, buffer) がIO自体のモードに依存しないという事実は、#30 のmimemagic移植時から存在していましたが、長らく見過ごされていました。今回の変更はその見落としを遡って修正する形になっています。
まとめ
冗長だったIO状態変更コードを根本から取り除くことで、IOの副作用汚染・Ruby 3.4の警告・json 3.0でのエラーという3つの問題が一度に解消されました。Workaroundを積み重ねるのではなく、不要なコードを削除することで複雑さを減らすという設計判断は、既存のWorkaroundが何のために存在したかを正確に理解した上で初めて可能になります。